Skip to content
This repository has been archived by the owner on Nov 23, 2023. It is now read-only.

feat: adds basic support for preact #28

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/fastify-dx-preact/.eslintrc
Original file line number Diff line number Diff line change
@@ -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',
},
},
}
177 changes: 177 additions & 0 deletions packages/fastify-dx-preact/index.js
Original file line number Diff line number Diff line change
@@ -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
// <script> tags, preventing a few types of attack
import devalue from 'devalue'

// Small SSR-ready library used to generate
// <title>, <meta> and <link> elements
import Head from 'unihead'

// Helpers from the Node.js stream library to
// make it easier to work with renderToPipeableStream()
import { generateHtmlStream, onAllReady, onShellReady } from './server/stream.js'

// Holds the universal route context
import RouteContext from './server/context.js'

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

export async function prepareClient ({
routes: routesPromise,
context: contextPromise,
...others
}) {
const context = await contextPromise
const resolvedRoutes = await routesPromise
return { context, routes: resolvedRoutes, ...others }
}

// The return value of this function gets registered as reply.html()
export 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 soHeadTemplate = createHtmlTemplateFunction(soHeadSource)
const soFooterTemplate = createHtmlTemplateFunction(soFooterSource)
// This function gets registered as reply.html()
return function ({ routes, context, body }) {
// Initialize hydration, which can stay empty if context.serverOnly is true
let hydration = ''
// Decide which templating functions to use, with and without hydration
const headTemplate = context.serverOnly ? soHeadTemplate : unHeadTemplate
const footerTemplate = context.serverOnly ? soFooterTemplate : unFooterTemplate
// Decide whether or not to include the hydration script
if (!context.serverOnly) {
hydration = (
'<script>\n' +
`window.route = ${devalue(context.toJSON())}\n` +
`window.routes = ${devalue(routes.toJSON())}\n` +
'</script>'
)
}
// Render page-level <head> 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,
})
}
47 changes: 47 additions & 0 deletions packages/fastify-dx-preact/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
106 changes: 106 additions & 0 deletions packages/fastify-dx-preact/plugin.cjs
Original file line number Diff line number Diff line change
@@ -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
Loading