Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fastify-react): server actions #139

Merged
merged 9 commits into from Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 68 additions & 10 deletions packages/fastify-react/index.js
Expand Up @@ -14,6 +14,9 @@ import * as devalue from 'devalue'
// <title>, <meta> and <link> elements
import Head from 'unihead'

// Used for removing <script> tags when serverOnly is enabled
import { HTMLRewriter } from 'html-rewriter-wasm'

// Helpers from the Node.js stream library to
// make it easier to work with renderToPipeableStream()
import {
Expand All @@ -27,13 +30,14 @@ import RouteContext from './server/context.js'

export default {
prepareClient,
prepareServer,
createHtmlFunction,
createRenderFunction,
createRouteHandler,
createRoute,
}

export async function prepareClient({
async function prepareClient({
routes: routesPromise,
context: contextPromise,
...others
Expand All @@ -44,17 +48,15 @@ export async function prepareClient({
}

// The return value of this function gets registered as reply.html()
export function createHtmlFunction(source, scope, config) {
async function createHtmlFunction(source, scope, config) {
// Templating functions for universal rendering (SSR+CSR)
const [unHeadSource, unFooterSource] = source.split('<!-- element -->')
const unHeadTemplate = createHtmlTemplateFunction(unHeadSource)
const unFooterTemplate = createHtmlTemplateFunction(unFooterSource)
// Templating functions for server-only rendering (SSR only)
const [soHeadSource, soFooterSource] = source
// Unsafe if dealing with user-input, but safe here
// where we control the index.html source
.replace(/<script[^>]+type="module"[^>]+>.*?<\/script>/g, '')
.split('<!-- element -->')
const [soHeadSource, soFooterSource] = (await removeModules(source)).split(
'<!-- element -->',
)
const soHeadTemplate = createHtmlTemplateFunction(soHeadSource)
const soFooterTemplate = createHtmlTemplateFunction(soFooterSource)
// This function gets registered as reply.html()
Expand Down Expand Up @@ -93,7 +95,7 @@ export function createHtmlFunction(source, scope, config) {
}
}

export async function createRenderFunction({ routes, create }) {
async function createRenderFunction({ routes, create }) {
// create is exported by client/index.js
return (req) => {
// Create convenience-access routeMap
Expand All @@ -117,14 +119,37 @@ export async function createRenderFunction({ routes, create }) {
}
}

export function createRouteHandler({ client }, scope, config) {
function createRouteHandler({ client }, scope, config) {
return (req, reply) => {
reply.html(reply.render(req))
return reply
}
}

export function createRoute(
function prepareServer(server) {
let url
server.decorate('serverURL', { getter: () => url })
server.addHook('onListen', () => {
const { port, address, family } = server.server.address()
const protocol = server.https ? 'https' : 'http'
if (family === 'IPv6') {
url = `${protocol}://[${address}]:${port}`
} else {
url = `${protocol}://${address}:${port}`
}
})
server.decorateRequest('fetchMap', null)
server.addHook('onRequest', (req, _, done) => {
req.fetchMap = new Map()
done()
})
server.addHook('onResponse', (req, _, done) => {
req.fetchMap = undefined
done()
})
}

export async function createRoute(
{ client, handler, errorHandler, route },
scope,
config,
Expand All @@ -138,6 +163,11 @@ export function createRoute(
client.context,
)
}

if (route.configure) {
await route.configure(scope)
}

if (route.getData) {
// If getData is provided, register JSON endpoint for it
scope.get(`/-/data${route.path}`, {
Expand Down Expand Up @@ -189,3 +219,31 @@ export function createRoute(
...route,
})
}

async function removeModules(html) {
const decoder = new TextDecoder()

let output = ''
const rewriter = new HTMLRewriter((outputChunk) => {
output += decoder.decode(outputChunk)
})

rewriter.on('script', {
element(element) {
for (const [attr, value] of element.attributes) {
if (attr === 'type' && value === 'module') {
element.replace('')
}
}
},
})

try {
const encoder = new TextEncoder()
await rewriter.write(encoder.encode(html))
await rewriter.end()
return output
} finally {
rewriter.free()
}
}
5 changes: 4 additions & 1 deletion packages/fastify-react/package.json
Expand Up @@ -28,12 +28,15 @@
"./plugin": "./plugin.cjs"
},
"dependencies": {
"@fastify/vite": "^6.0.5",
"acorn-strip-function": "^1.1.0",
"devalue": "latest",
"history": "latest",
"html-rewriter-wasm": "^0.4.1",
"minipass": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "latest",
"react-router-dom": "^6",
"unihead": "latest",
"valtio": "latest"
},
Expand Down
11 changes: 8 additions & 3 deletions packages/fastify-react/plugin.cjs
@@ -1,8 +1,9 @@
const { readFileSync, existsSync } = require('fs')
const { dirname, join, resolve } = require('path')
const { fileURLToPath } = require('url')
const stripFunction = require('acorn-strip-function')

function viteReactFastifyDX(config = {}) {
function viteFastifyReact(config = {}) {
const prefix = /^\/:/
const routing = Object.assign(
{
Expand Down Expand Up @@ -100,11 +101,15 @@ function viteReactFastifyDX(config = {}) {
return id
}
},
load(id) {
load(id, options) {
if (!options?.ssr && !id.startsWith('/:') && id.match(/.(j|t)sx$/)) {
const source = readFileSync(id, 'utf8')
return stripFunction(source, 'configure')
}
const [, virtual] = id.split(prefix)
return loadVirtualModule(virtual)
},
}
}

module.exports = viteReactFastifyDX
module.exports = viteFastifyReact
2 changes: 2 additions & 0 deletions packages/fastify-react/server/context.js
Expand Up @@ -19,6 +19,7 @@ export default class RouteContext {
this.req = req
this.reply = reply
this.head = {}
this.actionData = {}
this.state = null
this.data = route.data
this.firstRender = true
Expand All @@ -42,6 +43,7 @@ export default class RouteContext {

toJSON() {
return {
actionData: this.actionData,
state: this.state,
data: this.data,
layout: this.layout,
Expand Down
Empty file.
27 changes: 26 additions & 1 deletion packages/fastify-react/virtual/core.jsx
Expand Up @@ -20,6 +20,30 @@ export function useRouteContext() {
return routeContext
}

let serverActionCounter = 0

export function createServerAction(name) {
return `/-/action/${name ?? serverActionCounter++}`
}

export function useServerAction(action, options = {}) {
if (import.meta.env.SSR) {
const { req, server } = useRouteContext()
req.route.actionData[action] = waitFetch(
`${server.serverURL}${action}`,
options,
req.fetchMap,
)
return req.route.actionData[action]
}
const { actionData } = useRouteContext()
if (actionData[action]) {
return actionData[action]
}
actionData[action] = waitFetch(action, options)
return actionData[action]
}

export function AppRoute({ head, ctxHydration, ctx, children }) {
// If running on the server, assume all data
// functions have already ran through the preHandler hook
Expand Down Expand Up @@ -62,14 +86,15 @@ export function AppRoute({ head, ctxHydration, ctx, children }) {
// biome-ignore lint/correctness/useExhaustiveDependencies: I'm inclined to believe you, Biome, but I'm not risking it.
useEffect(() => {
window.route.firstRender = false
window.route.actionData = {}
}, [location])

// If we have a getData function registered for this route
if (!ctx.data && ctx.getData) {
try {
const { pathname, search } = location
// If not, fetch data from the JSON endpoint
ctx.data = waitFetch(`${pathname}${search}`)
ctx.data = waitFetch(`/-/data${pathname}${search}`)
} catch (status) {
// If it's an actual error...
if (status instanceof Error) {
Expand Down
17 changes: 11 additions & 6 deletions packages/fastify-react/virtual/resource.js
@@ -1,7 +1,12 @@
const fetchMap = new Map()
const resourceMap = new Map()
const clientFetchMap = new Map()
const clientResourceMap = new Map()

export function waitResource(path, id, promise) {
export function waitResource(
path,
id,
promise,
resourceMap = clientResourceMap,
) {
const resourceId = `${path}:${id}`
const loaderStatus = resourceMap.get(resourceId)
if (loaderStatus) {
Expand Down Expand Up @@ -37,7 +42,7 @@ export function waitResource(path, id, promise) {
return waitResource(path, id)
}

export function waitFetch(path) {
export function waitFetch(path, options = {}, fetchMap = clientFetchMap) {
const loaderStatus = fetchMap.get(path)
if (loaderStatus) {
if (loaderStatus.error || loaderStatus.data?.statusCode === 500) {
Expand All @@ -59,7 +64,7 @@ export function waitFetch(path) {
data: null,
promise: null,
}
loader.promise = fetch(`/-/data${path}`)
loader.promise = fetch(path, options)
.then((response) => response.json())
.then((loaderData) => {
loader.data = loaderData
Expand All @@ -73,5 +78,5 @@ export function waitFetch(path) {

fetchMap.set(path, loader)

return waitFetch(path)
return waitFetch(path, options, fetchMap)
}
1 change: 1 addition & 0 deletions packages/fastify-react/virtual/routes.js
Expand Up @@ -93,6 +93,7 @@ function getRouteModuleExports(routeModule) {
streaming: routeModule.streaming,
clientOnly: routeModule.clientOnly,
serverOnly: routeModule.serverOnly,
...routeModule,
}
}

Expand Down
4 changes: 2 additions & 2 deletions starters/react-base/package.json
Expand Up @@ -11,8 +11,8 @@
},
"dependencies": {
"@fastify/one-line-logger": "^1.2.0",
"@fastify/vite": "^6.0.3",
"@fastify/react": "^0.5.0",
"@fastify/vite": "^6.0.5",
"@fastify/react": "workspace:^",
"fastify": "^4.24.3",
"history": "^5.3.0",
"minipass": "^7.0.4",
Expand Down
40 changes: 38 additions & 2 deletions starters/react-kitchensink/client/base.css
Expand Up @@ -58,7 +58,43 @@ html {
& img {
width: 14em;
}
& button {
margin: 0 0.5em;
}

button, input[type=button] {
margin: 0;
border: none;
box-shadow: none;
cursor: pointer;
color: #333;
font-size: 1.2em;
background: #ff80ff;
padding: 0.5em;
&:hover {
background: #ff5eff;
color: #000;
}
}

label {
margin: 0;
outline: none;
border: none;
box-shadow: none;
color: #fff;
font-size: 1.2em;
margin-right: 0.5em;
padding: 0.5em;
}

input {
margin: 0;
outline: none;
border: none;
box-shadow: none;
color: #333;
font-size: 1.2em;
background: #ccc;
margin-right: 0.5em;
border: 2px solid #ff80ff;
padding: calc(0.5em - 2px);
}
41 changes: 41 additions & 0 deletions starters/react-kitchensink/client/pages/actions/data.jsx
@@ -0,0 +1,41 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { createServerAction, useServerAction } from '/:core.jsx'

const accessCounter = createServerAction()

export function configure (server) {
let counter = 0
server.get(accessCounter, (_, reply) => {
reply.send({ counter: ++counter })
})
}

export default function Form () {
// useServerAction(endpoint) acts a React suspense resource,
// with the exception that data is retrieved only once per
// route and cleared only when the user navigates to another route.
const data = useServerAction(accessCounter)
const [counter, setCounter] = useState(data.counter)

// Just use endpoint string to retrieve fresh data on-demand
const incrementCounter = async () => {
const request = await fetch(accessCounter)
const data = await request.json(0)
setCounter(data.counter)
}

return (
<>
<h1>Using inline server GET handler</h1>
<p><code>useServerAction(endpoint)</code> acts a React Suspense resource,
with the exception that data is retrieved only once per
route and cleared only when the user navigates to another route.</p>
<p>Counter: {counter}</p>
<input type="button" value="Increment" onClick={incrementCounter} />
<p>
<Link to="/">Go back to the index</Link>
</p>
</>
)
}