diff --git a/packages/fastify-react/index.js b/packages/fastify-react/index.js index a07a739e..33ca8107 100644 --- a/packages/fastify-react/index.js +++ b/packages/fastify-react/index.js @@ -14,6 +14,9 @@ import * as devalue from 'devalue' // , <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 { @@ -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 @@ -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() @@ -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 @@ -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, @@ -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}`, { @@ -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() + } +} diff --git a/packages/fastify-react/package.json b/packages/fastify-react/package.json index 91697903..c898126d 100644 --- a/packages/fastify-react/package.json +++ b/packages/fastify-react/package.json @@ -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" }, diff --git a/packages/fastify-react/plugin.cjs b/packages/fastify-react/plugin.cjs index ca8630fd..92d8adb5 100644 --- a/packages/fastify-react/plugin.cjs +++ b/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( { @@ -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 diff --git a/packages/fastify-react/server/context.js b/packages/fastify-react/server/context.js index 12b1cbf4..a0f51d67 100644 --- a/packages/fastify-react/server/context.js +++ b/packages/fastify-react/server/context.js @@ -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 @@ -42,6 +43,7 @@ export default class RouteContext { toJSON() { return { + actionData: this.actionData, state: this.state, data: this.data, layout: this.layout, diff --git a/packages/fastify-react/virtual/components.js b/packages/fastify-react/virtual/components.js new file mode 100644 index 00000000..e69de29b diff --git a/packages/fastify-react/virtual/core.jsx b/packages/fastify-react/virtual/core.jsx index 8379ad28..154d1eeb 100644 --- a/packages/fastify-react/virtual/core.jsx +++ b/packages/fastify-react/virtual/core.jsx @@ -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 @@ -62,6 +86,7 @@ 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 @@ -69,7 +94,7 @@ export function AppRoute({ head, ctxHydration, ctx, children }) { 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) { diff --git a/packages/fastify-react/virtual/resource.js b/packages/fastify-react/virtual/resource.js index fee685a5..8092a6a3 100644 --- a/packages/fastify-react/virtual/resource.js +++ b/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) { @@ -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) { @@ -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 @@ -73,5 +78,5 @@ export function waitFetch(path) { fetchMap.set(path, loader) - return waitFetch(path) + return waitFetch(path, options, fetchMap) } diff --git a/packages/fastify-react/virtual/routes.js b/packages/fastify-react/virtual/routes.js index d9155ba4..d942c6ac 100644 --- a/packages/fastify-react/virtual/routes.js +++ b/packages/fastify-react/virtual/routes.js @@ -93,6 +93,7 @@ function getRouteModuleExports(routeModule) { streaming: routeModule.streaming, clientOnly: routeModule.clientOnly, serverOnly: routeModule.serverOnly, + ...routeModule, } } diff --git a/starters/react-base/package.json b/starters/react-base/package.json index 867a972f..dedf5c14 100644 --- a/starters/react-base/package.json +++ b/starters/react-base/package.json @@ -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", diff --git a/starters/react-kitchensink/client/base.css b/starters/react-kitchensink/client/base.css index f2586179..18bd3aad 100644 --- a/starters/react-kitchensink/client/base.css +++ b/starters/react-kitchensink/client/base.css @@ -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); +} diff --git a/starters/react-kitchensink/client/pages/actions/data.jsx b/starters/react-kitchensink/client/pages/actions/data.jsx new file mode 100644 index 00000000..609b6fbf --- /dev/null +++ b/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> + </> + ) +} diff --git a/starters/react-kitchensink/client/pages/actions/form.jsx b/starters/react-kitchensink/client/pages/actions/form.jsx new file mode 100644 index 00000000..38dada95 --- /dev/null +++ b/starters/react-kitchensink/client/pages/actions/form.jsx @@ -0,0 +1,33 @@ +import { Link } from 'react-router-dom' +import { createServerAction } from '/:core.jsx' + +const isAdmin = createServerAction() + +export function configure (server) { + server.post(isAdmin, async (req, reply) => { + await new Promise((resolve, reject) => { + setTimeout(resolve, 1000) + }) + const username = req.body.username + if (username === 'admin') { + return reply.redirect('/admin') + } + return new Error('Invalid username') + }) +} + +export default function Form () { + return ( + <> + <h1>Using inline server POST handler</h1> + <form action={isAdmin} method="post"> + <label htmlFor="username">Username:</label> + <input type="text" name="username" /> + <input type="submit" value="submit" /> + </form> + <p> + <Link to="/">Go back to the index</Link> + </p> + </> + ) +} diff --git a/starters/react-kitchensink/client/pages/form/[id].jsx b/starters/react-kitchensink/client/pages/form/[id].jsx index 9fe225d6..3fe40f09 100644 --- a/starters/react-kitchensink/client/pages/form/[id].jsx +++ b/starters/react-kitchensink/client/pages/form/[id].jsx @@ -1,8 +1,6 @@ import { useRouteContext } from '/:core.jsx' -export const layout = 'default' - export function getData ({ req, reply }) { if (req.method === 'POST') { if (req.body.number !== '42') { diff --git a/starters/react-kitchensink/client/pages/index.jsx b/starters/react-kitchensink/client/pages/index.jsx index bafdf94e..86701885 100644 --- a/starters/react-kitchensink/client/pages/index.jsx +++ b/starters/react-kitchensink/client/pages/index.jsx @@ -19,24 +19,15 @@ export default function Index () { <img src={logo} /> <h1>{snapshot.message}</h1> <ul className="columns-2"> - <li><Link to="/using-data">/using-data</Link> demonstrates how to - leverage the <code>getData()</code> function - and <code>useRouteContext()</code> to retrieve server data for a route.</li> - <li><Link to="/using-store">/using-store</Link> demonstrates how to - leverage the - automated <a href="https://github.com/pmndrs/valtio">Valtio</a> store - to retrieve server data for a route and maintain it in a global - state even after navigating to another route.</li> - <li><Link to="/using-auth">/using-auth</Link> demonstrates how to - wrap a route in a custom layout component.</li> - <li><Link to="/form/123">/form/123</Link> demonstrates how to - send a POST request with form data to a route with dynamic URL.</li> - <li><Link to="/client-only">/client-only</Link> demonstrates how to set - up a route for rendering on the client only (disables SSR).</li> - <li><Link to="/server-only">/server-only</Link> demonstrates how to set - up a route for rendering on the server only (sends no JavaScript).</li> - <li><Link to="/streaming">/streaming</Link> demonstrates how to set - up a route for SSR in streaming mode.</li> + <li><Link to="/using-data">/using-data</Link> — isomorphic data fetching.</li> + <li><Link to="/using-store">/using-store</Link> — integrated <a href="https://github.com/pmndrs/valtio">Valtio</a> store.</li> + <li><Link to="/using-auth">/using-auth</Link> — <b>custom layout</b>.</li> + <li><Link to="/form/123">/form/123</Link> — <code>POST</code> to dynamic route.</li> + <li><Link to="/actions/data">/actions/data</Link> — inline <code>GET</code> handler.</li> + <li><Link to="/actions/form">/actions/form</Link> — inline <code>POST</code> handler.</li> + <li><Link to="/client-only">/client-only</Link> — <b>disabling</b> SSR.</li> + <li><Link to="/server-only">/server-only</Link> — <code>0kb</code> JavaScript.</li> + <li><Link to="/streaming">/streaming</Link> — <b>streaming</b> SSR.</li> </ul> </> ) diff --git a/starters/react-kitchensink/package.json b/starters/react-kitchensink/package.json index 44627d0e..a412ae4d 100644 --- a/starters/react-kitchensink/package.json +++ b/starters/react-kitchensink/package.json @@ -12,14 +12,14 @@ "dependencies": { "@fastify/formbody": "^7.4.0", "@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", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0", + "react-router-dom": "^6", "unihead": "^0.0.6", "valtio": "^1.12.0" }, diff --git a/starters/react-kitchensink/server.js b/starters/react-kitchensink/server.js index 21cbde40..68953408 100644 --- a/starters/react-kitchensink/server.js +++ b/starters/react-kitchensink/server.js @@ -3,11 +3,11 @@ import FastifyVite from '@fastify/vite' import FastifyFormBody from '@fastify/formbody' const server = Fastify({ - logger: { - transport: { - target: '@fastify/one-line-logger' - } - } + // logger: { + // transport: { + // target: '@fastify/one-line-logger' + // } + // } }) await server.register(FastifyFormBody)