From 6b45d7a74587044363f3c7f75d4ff09224ff0e99 Mon Sep 17 00:00:00 2001 From: Michele Riva Date: Sun, 26 Jun 2022 13:44:35 +0200 Subject: [PATCH] feat: adds basic support for preact --- packages/fastify-dx-preact/.eslintrc | 32 ++++ packages/fastify-dx-preact/index.js | 177 ++++++++++++++++++ packages/fastify-dx-preact/package.json | 47 +++++ packages/fastify-dx-preact/plugin.cjs | 106 +++++++++++ packages/fastify-dx-preact/server/context.js | 65 +++++++ packages/fastify-dx-preact/server/stream.js | 14 ++ packages/fastify-dx-preact/virtual/context.js | 4 + packages/fastify-dx-preact/virtual/core.js | 25 +++ packages/fastify-dx-preact/virtual/create.jsx | 6 + packages/fastify-dx-preact/virtual/layouts.js | 14 ++ .../virtual/layouts/default.jsx | 9 + packages/fastify-dx-preact/virtual/mount.js | 47 +++++ .../fastify-dx-preact/virtual/resource.js | 67 +++++++ packages/fastify-dx-preact/virtual/root.jsx | 10 + packages/fastify-dx-preact/virtual/routes.js | 120 ++++++++++++ 15 files changed, 743 insertions(+) create mode 100644 packages/fastify-dx-preact/.eslintrc create mode 100644 packages/fastify-dx-preact/index.js create mode 100644 packages/fastify-dx-preact/package.json create mode 100644 packages/fastify-dx-preact/plugin.cjs create mode 100644 packages/fastify-dx-preact/server/context.js create mode 100644 packages/fastify-dx-preact/server/stream.js create mode 100644 packages/fastify-dx-preact/virtual/context.js create mode 100644 packages/fastify-dx-preact/virtual/core.js create mode 100644 packages/fastify-dx-preact/virtual/create.jsx create mode 100644 packages/fastify-dx-preact/virtual/layouts.js create mode 100644 packages/fastify-dx-preact/virtual/layouts/default.jsx create mode 100644 packages/fastify-dx-preact/virtual/mount.js create mode 100644 packages/fastify-dx-preact/virtual/resource.js create mode 100644 packages/fastify-dx-preact/virtual/root.jsx create mode 100644 packages/fastify-dx-preact/virtual/routes.js diff --git a/packages/fastify-dx-preact/.eslintrc b/packages/fastify-dx-preact/.eslintrc new file mode 100644 index 0000000..2ef2872 --- /dev/null +++ b/packages/fastify-dx-preact/.eslintrc @@ -0,0 +1,32 @@ +{ + parser: '@babel/eslint-parser', + parserOptions: { + requireConfigFile: false, + ecmaVersion: 2021, + sourceType: 'module', + babelOptions: { + presets: ['@babel/preset-react'], + }, + ecmaFeatures: { + jsx: true, + }, + }, + extends: [ + 'preact', + 'standard', + ], + plugins: [ + 'react', + ], + rules: { + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'comma-dangle': ['error', 'always-multiline'], + 'import/no-absolute-path': 'off', + }, + settings: { + react: { + version: '18.0', + }, + }, +} diff --git a/packages/fastify-dx-preact/index.js b/packages/fastify-dx-preact/index.js new file mode 100644 index 0000000..ea05b67 --- /dev/null +++ b/packages/fastify-dx-preact/index.js @@ -0,0 +1,177 @@ +// Used to send a readable stream to reply.send() +import { Readable } from 'stream' + +// fastify-vite's minimal HTML templating function, +// which extracts interpolation variables from comments +// and returns a function with the generated code +import { createHtmlTemplateFunction } from 'fastify-vite' + +// Used to safely serialize JavaScript into +// ' + ) + } + // Render page-level elements + const head = new Head(context.head).render() + // Create readable stream with prepended and appended chunks + const readable = Readable.from(generateHtmlStream({ + body: body && ( + context.streaming + ? onShellReady(body) + : onAllReady(body) + ), + head: headTemplate({ ...context, head, hydration }), + footer: footerTemplate(context), + })) + // Send out header and readable stream with full response + this.type('text/html') + this.send(readable) + } +} + +export async function createRenderFunction ({ routes, create }) { + // create is exported by client/index.js + return function (req) { + // Create convenience-access routeMap + const routeMap = Object.fromEntries(routes.toJSON().map((route) => { + return [route.path, route] + })) + // Creates main Preact component with all the SSR context it needs + const app = !req.route.clientOnly && create({ + routes, + routeMap, + ctxHydration: req.route, + url: req.url, + }) + // Perform SSR, i.e., turn app.instance into an HTML fragment + // The SSR context data is passed along so it can be inlined for hydration + return { routes, context: req.route, body: app } + } +} + +export function createRouteHandler (client, scope, config) { + return function (req, reply) { + reply.html(reply.render(req)) + return reply + } +} + +export function createRoute ({ client, handler, errorHandler, route }, scope, config) { + const onRequest = async function onRequest (req, reply) { + req.route = await RouteContext.create( + scope, + req, + reply, + route, + client.context, + ) + } + if (route.getData) { + // If getData is provided, register JSON endpoint for it + scope.get(`/-/data${route.path}`, { + onRequest, + async handler (req, reply) { + reply.send(await route.getData(req.route)) + }, + }) + } + + // See https://github.com/fastify/fastify-dx/blob/main/URMA.md + const hasURMAHooks = Boolean( + route.getData || route.getMeta || route.onEnter, + ) + + // Extend with route context initialization module + RouteContext.extend(client.context) + + scope.get(route.path, { + onRequest, + // If either getData or onEnter are provided, + // make sure they run before the SSR route handler + ...hasURMAHooks && { + async preHandler (req, reply) { + try { + if (route.getData) { + req.route.data = await route.getData(req.route) + } + if (route.getMeta) { + req.route.head = await route.getMeta(req.route) + } + if (route.onEnter) { + if (!req.route.data) { + req.route.data = {} + } + const result = await route.onEnter(req.route) + Object.assign(req.route.data, result) + } + } catch (err) { + if (config.dev) { + console.error(err) + } + req.route.error = err + } + }, + }, + handler, + errorHandler, + ...route, + }) +} diff --git a/packages/fastify-dx-preact/package.json b/packages/fastify-dx-preact/package.json new file mode 100644 index 0000000..16903c3 --- /dev/null +++ b/packages/fastify-dx-preact/package.json @@ -0,0 +1,47 @@ +{ + "scripts": { + "lint": "eslint . --ext .js,.jsx --fix" + }, + "type": "module", + "main": "index.js", + "name": "fastify-dx-preact", + "version": "0.0.2", + "files": [ + "virtual/create.jsx", + "virtual/root.jsx", + "virtual/layouts.js", + "virtual/layouts/default.jsx", + "virtual/context.js", + "virtual/mount.js", + "virtual/resource.js", + "virtual/core.jsx", + "virtual/routes.js", + "index.js", + "plugin.cjs", + "server/context.js", + "server/stream.js" + ], + "license": "MIT", + "exports": { + ".": "./index.js", + "./plugin": "./plugin.cjs" + }, + "dependencies": { + "devalue": "^2.0.1", + "preact": "^10.8.2", + "unihead": "^0.0.6", + "valtio": "^1.6.1" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.16.0", + "@babel/preset-react": "^7.16.0", + "@vitejs/plugin-react": "^1.3.2", + "eslint": "^7.32.0", + "eslint-config-preact": "^1.3.0", + "eslint-config-standard": "^16.0.2", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^4.3.1", + "jest": "^28.1.1" + } +} diff --git a/packages/fastify-dx-preact/plugin.cjs b/packages/fastify-dx-preact/plugin.cjs new file mode 100644 index 0000000..6dda60d --- /dev/null +++ b/packages/fastify-dx-preact/plugin.cjs @@ -0,0 +1,106 @@ +const { readFileSync, existsSync } = require('fs') +const { resolve } = require('path') + +function vitePreactFastifyDX (config = {}) { + const prefix = /^\/?dx:/ + const routing = Object.assign({ + globPattern: '/pages/**/*.jsx', + paramPattern: /\[(\w+)\]/, + }, config) + const virtualRoot = resolve(__dirname, 'virtual') + const virtualModules = [ + 'mount.js', + 'resource.js', + 'routes.js', + 'layouts.js', + 'create.jsx', + 'root.jsx', + 'layouts/', + 'context.js', + 'core.jsx' + ] + virtualModules.includes = function (virtual) { + if (!virtual) { + return false + } + for (const entry of this) { + if (virtual.startsWith(entry)) { + return true + } + } + return false + } + const virtualModuleInserts = { + 'routes.js': { + $globPattern: routing.globPattern, + $paramPattern: routing.paramPattern, + } + } + + let viteProjectRoot + + function loadVirtualModuleOverride (virtual) { + if (!virtualModules.includes(virtual)) { + return + } + const overridePath = resolve(viteProjectRoot, virtual) + if (existsSync(overridePath)) { + return overridePath + } + } + + function loadVirtualModule (virtual) { + if (!virtualModules.includes(virtual)) { + return + } + let code = readFileSync(resolve(virtualRoot, virtual), 'utf8') + if (virtualModuleInserts[virtual]) { + for (const [key, value] of Object.entries(virtualModuleInserts[virtual])) { + code = code.replace(new RegExp(escapeRegExp(key), 'g'), value) + } + } + return { + code, + map: null, + } + } + + // Thanks to https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js + function escapeRegExp (s) { + return s + .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + .replace(/-/g, '\\x2d') + } + + return { + name: 'vite-plugin-preact-fastify-dx', + config (config, { command }) { + if (command === 'build' && config.build?.ssr) { + config.build.rollupOptions = { + output: { + format: 'es', + }, + } + } + }, + configResolved (config) { + viteProjectRoot = config.root + }, + async resolveId (id) { + const [, virtual] = id.split(prefix) + if (virtual) { + const override = await loadVirtualModuleOverride(virtual) + if (override) { + return override + } + return id + } + }, + load (id) { + const [, virtual] = id.split(prefix) + return loadVirtualModule(virtual) + }, + } +} + +module.exports = vitePreactFastifyDX diff --git a/packages/fastify-dx-preact/server/context.js b/packages/fastify-dx-preact/server/context.js new file mode 100644 index 0000000..fab9a2e --- /dev/null +++ b/packages/fastify-dx-preact/server/context.js @@ -0,0 +1,65 @@ + +const routeContextInspect = Symbol.for('nodejs.util.inspect.custom') + +export default class RouteContext { + static async create (server, req, reply, route, contextInit) { + const routeContext = new RouteContext(server, req, reply, route) + if (contextInit) { + if (contextInit.state) { + routeContext.state = contextInit.state() + } + if (contextInit.default) { + await contextInit.default(routeContext) + } + } + return routeContext + } + + constructor (server, req, reply, route) { + this.server = server + this.req = req + this.reply = reply + this.head = {} + this.state = null + this.data = route.data + this.firstRender = true + this.layout = route.layout + this.getMeta = !!route.getMeta + this.getData = !!route.getData + this.onEnter = !!route.onEnter + this.streaming = route.streaming + this.clientOnly = route.clientOnly + this.serverOnly = route.serverOnly + } + + [routeContextInspect] () { + return { + ...this, + server: { [routeContextInspect]: () => '[Server]' }, + req: { [routeContextInspect]: () => '[Request]' }, + reply: { [routeContextInspect]: () => '[Reply]' }, + } + } + + toJSON () { + return { + state: this.state, + data: this.data, + layout: this.layout, + getMeta: this.getMeta, + getData: this.getData, + onEnter: this.onEnter, + firstRender: this.firstRender, + clientOnly: this.clientOnly, + } + } +} + +RouteContext.extend = function (initial) { + const { default: _, ...extra } = initial + for (const [prop, value] of Object.entries(extra)) { + if (prop !== 'data' && prop !== 'state') { + Object.defineProperty(RouteContext.prototype, prop, value) + } + } +} diff --git a/packages/fastify-dx-preact/server/stream.js b/packages/fastify-dx-preact/server/stream.js new file mode 100644 index 0000000..533412c --- /dev/null +++ b/packages/fastify-dx-preact/server/stream.js @@ -0,0 +1,14 @@ + +// Helper function to prepend and append chunks the body stream +export async function * generateHtmlStream ({ head, body, stream, footer }) { + yield head + if (body) { + yield body + } + if (stream) { + for await (const chunk of await stream) { + yield chunk + } + } + yield footer +} diff --git a/packages/fastify-dx-preact/virtual/context.js b/packages/fastify-dx-preact/virtual/context.js new file mode 100644 index 0000000..1e605f5 --- /dev/null +++ b/packages/fastify-dx-preact/virtual/context.js @@ -0,0 +1,4 @@ +// This file serves as a placeholder +// if no context.js file is provided + +export default () => {} diff --git a/packages/fastify-dx-preact/virtual/core.js b/packages/fastify-dx-preact/virtual/core.js new file mode 100644 index 0000000..ca9ebff --- /dev/null +++ b/packages/fastify-dx-preact/virtual/core.js @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'preact' + +export const RouteContext = createContext() + +export function useRouteContext () { + return useContext(RouteContext) +} + +export async function jsonDataFetch (path) { + const response = await fetch(`/-/data${path}`) + let data + let error + try { + data = await response.json() + } catch (err) { + error = err + } + if (data?.statusCode === 500) { + throw new Error(data.message) + } + if (error) { + throw error + } + return data +} diff --git a/packages/fastify-dx-preact/virtual/create.jsx b/packages/fastify-dx-preact/virtual/create.jsx new file mode 100644 index 0000000..02084b0 --- /dev/null +++ b/packages/fastify-dx-preact/virtual/create.jsx @@ -0,0 +1,6 @@ +import Root from '/dx:root.jsx' + +export default function create ({ url, payload }) { + // eslint-disable-next-line react/display-name + return () => +} diff --git a/packages/fastify-dx-preact/virtual/layouts.js b/packages/fastify-dx-preact/virtual/layouts.js new file mode 100644 index 0000000..064977f --- /dev/null +++ b/packages/fastify-dx-preact/virtual/layouts.js @@ -0,0 +1,14 @@ +import { lazy } from 'preact/compat' + +const DefaultLayout = () => import('/dx:layouts/default.jsx') + +const appLayouts = import.meta.glob('/layouts/*.jsx') + +appLayouts['/layouts/default.jsx'] ??= DefaultLayout + +export default Object.fromEntries( + Object.keys(appLayouts).map((path) => { + const name = path.slice(9, -4) + return [name, lazy(appLayouts[path])] + }), +) diff --git a/packages/fastify-dx-preact/virtual/layouts/default.jsx b/packages/fastify-dx-preact/virtual/layouts/default.jsx new file mode 100644 index 0000000..269680c --- /dev/null +++ b/packages/fastify-dx-preact/virtual/layouts/default.jsx @@ -0,0 +1,9 @@ +import { Suspense } from 'preact' + +export default function Layout ({ children }) { + return ( + + {children} + + ) +} diff --git a/packages/fastify-dx-preact/virtual/mount.js b/packages/fastify-dx-preact/virtual/mount.js new file mode 100644 index 0000000..45921fc --- /dev/null +++ b/packages/fastify-dx-preact/virtual/mount.js @@ -0,0 +1,47 @@ +import Head from 'unihead/client' +import { hydrate, render } from 'preact' + +import create from '/dx:create.jsx' +import routesPromise from '/dx:routes.js' + +mount('main') + +async function mount (target) { + if (typeof target === 'string') { + target = document.querySelector(target) + } + const context = await import('/dx:context.js') + const ctxHydration = await extendContext(window.route, context) + const head = new Head(window.route.head, window.document) + const resolvedRoutes = await routesPromise + const routeMap = Object.fromEntries( + resolvedRoutes.map((route) => [route.path, route]), + ) + + const app = create({ + head, + ctxHydration, + routes: window.routes, + routeMap, + }) + if (ctxHydration.clientOnly) { + render(app, target) + } else { + hydrate(app, target) + } +} + +async function extendContext (ctx, { + // The route context initialization function + default: setter, + // We destructure state here just to discard it from extra + state, + // Other named exports from context.js + ...extra +}) { + Object.assign(ctx, extra) + if (setter) { + await setter(ctx) + } + return ctx +} diff --git a/packages/fastify-dx-preact/virtual/resource.js b/packages/fastify-dx-preact/virtual/resource.js new file mode 100644 index 0000000..cfdde28 --- /dev/null +++ b/packages/fastify-dx-preact/virtual/resource.js @@ -0,0 +1,67 @@ +const fetchMap = new Map() +const resourceMap = new Map() + +export function waitResource (path, id, promise) { + const resourceId = `${path}:${id}` + const loader = resourceMap.get(resourceId) + if (loader) { + if (loader.error) { + throw loader.error + } + if (loader.suspended) { + throw loader.promise + } + resourceMap.delete(resourceId) + + return loader.result + } else { + const loader = { + suspended: true, + error: null, + result: null, + promise: null, + } + loader.promise = promise() + .then((result) => { loader.result = result }) + .catch((loaderError) => { loader.error = loaderError }) + .finally(() => { loader.suspended = false }) + + resourceMap.set(resourceId, loader) + + return waitResource(path, id) + } +} + +export function waitFetch (path) { + const loader = fetchMap.get(path) + if (loader) { + if (loader.error || loader.data?.statusCode === 500) { + if (loader.data?.statusCode === 500) { + throw new Error(loader.data.message) + } + throw loader.error + } + if (loader.suspended) { + throw loader.promise + } + fetchMap.delete(path) + + return loader.data + } else { + const loader = { + suspended: true, + error: null, + data: null, + promise: null, + } + loader.promise = fetch(`/-/data${path}`) + .then((response) => response.json()) + .then((loaderData) => { loader.data = loaderData }) + .catch((loaderError) => { loader.error = loaderError }) + .finally(() => { loader.suspended = false }) + + fetchMap.set(path, loader) + + return waitFetch(path) + } +} diff --git a/packages/fastify-dx-preact/virtual/root.jsx b/packages/fastify-dx-preact/virtual/root.jsx new file mode 100644 index 0000000..6c101db --- /dev/null +++ b/packages/fastify-dx-preact/virtual/root.jsx @@ -0,0 +1,10 @@ +import { Suspense } from 'preact' +import { DXApp } from '/dx:core.jsx' + +export default function Root ({ url, serverInit }) { + return ( + + + + ) +} diff --git a/packages/fastify-dx-preact/virtual/routes.js b/packages/fastify-dx-preact/virtual/routes.js new file mode 100644 index 0000000..23d57dd --- /dev/null +++ b/packages/fastify-dx-preact/virtual/routes.js @@ -0,0 +1,120 @@ +/* global $paramPattern */ + +import { lazy } from 'preact' + +export default import.meta.env.SSR + ? createRoutes(import.meta.globEager('$globPattern')) + : hydrateRoutes(import.meta.glob('$globPattern')) + +async function createRoutes (from, { param } = { param: $paramPattern }) { + // Otherwise we get a ReferenceError, but since + // this function is only ran once, there's no overhead + class Routes extends Array { + toJSON () { + return this.map((route) => { + return { + id: route.id, + path: route.path, + layout: route.layout, + getData: !!route.getData, + getMeta: !!route.getMeta, + onEnter: !!route.onEnter, + } + }) + } + } + const importPaths = Object.keys(from) + const promises = [] + if (Array.isArray(from)) { + for (const routeDef of from) { + promises.push( + getRouteModule(routeDef.path, routeDef.component) + .then((routeModule) => { + return { + id: routeDef.path, + path: routeDef.path ?? routeModule.path, + ...routeModule, + } + }), + ) + } + } else { + // Ensure that static routes have precedence over the dynamic ones + for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) { + promises.push( + getRouteModule(path, from[path]) + .then((routeModule) => { + return { + id: path, + layout: routeModule.layout, + path: routeModule.path ?? path + // Remove /pages and .jsx extension + .slice(6, -4) + // Replace [id] with :id + .replace(param, (_, m) => `:${m}`) + // Replace '/index' with '/' + .replace(/\/index$/, '/') + // Remove trailing slashs + .replace(/.+\/+$/, ''), + ...routeModule, + } + }), + ) + } + } + return new Routes(...await Promise.all(promises)) +} + +async function hydrateRoutes (from) { + if (Array.isArray(from)) { + from = Object.fromEntries( + from.map((route) => [route.path, route]), + ) + } + return window.routes.map((route) => { + route.loader = memoImport(from[route.id]) + route.component = lazy(() => route.loader()) + return route + }) +} + +function getRouteModuleExports (routeModule) { + return { + // The Route component (default export) + component: routeModule.default, + // The Layout Route component + layout: routeModule.layout, + // Route-level hooks + getData: routeModule.getData, + getMeta: routeModule.getMeta, + onEnter: routeModule.onEnter, + // Other Route-level settings + streaming: routeModule.streaming, + clientOnly: routeModule.clientOnly, + serverOnly: routeModule.serverOnly, + } +} + +async function getRouteModule (path, routeModule) { + // const isServer = typeof process !== 'undefined' + if (typeof routeModule === 'function') { + routeModule = await routeModule() + return getRouteModuleExports(routeModule) + } + return getRouteModuleExports(routeModule) +} + +function memoImport (func) { + // Otherwise we get a ReferenceError, but since this function + // is only ran once for each route, there's no overhead + const kFuncExecuted = Symbol('kFuncExecuted') + const kFuncValue = Symbol('kFuncValue') + func[kFuncExecuted] = false + return async function () { + if (!func[kFuncExecuted]) { + func[kFuncValue] = await func() + func[kFuncExecuted] = true + } + return func[kFuncValue] + } +}