@fastify/react 0.6.0 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+
2
+ export function generateStores(keys) {
3
+ let code = `
4
+ import { useRouteContext } from '@fastify/react/client'
5
+
6
+ function storeGetter (proxy, prop) {
7
+ if (!proxy.context) {
8
+ proxy.context = useRouteContext()
9
+ }
10
+ if (prop === 'state') {
11
+ return proxy.context.state[proxy.key]
12
+ }
13
+ let method
14
+ if (method = proxy.context.actions[proxy.key][prop]) {
15
+ if (!proxy.wrappers[prop]) {
16
+ proxy.wrappers[prop] = (...args) => {
17
+ return method(proxy.context.state, ...args)
18
+ }
19
+ }
20
+ return proxy.wrappers[prop]
21
+ }
22
+ }
23
+ `
24
+ for (const key of keys) {
25
+ code += `
26
+ export const ${key} = new Proxy({
27
+ key: '${key}',
28
+ wrappers: {},
29
+ context: null,
30
+ }, {
31
+ get: storeGetter
32
+ })
33
+ `
34
+ }
35
+ return {
36
+ code,
37
+ map: null
38
+ }
39
+ }
@@ -0,0 +1,100 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { findExports } from 'mlly'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+
8
+ const virtualRoot = resolve(__dirname, '..', 'virtual')
9
+
10
+ const virtualModules = [
11
+ 'mount.js',
12
+ 'resource.js',
13
+ 'routes.js',
14
+ 'layouts.js',
15
+ 'create.jsx',
16
+ 'root.jsx',
17
+ 'layouts/',
18
+ 'context.js',
19
+ 'core.jsx',
20
+ 'index.js',
21
+ ]
22
+
23
+ export const prefix = /^\/?\$app\//
24
+
25
+ export async function resolveId (id) {
26
+ if (prefix.test(id)) {
27
+ const [, virtual] = id.split(prefix)
28
+ if (virtual) {
29
+ const override = loadVirtualModuleOverride(this.root, virtual)
30
+ if (override) {
31
+ return override
32
+ }
33
+ return id
34
+ }
35
+ }
36
+ }
37
+
38
+ export function loadVirtualModule (virtualInput) {
39
+ let virtual = virtualInput
40
+ if (!/\.((mc)?ts)|((mc)?js)|(jsx)$/.test(virtual)) {
41
+ virtual += '.js'
42
+ }
43
+ if (!virtualModules.includes(virtual)) {
44
+ return
45
+ }
46
+ const code = readFileSync(resolve(virtualRoot, virtual), 'utf8')
47
+ return {
48
+ code,
49
+ map: null,
50
+ }
51
+ }
52
+
53
+ virtualModules.includes = function (virtual) {
54
+ if (!virtual) {
55
+ return false
56
+ }
57
+ for (const entry of this) {
58
+ if (virtual.startsWith(entry)) {
59
+ return true
60
+ }
61
+ }
62
+ return false
63
+ }
64
+
65
+ function loadVirtualModuleOverride (viteProjectRoot, virtual) {
66
+ if (!virtualModules.includes(virtual)) {
67
+ return
68
+ }
69
+ const overridePath = resolve(viteProjectRoot, virtual)
70
+ if (existsSync(overridePath)) {
71
+ return overridePath
72
+ }
73
+ }
74
+
75
+ export function loadSource (id) {
76
+ const filePath = id
77
+ .replace(/\?client$/, '')
78
+ .replace(/\?server$/, '')
79
+ return readFileSync(filePath, 'utf8')
80
+ }
81
+
82
+ export function createPlaceholderExports (source) {
83
+ let pExports = ''
84
+ for (const exp of findExports(source)) {
85
+ switch (exp.type) {
86
+ case 'named':
87
+ for (const name of exp.names) {
88
+ pExports += `export const ${name} = {}\n`
89
+ }
90
+ break
91
+ case 'default':
92
+ pExports += `export default {}\n`
93
+ break
94
+ case 'declaration':
95
+ pExports += `export const ${exp.name} = {}\n`
96
+ break
97
+ }
98
+ }
99
+ return pExports
100
+ }
package/rendering.js ADDED
@@ -0,0 +1,139 @@
1
+ import { Readable } from 'node:stream'
2
+ // Helper to make the stream returned renderToPipeableStream()
3
+ // behave like an event emitter and facilitate error handling in Fastify
4
+ import { Minipass } from 'minipass'
5
+ // React 18's preferred server-side rendering function,
6
+ // which enables the combination of React.lazy() and Suspense
7
+ import { renderToPipeableStream } from 'react-dom/server'
8
+ import * as devalue from 'devalue'
9
+ import Head from 'unihead'
10
+ import { createHtmlTemplates } from './templating.js'
11
+
12
+ // Helper function to get an AsyncIterable (via PassThrough)
13
+ // from the renderToPipeableStream() onShellReady event
14
+ export function onShellReady(app) {
15
+ const duplex = new Minipass()
16
+ return new Promise((resolve, reject) => {
17
+ try {
18
+ const pipeable = renderToPipeableStream(app, {
19
+ onShellReady() {
20
+ resolve(pipeable.pipe(duplex))
21
+ },
22
+ })
23
+ } catch (error) {
24
+ resolve(error)
25
+ }
26
+ })
27
+ }
28
+
29
+ // Helper function to get an AsyncIterable (via Minipass)
30
+ // from the renderToPipeableStream() onAllReady event
31
+ export function onAllReady(app) {
32
+ const duplex = new Minipass()
33
+ return new Promise((resolve, reject) => {
34
+ try {
35
+ const pipeable = renderToPipeableStream(app, {
36
+ onAllReady() {
37
+ resolve(pipeable.pipe(duplex))
38
+ },
39
+ })
40
+ } catch (error) {
41
+ resolve(error)
42
+ }
43
+ })
44
+ }
45
+
46
+ export async function createRenderFunction ({ routes, create }) {
47
+ // Used when hydrating React Router on the client
48
+ const routeMap = Object.fromEntries(routes.map(_ => [_.path, _]))
49
+ // Registered as reply.render()
50
+ return function () {
51
+ if (this.request.route.streaming) {
52
+ return createStreamingResponse(this.request, routes, routeMap, create)
53
+ }
54
+ return createResponse(this.request, routes, routeMap, create)
55
+ }
56
+ }
57
+
58
+ async function createStreamingResponse (req, routes) {
59
+ // SSR stream
60
+ const body = await onShellReady(req.route.app)
61
+ return { routes, context: req.route, body }
62
+ }
63
+
64
+ async function createResponse (req, routes) {
65
+ let body
66
+ if (!req.route.clientOnly) {
67
+ // SSR string
68
+ body = await onAllReady(req.route.app)
69
+ }
70
+ return { routes, context: req.route, body }
71
+ }
72
+
73
+ // The return value of this function gets registered as reply.html()
74
+ export async function createHtmlFunction (source, _, config) {
75
+ // Creates `universal` and `serverOnly` sets of
76
+ // HTML `beforeElement` and `afterElement` templates
77
+ const templates = await createHtmlTemplates(source, config)
78
+
79
+ // Registered as reply.html()
80
+ return async function () {
81
+ const { routes, context, body } = await this.render()
82
+
83
+ this.type('text/html')
84
+
85
+ // Use template with client module import removed
86
+ if (context.serverOnly) {
87
+ // Turn off hydration
88
+ context.hydration = ''
89
+
90
+ return streamShell(
91
+ templates.serverOnly,
92
+ context,
93
+ body,
94
+ )
95
+ }
96
+
97
+ // Embed full hydration script
98
+ context.hydration = (
99
+ `<script>\nwindow.route = ${
100
+ // Server data payload
101
+ devalue.uneval(context.toJSON())
102
+ }\nwindow.routes = ${
103
+ // Universal router payload
104
+ devalue.uneval(routes.toJSON())
105
+ }\n</script>`
106
+ )
107
+
108
+ // In all other cases use universal,
109
+ // template which works the same for SSR and CSR.
110
+
111
+ if (context.clientOnly) {
112
+ return sendClientOnlyShell(templates.universal, context)
113
+ }
114
+
115
+ return streamShell(templates.universal, context, body)
116
+ }
117
+ }
118
+
119
+ export function sendClientOnlyShell (templates, context, body) {
120
+ context.head = new Head(context.head).render()
121
+ return `${
122
+ templates.beforeElement(context)
123
+ }${
124
+ templates.afterElement(context)
125
+ }`
126
+ }
127
+
128
+ export function streamShell (templates, context, body) {
129
+ context.head = new Head(context.head).render()
130
+ return Readable.from(createShellStream(templates, context, body))
131
+ }
132
+
133
+ async function * createShellStream (templates, context, body) {
134
+ yield templates.beforeElement(context)
135
+ for await (const chunk of body) {
136
+ yield chunk
137
+ }
138
+ yield templates.afterElement(context)
139
+ }
package/routing.js ADDED
@@ -0,0 +1,142 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import Youch from 'youch'
4
+ import RouteContext from './context.js'
5
+ import { createHtmlFunction } from './rendering.js'
6
+
7
+ export async function prepareClient (entries, _) {
8
+ const client = entries.ssr
9
+ if (client.context instanceof Promise) {
10
+ client.context = await client.context
11
+ }
12
+ if (client.routes instanceof Promise) {
13
+ client.routes = await client.routes
14
+ }
15
+ if (client.create instanceof Promise) {
16
+ const { default: create } = await client.create
17
+ client.create = create
18
+ }
19
+ return client
20
+ }
21
+
22
+ export function createErrorHandler (_, scope, config) {
23
+ return async (error, req, reply) => {
24
+ req.log.error(error)
25
+ if (config.dev) {
26
+ const youch = new Youch(error, req.raw)
27
+ reply.code(500)
28
+ reply.type('text/html')
29
+ reply.send(await youch.toHTML())
30
+ return reply
31
+ }
32
+ reply.code(500)
33
+ reply.send('')
34
+ return reply
35
+ }
36
+ }
37
+
38
+ export async function createRoute ({ client, errorHandler, route }, scope, config) {
39
+ if (route.configure) {
40
+ await route.configure(scope)
41
+ }
42
+
43
+ // Used when hydrating Vue Router on the client
44
+ const routeMap = Object.fromEntries(client.routes.map(_ => [_.path, _]))
45
+
46
+ // Extend with route context initialization module
47
+ RouteContext.extend(client.context)
48
+
49
+ const onRequest = async (req, reply) => {
50
+ req.route = await RouteContext.create(
51
+ scope,
52
+ req,
53
+ reply,
54
+ route,
55
+ client.context,
56
+ )
57
+ }
58
+
59
+ const preHandler = [
60
+ async (req) => {
61
+ if (!req.route.clientOnly) {
62
+ const app = client.create({
63
+ routes: client.routes,
64
+ routeMap,
65
+ ctxHydration: req.route,
66
+ url: req.url,
67
+ })
68
+ req.route.app = app
69
+ }
70
+ }
71
+ ]
72
+
73
+ if (route.getData) {
74
+ preHandler.push(async (req) => {
75
+ if (!req.route.data) {
76
+ req.route.data = {}
77
+ }
78
+ const result = await route.getData(req.route)
79
+ Object.assign(req.route.data, result)
80
+ })
81
+ }
82
+
83
+ if (route.getMeta) {
84
+ preHandler.push(async (req) => {
85
+ req.route.head = await route.getMeta(req.route)
86
+ })
87
+ }
88
+
89
+ if (route.onEnter) {
90
+ preHandler.push(async (req) => {
91
+ try {
92
+ if (route.onEnter) {
93
+ if (!req.route.data) {
94
+ req.route.data = {}
95
+ }
96
+ const result = await route.onEnter(req.route)
97
+ Object.assign(req.route.data, result)
98
+ }
99
+ } catch (err) {
100
+ if (config.dev) {
101
+ console.error(err)
102
+ }
103
+ req.route.error = err
104
+ }
105
+ })
106
+ }
107
+
108
+ // Route handler
109
+ let handler
110
+ if (config.dev) {
111
+ handler = (_, reply) => reply.html()
112
+ } else {
113
+ const { id } = route
114
+ const htmlPath = id.replace(/pages\/(.*?)\.jsx/, 'html/$1.html')
115
+ const htmlSource = readFileSync(join(config.vite.root, config.vite.build.outDir, htmlPath), 'utf8')
116
+ const htmlFunction = await createHtmlFunction(htmlSource, scope, config)
117
+ handler = (_, reply) => htmlFunction.call(reply)
118
+ }
119
+
120
+ // Replace wildcard routes with Fastify compatible syntax
121
+ const routePath = route.path.replace(/:[^+]+\+/, '*')
122
+
123
+ scope.route({
124
+ url: routePath,
125
+ method: route.method ?? ['GET', 'POST', 'PUT', 'DELETE'],
126
+ errorHandler,
127
+ onRequest,
128
+ preHandler,
129
+ handler,
130
+ ...route,
131
+ })
132
+
133
+ if (route.getData) {
134
+ // If getData is provided, register JSON endpoint for it
135
+ scope.get(`/-/data${routePath}`, {
136
+ onRequest,
137
+ async handler (req, reply) {
138
+ reply.send(await route.getData(req.route))
139
+ },
140
+ })
141
+ }
142
+ }
package/server.js ADDED
@@ -0,0 +1,129 @@
1
+ // Otherwise we get a ReferenceError, but since
2
+ // this function is only ran once, there's no overhead
3
+ class Routes extends Array {
4
+ toJSON () {
5
+ return this.map((route) => {
6
+ return {
7
+ id: route.id,
8
+ path: route.path,
9
+ name: route.name,
10
+ layout: route.layout,
11
+ getData: !!route.getData,
12
+ getMeta: !!route.getMeta,
13
+ onEnter: !!route.onEnter,
14
+ }
15
+ })
16
+ }
17
+ }
18
+
19
+ export function prepareServer(server) {
20
+ let url
21
+ server.decorate('serverURL', { getter: () => url })
22
+ server.addHook('onListen', () => {
23
+ const { port, address, family } = server.server.address()
24
+ const protocol = server.https ? 'https' : 'http'
25
+ if (family === 'IPv6') {
26
+ url = `${protocol}://[${address}]:${port}`
27
+ } else {
28
+ url = `${protocol}://${address}:${port}`
29
+ }
30
+ })
31
+ server.decorateRequest('fetchMap', null)
32
+ server.addHook('onRequest', (req, _, done) => {
33
+ req.fetchMap = new Map()
34
+ done()
35
+ })
36
+ server.addHook('onResponse', (req, _, done) => {
37
+ req.fetchMap = undefined
38
+ done()
39
+ })
40
+ }
41
+
42
+ export async function createRoutes (fromPromise, { param } = { param: /\[([\.\w]+\+?)\]/ }) {
43
+ const { default: from } = await fromPromise
44
+ const importPaths = Object.keys(from)
45
+ const promises = []
46
+ if (Array.isArray(from)) {
47
+ for (const routeDef of from) {
48
+ promises.push(
49
+ getRouteModule(routeDef.path, routeDef.component)
50
+ .then((routeModule) => {
51
+ return {
52
+ id: routeDef.path,
53
+ name: routeDef.path ?? routeModule.path,
54
+ path: routeDef.path ?? routeModule.path,
55
+ ...routeModule,
56
+ }
57
+ }),
58
+ )
59
+ }
60
+ } else {
61
+ // Ensure that static routes have precedence over the dynamic ones
62
+ for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) {
63
+ promises.push(
64
+ getRouteModule(path, from[path])
65
+ .then((routeModule) => {
66
+ const route = {
67
+ id: path,
68
+ layout: routeModule.layout,
69
+ name: path
70
+ // Remove /pages and .vue extension
71
+ .slice(6, -4)
72
+ // Remove params
73
+ .replace(param, '')
74
+ // Remove leading and trailing slashes
75
+ .replace(/^\/*|\/*$/g, '')
76
+ // Replace slashes with underscores
77
+ .replace(/\//g, '_'),
78
+ path:
79
+ routeModule.path ??
80
+ path
81
+ // Remove /pages and .vue extension
82
+ .slice(6, -4)
83
+ // Replace [id] with :id and [slug+] with :slug+
84
+ .replace(param, (_, m) => `:${m}`)
85
+ .replace(/:\w+\+/, (_, m) => `*`)
86
+ // Replace '/index' with '/'
87
+ .replace(/\/index$/, '/')
88
+ // Remove trailing slashs
89
+ .replace(/(.+)\/+$/, (...m) => m[1]),
90
+ ...routeModule,
91
+ }
92
+
93
+ if (route.name === '') {
94
+ route.name = 'catch-all'
95
+ }
96
+
97
+ return route
98
+ }),
99
+ )
100
+ }
101
+ }
102
+ return new Routes(...await Promise.all(promises))
103
+ }
104
+
105
+
106
+ function getRouteModuleExports (routeModule) {
107
+ return {
108
+ // The Route component (default export)
109
+ component: routeModule.default,
110
+ // The Layout Route component
111
+ layout: routeModule.layout,
112
+ // Route-level hooks
113
+ getData: routeModule.getData,
114
+ getMeta: routeModule.getMeta,
115
+ onEnter: routeModule.onEnter,
116
+ // Other Route-level settings
117
+ streaming: routeModule.streaming,
118
+ clientOnly: routeModule.clientOnly,
119
+ serverOnly: routeModule.serverOnly,
120
+ }
121
+ }
122
+
123
+ async function getRouteModule (path, routeModuleInput) {
124
+ if (typeof routeModuleInput === 'function') {
125
+ const routeModule = await routeModuleInput()
126
+ return getRouteModuleExports(routeModule)
127
+ }
128
+ return getRouteModuleExports(routeModuleInput)
129
+ }
package/templating.js ADDED
@@ -0,0 +1,51 @@
1
+ import { createHtmlTemplateFunction } from '@fastify/vite/utils'
2
+ import { HTMLRewriter } from 'html-rewriter-wasm'
3
+
4
+ export async function createHtmlTemplates (source, config) {
5
+ const el = '<!-- element -->'
6
+
7
+ const universal = source.split(el)
8
+ const serverOnlyRaw = await removeClientModule(source, config)
9
+ const serverOnly = serverOnlyRaw.split(el)
10
+
11
+ return {
12
+ // Templates for client-only and universal rendering
13
+ universal: {
14
+ beforeElement: await createHtmlTemplateFunction(universal[0]),
15
+ afterElement: await createHtmlTemplateFunction(universal[1]),
16
+ },
17
+ // Templates for server-only rendering
18
+ serverOnly: {
19
+ beforeElement: await createHtmlTemplateFunction(serverOnly[0]),
20
+ afterElement: await createHtmlTemplateFunction(serverOnly[1]),
21
+ },
22
+ }
23
+ }
24
+
25
+ async function removeClientModule (html, config) {
26
+ const decoder = new TextDecoder()
27
+
28
+ let output = ''
29
+ const rewriter = new HTMLRewriter((outputChunk) => {
30
+ output += decoder.decode(outputChunk)
31
+ })
32
+
33
+ rewriter.on('script', {
34
+ element (element) {
35
+ for (const [attr, value] of element.attributes) {
36
+ if (attr === 'type' && value === 'module') {
37
+ element.replace('')
38
+ }
39
+ }
40
+ },
41
+ })
42
+
43
+ try {
44
+ const encoder = new TextEncoder()
45
+ await rewriter.write(encoder.encode(html))
46
+ await rewriter.end()
47
+ return output
48
+ } finally {
49
+ rewriter.free()
50
+ }
51
+ }
package/virtual/core.jsx CHANGED
@@ -1,24 +1,13 @@
1
1
  import { createPath } from 'history'
2
- import { createContext, useContext, useEffect } from 'react'
3
- import { BrowserRouter, useLocation } from 'react-router-dom'
4
- import { StaticRouter } from 'react-router-dom/server.mjs'
5
- import { proxy, useSnapshot } from 'valtio'
6
- import layouts from '/:layouts.js'
7
- import { waitFetch, waitResource } from '/:resource.js'
2
+ import { useEffect } from 'react'
3
+ import { BrowserRouter, StaticRouter, useLocation } from 'react-router'
4
+ import { proxy } from 'valtio'
5
+ import { RouteContext, useRouteContext } from '@fastify/react/client'
6
+ import layouts from '$app/layouts.js'
7
+ import { waitFetch, waitResource } from '$app/resource.js'
8
8
 
9
9
  export const isServer = import.meta.env.SSR
10
10
  export const Router = isServer ? StaticRouter : BrowserRouter
11
- export const RouteContext = createContext({})
12
-
13
- export function useRouteContext() {
14
- const routeContext = useContext(RouteContext)
15
- if (routeContext.state) {
16
- routeContext.snapshot = isServer
17
- ? routeContext.state ?? {}
18
- : useSnapshot(routeContext.state ?? {})
19
- }
20
- return routeContext
21
- }
22
11
 
23
12
  let serverActionCounter = 0
24
13
 
@@ -1,4 +1,4 @@
1
- import Root from '/:root.jsx'
1
+ import Root from '$app/root.jsx'
2
2
 
3
3
  export default function create({ url, ...serverInit }) {
4
4
  return <Root url={url} {...serverInit} />
@@ -1,6 +1,6 @@
1
1
  import { lazy } from 'react'
2
2
 
3
- const DefaultLayout = () => import('/:layouts/default.jsx')
3
+ const DefaultLayout = () => import('$app/layouts/default.jsx')
4
4
 
5
5
  const appLayouts = import.meta.glob('/layouts/*.{jsx,tsx}')
6
6