@fastify/react 1.0.1 → 1.1.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.
package/context.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createHead } from '@unhead/react/server'
2
+
1
3
  const routeContextInspect = Symbol.for('nodejs.util.inspect.custom')
2
4
 
3
5
  export default class RouteContext {
@@ -15,10 +17,12 @@ export default class RouteContext {
15
17
  }
16
18
 
17
19
  constructor(server, req, reply, route) {
20
+ this.app = null
18
21
  this.server = server
19
22
  this.req = req
20
23
  this.reply = reply
21
24
  this.head = {}
25
+ this.useHead = createHead()
22
26
  this.actionData = {}
23
27
  this.state = null
24
28
  this.data = route.data
@@ -46,6 +50,7 @@ export default class RouteContext {
46
50
  actionData: this.actionData,
47
51
  state: this.state,
48
52
  data: this.data,
53
+ head: this.head,
49
54
  layout: this.layout,
50
55
  getMeta: this.getMeta,
51
56
  getData: this.getData,
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "index.js",
4
4
  "name": "@fastify/react",
5
5
  "description": "The official @fastify/vite renderer for React",
6
- "version": "1.0.1",
6
+ "version": "1.1.0-beta.1",
7
7
  "files": [
8
8
  "plugin/index.js",
9
9
  "plugin/parsers.js",
@@ -21,6 +21,16 @@
21
21
  "virtual/resource.js",
22
22
  "virtual/root.jsx",
23
23
  "virtual/routes.js",
24
+ "virtual-ts/layouts/default.tsx",
25
+ "virtual-ts/context.ts",
26
+ "virtual-ts/core.tsx",
27
+ "virtual-ts/create.tsx",
28
+ "virtual-ts/index.ts",
29
+ "virtual-ts/layouts.ts",
30
+ "virtual-ts/mount.ts",
31
+ "virtual-ts/resource.ts",
32
+ "virtual-ts/root.tsx",
33
+ "virtual-ts/routes.ts",
24
34
  "client.js",
25
35
  "context.js",
26
36
  "index.js",
@@ -37,6 +47,7 @@
37
47
  "./server": "./server.js"
38
48
  },
39
49
  "dependencies": {
50
+ "@unhead/react": "^2.0.8",
40
51
  "acorn": "^8.14.1",
41
52
  "acorn-strip-function": "^1.2.0",
42
53
  "acorn-walk": "^8.3.4",
@@ -48,10 +59,9 @@
48
59
  "react": "^19.1.0",
49
60
  "react-dom": "^19.1.0",
50
61
  "react-router": "^7.5.0",
51
- "unihead": "latest",
52
62
  "valtio": "latest",
53
63
  "youch": "^3.3.4",
54
- "@fastify/vite": "^8.0.2"
64
+ "@fastify/vite": "^8.1.2"
55
65
  },
56
66
  "devDependencies": {
57
67
  "@biomejs/biome": "^1.9.2"
package/plugin/index.js CHANGED
@@ -12,37 +12,30 @@ import { closeBundle } from './preload.js'
12
12
  import { parseStateKeys } from './parsers.js'
13
13
  import { generateStores } from './stores.js'
14
14
 
15
- export default function viteFastifyVue () {
15
+ export default function viteFastifyReactPlugin ({ ts } = {}) {
16
16
  const context = {
17
17
  root: null,
18
18
  }
19
19
  return [viteFastify({
20
- clientModule: '$app/index.js'
20
+ clientModule: ts ? '$app/index.ts' : '$app/index.js'
21
21
  }), {
22
- name: 'vite-plugin-fastify-react',
22
+ // https://vite.dev/guide/api-plugin#conventions
23
+ name: 'vite-plugin-react-fastify',
23
24
  config,
24
25
  configResolved: configResolved.bind(context),
25
26
  resolveId: resolveId.bind(context),
26
27
  async load (id) {
27
- if (id.includes('?server') && !context.resolvedConfig.build.ssr) {
28
+ if (id.includes('?server') && !this.environment.config.build?.ssr) {
28
29
  const source = loadSource(id)
29
30
  return createPlaceholderExports(source)
30
31
  }
31
- if (id.includes('?client') && context.resolvedConfig.build.ssr) {
32
+ if (id.includes('?client') && this.environment.config.build?.ssr) {
32
33
  const source = loadSource(id)
33
34
  return createPlaceholderExports(source)
34
35
  }
35
36
  if (prefix.test(id)) {
36
37
  const [, virtual] = id.split(prefix)
37
38
  if (virtual) {
38
- if (virtual === 'stores') {
39
- const contextPath = join(context.root, 'context.js')
40
- if (existsSync(contextPath)) {
41
- const keys = parseStateKeys(readFileSync(contextPath, 'utf8'))
42
- return generateStores(keys)
43
- }
44
- return
45
- }
46
39
  return loadVirtualModule(virtual)
47
40
  }
48
41
  }
@@ -51,7 +44,9 @@ export default function viteFastifyVue () {
51
44
  order: 'post',
52
45
  handler: transformIndexHtml.bind(context)
53
46
  },
54
- closeBundle: closeBundle.bind(context),
47
+ closeBundle () {
48
+ closeBundle.call(this, context.resolvedBundle)
49
+ },
55
50
  }]
56
51
  }
57
52
 
package/plugin/preload.js CHANGED
@@ -1,46 +1,57 @@
1
- import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
- import { join, parse as parsePath } from 'node:path'
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
2
+ import { join, isAbsolute, parse as parsePath } from 'node:path'
3
3
  import { HTMLRewriter } from 'html-rewriter-wasm'
4
4
 
5
- const imageFile = /\.((png)|(jpg)|(svg)|(webp)|(gif))$/
5
+ const imageFileRE = /\.((png)|(jpg)|(svg)|(webp)|(gif))$/
6
6
 
7
- export async function closeBundle() {
8
- if (!this.resolvedConfig.build.ssr) {
9
- const distDir = join(this.root, this.resolvedConfig.build.outDir)
10
- const pages = Object.fromEntries(
11
- Object.entries(this.resolvedBundle ?? {})
12
- .filter(([id, meta]) => {
13
- if (meta.facadeModuleId?.includes('/pages/')) {
14
- meta.htmlPath = meta.facadeModuleId.replace(/.*pages\/(.*)\.jsx$/, 'html/$1.html')
15
- return true
16
- }
17
- })
18
- )
19
- for (const page of Object.values(pages)) {
20
- const jsImports = page.imports
21
- const cssImports = page.viteMetadata.importedCss
22
- const images = page.moduleIds.filter(_ => imageFile.test(_))
23
- let imagePreloads = '\n'
24
- for (let image of images) {
25
- image = image.slice(this.root.length + 1)
26
- imagePreloads += ` <link rel="preload" as="image" href="${this.resolvedConfig.base}${image}">\n`
27
- }
28
- let cssPreloads = ''
29
- for (const css of cssImports) {
30
- cssPreloads += ` <link rel="preload" as="style" href="${this.resolvedConfig.base}${css}">\n`
31
- }
32
- let jsPreloads = ''
33
- for (const js of jsImports) {
34
- jsPreloads += ` <link rel="modulepreload" href="${this.resolvedConfig.base}${js}">\n`
35
- }
36
- const pageHtml = await appendHead(
37
- this.indexHtml,
38
- imagePreloads,
39
- cssPreloads,
40
- jsPreloads
41
- )
42
- writeHtml(page, pageHtml, distDir)
7
+ export async function closeBundle(resolvedBundle) {
8
+ if (this.environment.name !== 'client') {
9
+ return
10
+ }
11
+ const { assetsInlineLimit } = this.environment.config.build
12
+ const { root, base } = this.environment.config
13
+ let distDir
14
+ if (isAbsolute(this.environment.config.build.outDir)) {
15
+ distDir = this.environment.config.build.outDir
16
+ } else {
17
+ distDir = join(root, this.environment.config.build.outDir)
18
+ }
19
+ const indexHtml = readFileSync(join(distDir, 'index.html'), 'utf8')
20
+ const pages = Object.fromEntries(
21
+ Object.entries(resolvedBundle ?? {})
22
+ .filter(([id, meta]) => {
23
+ if (meta.facadeModuleId?.includes('/pages/')) {
24
+ meta.htmlPath = meta.facadeModuleId.replace(/.*pages\/(.*)\.(j|t)sx$/, 'html/$1.html')
25
+ return true
26
+ }
27
+ })
28
+ )
29
+ for (const page of Object.values(pages)) {
30
+ const jsImports = page.imports
31
+ const cssImports = page.viteMetadata.importedCss
32
+ const images = page.moduleIds.filter((img) => {
33
+ return (page.modules[img].originalLength > assetsInlineLimit) && imageFileRE.test(img)
34
+ })
35
+ let imagePreloads = '\n'
36
+ for (let image of images) {
37
+ image = image.slice(root.length + 1)
38
+ imagePreloads += ` <link rel="preload" as="image" crossorigin href="${base}${image}">\n`
43
39
  }
40
+ let cssPreloads = ''
41
+ for (const css of cssImports) {
42
+ cssPreloads += ` <link rel="preload" as="style" crossorigin href="${base}${css}">\n`
43
+ }
44
+ let jsPreloads = ''
45
+ for (const js of jsImports) {
46
+ jsPreloads += ` <link rel="modulepreload" crossorigin href="${base}${js}">\n`
47
+ }
48
+ const pageHtml = await appendHead(
49
+ indexHtml,
50
+ imagePreloads,
51
+ cssPreloads,
52
+ jsPreloads
53
+ )
54
+ writeHtml(page, pageHtml, distDir)
44
55
  }
45
56
  }
46
57
 
package/plugin/stores.js CHANGED
@@ -11,7 +11,7 @@ function storeGetter (proxy, prop) {
11
11
  return proxy.context.state[proxy.key]
12
12
  }
13
13
  let method
14
- if (method = proxy.context.actions[proxy.key][prop]) {
14
+ if (method = proxy.context.actions?.[proxy.key]?.[prop]) {
15
15
  if (!proxy.wrappers[prop]) {
16
16
  proxy.wrappers[prop] = (...args) => {
17
17
  return method(proxy.context.state, ...args)
package/plugin/virtual.js CHANGED
@@ -6,6 +6,7 @@ import { findExports } from 'mlly'
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
7
 
8
8
  const virtualRoot = resolve(__dirname, '..', 'virtual')
9
+ const virtualRootTS = resolve(__dirname, '..', 'virtual-ts')
9
10
 
10
11
  const virtualModules = [
11
12
  'mount.js',
@@ -20,9 +21,27 @@ const virtualModules = [
20
21
  'index.js',
21
22
  ]
22
23
 
24
+ const virtualModulesTS = [
25
+ 'mount.ts',
26
+ 'resource.ts',
27
+ 'routes.ts',
28
+ 'layouts.ts',
29
+ 'create.tsx',
30
+ 'root.tsx',
31
+ 'layouts/',
32
+ 'context.ts',
33
+ 'core.tsx',
34
+ 'index.ts',
35
+ ]
36
+
23
37
  export const prefix = /^\/?\$app\//
24
38
 
25
39
  export async function resolveId (id) {
40
+ // Paths are prefixed with .. on Windows by the glob import
41
+ if (process.platform === 'win32' && /^\.\.\/[C-Z]:/.test(id)) {
42
+ return id.substring(3)
43
+ }
44
+
26
45
  if (prefix.test(id)) {
27
46
  const [, virtual] = id.split(prefix)
28
47
  if (virtual) {
@@ -37,19 +56,34 @@ export async function resolveId (id) {
37
56
 
38
57
  export function loadVirtualModule (virtualInput) {
39
58
  let virtual = virtualInput
40
- if (!/\.((mc)?ts)|((mc)?js)|(jsx)$/.test(virtual)) {
41
- virtual += '.js'
42
- }
43
- if (!virtualModules.includes(virtual)) {
59
+ if (!virtualModules.includes(virtual) && !virtualModulesTS.includes(virtual)) {
44
60
  return
45
61
  }
46
- const code = readFileSync(resolve(virtualRoot, virtual), 'utf8')
62
+ let virtualRootDir = virtualRoot
63
+ if (virtualInput.match(/\.tsx?$/)) {
64
+ virtualRootDir = virtualRootTS
65
+ }
66
+ const codePath = resolve(virtualRootDir, virtual)
47
67
  return {
48
- code,
68
+ code: readFileSync(codePath, 'utf8'),
49
69
  map: null,
50
70
  }
51
71
  }
52
72
 
73
+
74
+ virtualModulesTS.includes = function (virtual) {
75
+ if (!virtual) {
76
+ return false
77
+ }
78
+ for (const entry of this) {
79
+ if (virtual.startsWith(entry)) {
80
+ return true
81
+ }
82
+ }
83
+ return false
84
+ }
85
+
86
+
53
87
  virtualModules.includes = function (virtual) {
54
88
  if (!virtual) {
55
89
  return false
@@ -62,11 +96,16 @@ virtualModules.includes = function (virtual) {
62
96
  return false
63
97
  }
64
98
 
65
- function loadVirtualModuleOverride (viteProjectRoot, virtual) {
66
- if (!virtualModules.includes(virtual)) {
99
+ function loadVirtualModuleOverride (viteProjectRoot, virtualInput) {
100
+ let virtual = virtualInput
101
+ if (!virtualModules.includes(virtual) && !virtualModulesTS.includes(virtual)) {
67
102
  return
68
103
  }
69
- const overridePath = resolve(viteProjectRoot, virtual)
104
+ let overridePath = resolve(viteProjectRoot, virtual)
105
+ if (existsSync(overridePath)) {
106
+ return overridePath
107
+ }
108
+ overridePath = overridePath.replace('.js', '.ts')
70
109
  if (existsSync(overridePath)) {
71
110
  return overridePath
72
111
  }
package/rendering.js CHANGED
@@ -6,7 +6,7 @@ import { Minipass } from 'minipass'
6
6
  // which enables the combination of React.lazy() and Suspense
7
7
  import { renderToPipeableStream } from 'react-dom/server'
8
8
  import * as devalue from 'devalue'
9
- import Head from 'unihead'
9
+ import { transformHtmlTemplate } from '@unhead/react/server'
10
10
  import { createHtmlTemplates } from './templating.js'
11
11
 
12
12
  // Helper function to get an AsyncIterable (via PassThrough)
@@ -46,6 +46,7 @@ export function onAllReady(app) {
46
46
  export async function createRenderFunction ({ routes, create }) {
47
47
  // Used when hydrating React Router on the client
48
48
  const routeMap = Object.fromEntries(routes.map(_ => [_.path, _]))
49
+
49
50
  // Registered as reply.render()
50
51
  return function () {
51
52
  if (this.request.route.streaming) {
@@ -80,6 +81,7 @@ export async function createHtmlFunction (source, _, config) {
80
81
  return async function () {
81
82
  const { routes, context, body } = await this.render()
82
83
 
84
+ context.useHead.push(context.head)
83
85
  this.type('text/html')
84
86
 
85
87
  // Use template with client module import removed
@@ -116,24 +118,35 @@ export async function createHtmlFunction (source, _, config) {
116
118
  }
117
119
  }
118
120
 
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
- }`
121
+ export async function sendClientOnlyShell (templates, context) {
122
+ return await transformHtmlTemplate(
123
+ context.useHead,
124
+ `${
125
+ templates.beforeElement(context)
126
+ }${
127
+ templates.afterElement(context)
128
+ }`
129
+ )
126
130
  }
127
131
 
128
132
  export function streamShell (templates, context, body) {
129
- context.head = new Head(context.head).render()
130
133
  return Readable.from(createShellStream(templates, context, body))
131
134
  }
132
135
 
133
136
  async function * createShellStream (templates, context, body) {
134
- yield templates.beforeElement(context)
137
+ yield await transformHtmlTemplate(
138
+ context.useHead,
139
+ templates.beforeElement(context)
140
+ )
141
+
135
142
  for await (const chunk of body) {
136
- yield chunk
143
+ yield await transformHtmlTemplate(
144
+ context.useHead,
145
+ chunk.toString()
146
+ )
137
147
  }
138
- yield templates.afterElement(context)
148
+ yield await transformHtmlTemplate(
149
+ context.useHead,
150
+ templates.afterElement(context)
151
+ )
139
152
  }
package/routing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, isAbsolute } from 'node:path'
3
3
  import Youch from 'youch'
4
4
  import RouteContext from './context.js'
5
5
  import { createHtmlFunction } from './rendering.js'
@@ -111,8 +111,12 @@ export async function createRoute ({ client, errorHandler, route }, scope, confi
111
111
  handler = (_, reply) => reply.html()
112
112
  } else {
113
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')
114
+ const htmlPath = id.replace(/pages\/(.*?)\.(j|t)sx/, 'html/$1.html')
115
+ let distDir = config.vite.build.outDir
116
+ if (!isAbsolute(config.vite.build.outDir)) {
117
+ distDir = join(config.vite.root, distDir)
118
+ }
119
+ const htmlSource = readFileSync(join(distDir, htmlPath), 'utf8')
116
120
  const htmlFunction = await createHtmlFunction(htmlSource, scope, config)
117
121
  handler = (_, reply) => htmlFunction.call(reply)
118
122
  }
@@ -120,12 +124,13 @@ export async function createRoute ({ client, errorHandler, route }, scope, confi
120
124
  // Replace wildcard routes with Fastify compatible syntax
121
125
  const routePath = route.path.replace(/:[^+]+\+/, '*')
122
126
 
127
+ unshiftHook(route, 'onRequest', onRequest)
128
+ unshiftHook(route, 'preHandler', preHandler)
129
+
123
130
  scope.route({
124
131
  url: routePath,
125
132
  method: route.method ?? ['GET', 'POST', 'PUT', 'DELETE'],
126
133
  errorHandler,
127
- onRequest,
128
- preHandler,
129
134
  handler,
130
135
  ...route,
131
136
  })
@@ -140,3 +145,16 @@ export async function createRoute ({ client, errorHandler, route }, scope, confi
140
145
  })
141
146
  }
142
147
  }
148
+
149
+ function unshiftHook (route, hookName, hook) {
150
+ if (!route[hookName]) {
151
+ route[hookName] = []
152
+ }
153
+ if (!Array.isArray(hook)) {
154
+ hook = [hook]
155
+ }
156
+ if (!Array.isArray(route[hookName])) {
157
+ route[hookName] = [route[hookName]]
158
+ }
159
+ route[hookName] = [...route[hookName], ...hook]
160
+ }
package/server.js CHANGED
@@ -39,7 +39,7 @@ export function prepareServer(server) {
39
39
  })
40
40
  }
41
41
 
42
- export async function createRoutes (fromPromise, { param } = { param: /\[([\.\w]+\+?)\]/ }) {
42
+ export async function createRoutes (fromPromise, { param } = { param: /\[([.\w]+\+?)\]/ }) {
43
43
  const { default: from } = await fromPromise
44
44
  const importPaths = Object.keys(from)
45
45
  const promises = []
@@ -117,6 +117,19 @@ function getRouteModuleExports (routeModule) {
117
117
  streaming: routeModule.streaming,
118
118
  clientOnly: routeModule.clientOnly,
119
119
  serverOnly: routeModule.serverOnly,
120
+ // Server configure function
121
+ configure: routeModule.configure,
122
+ // Route-level Fastify hooks
123
+ onRequest: routeModule.onRequest ?? undefined,
124
+ preParsing: routeModule.preParsing ?? undefined,
125
+ preValidation: routeModule.preValidation ?? undefined,
126
+ preHandler: routeModule.preHandler ?? undefined,
127
+ preSerialization: routeModule.preSerialization ?? undefined,
128
+ onError: routeModule.onError ?? undefined,
129
+ onSend: routeModule.onSend ?? undefined,
130
+ onResponse: routeModule.onResponse ?? undefined,
131
+ onTimeout: routeModule.onTimeout ?? undefined,
132
+ onRequestAbort: routeModule.onRequestAbort ?? undefined,
120
133
  }
121
134
  }
122
135
 
package/virtual/core.jsx CHANGED
@@ -33,7 +33,7 @@ export function useServerAction(action, options = {}) {
33
33
  return actionData[action]
34
34
  }
35
35
 
36
- export function AppRoute({ head, ctxHydration, ctx, children }) {
36
+ export function AppRoute({ ctxHydration, ctx, children }) {
37
37
  // If running on the server, assume all data
38
38
  // functions have already ran through the preHandler hook
39
39
  if (isServer) {
@@ -100,7 +100,8 @@ export function AppRoute({ head, ctxHydration, ctx, children }) {
100
100
  if (!ctx.firstRender && ctx.getMeta) {
101
101
  const updateMeta = async () => {
102
102
  const { getMeta } = await ctx.loader()
103
- head.update(await getMeta(ctx))
103
+ ctx.head = await getMeta(ctx)
104
+ ctxHydration.useHead.push(ctx.head)
104
105
  }
105
106
  waitResource(path, 'updateMeta', updateMeta)
106
107
  }
@@ -1,5 +1,19 @@
1
+ import {
2
+ UnheadProvider as ClientUnheadProvider
3
+ } from '@unhead/react/client'
4
+ import {
5
+ UnheadProvider as ServerUnheadProvider
6
+ } from '@unhead/react/server?server'
7
+
1
8
  import Root from '$app/root.jsx'
2
9
 
3
10
  export default function create({ url, ...serverInit }) {
4
- return <Root url={url} {...serverInit} />
11
+ const UnheadProvider = import.meta.env.SSR
12
+ ? ServerUnheadProvider
13
+ : ClientUnheadProvider
14
+ return (
15
+ <UnheadProvider value={serverInit.ctxHydration.useHead}>
16
+ <Root url={url} {...serverInit} />
17
+ </UnheadProvider>
18
+ )
5
19
  }
package/virtual/mount.js CHANGED
@@ -1,23 +1,27 @@
1
1
  import { createRoot, hydrateRoot } from 'react-dom/client'
2
- import Head from 'unihead/client'
3
2
  import { hydrateRoutes } from '@fastify/react/client'
3
+ import { createHead } from '@unhead/react/client'
4
4
  import routes from '$app/routes.js'
5
5
  import create from '$app/create.jsx'
6
6
  import * as context from '$app/context.js'
7
7
 
8
8
  async function mountApp (...targets) {
9
9
  const ctxHydration = await extendContext(window.route, context)
10
- const head = new Head(window.route.head, window.document)
11
10
  const resolvedRoutes = await hydrateRoutes(routes)
12
11
  const routeMap = Object.fromEntries(
13
12
  resolvedRoutes.map((route) => [route.path, route]),
14
13
  )
14
+ const useHead = createHead()
15
+ ctxHydration.useHead = useHead
16
+ ctxHydration.useHead.push(window.route.head)
17
+
15
18
  const app = create({
16
- head,
17
19
  ctxHydration,
18
20
  routes: window.routes,
19
21
  routeMap,
20
22
  })
23
+
24
+
21
25
  let mountTargetFound = false
22
26
  for (const target of targets) {
23
27
  const targetElem = document.querySelector(target)
@@ -0,0 +1,4 @@
1
+ // This file serves as a placeholder
2
+ // if no context.js file is provided
3
+
4
+ export default () => {}
@@ -0,0 +1,136 @@
1
+ import { createPath } from 'history'
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
+
9
+ export const isServer = import.meta.env.SSR
10
+ export const Router = isServer ? StaticRouter : BrowserRouter
11
+
12
+ let serverActionCounter = 0
13
+
14
+ export function createServerAction(name) {
15
+ return `/-/action/${name ?? serverActionCounter++}`
16
+ }
17
+
18
+ export function useServerAction(action, options = {}) {
19
+ if (import.meta.env.SSR) {
20
+ const { req, server } = useRouteContext()
21
+ req.route.actionData[action] = waitFetch(
22
+ `${server.serverURL}${action}`,
23
+ options,
24
+ req.fetchMap,
25
+ )
26
+ return req.route.actionData[action]
27
+ }
28
+ const { actionData } = useRouteContext()
29
+ if (actionData[action]) {
30
+ return actionData[action]
31
+ }
32
+ actionData[action] = waitFetch(action, options)
33
+ return actionData[action]
34
+ }
35
+
36
+ export function AppRoute({ ctxHydration, ctx, children }) {
37
+ // If running on the server, assume all data
38
+ // functions have already ran through the preHandler hook
39
+ if (isServer) {
40
+ const Layout = layouts[ctxHydration.layout ?? 'default']
41
+ return (
42
+ <RouteContext.Provider
43
+ value={{
44
+ ...ctx,
45
+ ...ctxHydration,
46
+ state: isServer
47
+ ? ctxHydration.state ?? {}
48
+ : proxy(ctxHydration.state ?? {}),
49
+ }}
50
+ >
51
+ <Layout>{children}</Layout>
52
+ </RouteContext.Provider>
53
+ )
54
+ }
55
+ // Note that on the client, window.route === ctxHydration
56
+
57
+ // Indicates whether or not this is a first render on the client
58
+ ctx.firstRender = window.route.firstRender
59
+
60
+ // If running on the client, the server context data
61
+ // is still available, hydrated from window.route
62
+ if (ctx.firstRender) {
63
+ ctx.data = window.route.data
64
+ ctx.head = window.route.head
65
+ } else {
66
+ ctx.data = undefined
67
+ ctx.head = undefined
68
+ }
69
+
70
+ const location = useLocation()
71
+ const path = createPath(location)
72
+
73
+ // When the next route renders client-side,
74
+ // force it to execute all URMA hooks again
75
+ // biome-ignore lint/correctness/useExhaustiveDependencies: I'm inclined to believe you, Biome, but I'm not risking it.
76
+ useEffect(() => {
77
+ window.route.firstRender = false
78
+ window.route.actionData = {}
79
+ }, [location])
80
+
81
+ // If we have a getData function registered for this route
82
+ if (!ctx.data && ctx.getData) {
83
+ try {
84
+ const { pathname, search } = location
85
+ // If not, fetch data from the JSON endpoint
86
+ ctx.data = waitFetch(`/-/data${pathname}${search}`)
87
+ } catch (status) {
88
+ // If it's an actual error...
89
+ if (status instanceof Error) {
90
+ ctx.error = status
91
+ }
92
+ // If it's just a promise (suspended state)
93
+ throw status
94
+ }
95
+ }
96
+
97
+ // Note that ctx.loader() at this point will resolve the
98
+ // memoized module, so there's barely any overhead
99
+
100
+ if (!ctx.firstRender && ctx.getMeta) {
101
+ const updateMeta = async () => {
102
+ const { getMeta } = await ctx.loader()
103
+ ctx.head = await getMeta(ctx)
104
+ ctxHydration.useHead.push(ctx.head)
105
+ }
106
+ waitResource(path, 'updateMeta', updateMeta)
107
+ }
108
+
109
+ if (!ctx.firstRender && ctx.onEnter) {
110
+ const runOnEnter = async () => {
111
+ const { onEnter } = await ctx.loader()
112
+ const updatedData = await onEnter(ctx)
113
+ if (!ctx.data) {
114
+ ctx.data = {}
115
+ }
116
+ Object.assign(ctx.data, updatedData)
117
+ }
118
+ waitResource(path, 'onEnter', runOnEnter)
119
+ }
120
+
121
+ const Layout = layouts[ctx.layout ?? 'default']
122
+
123
+ return (
124
+ <RouteContext.Provider
125
+ value={{
126
+ ...ctxHydration,
127
+ ...ctx,
128
+ state: isServer
129
+ ? ctxHydration.state ?? {}
130
+ : proxy(ctxHydration.state ?? {}),
131
+ }}
132
+ >
133
+ <Layout>{children}</Layout>
134
+ </RouteContext.Provider>
135
+ )
136
+ }
@@ -0,0 +1,19 @@
1
+ import {
2
+ UnheadProvider as ClientUnheadProvider
3
+ } from '@unhead/react/client'
4
+ import {
5
+ UnheadProvider as ServerUnheadProvider
6
+ } from '@unhead/react/server?server'
7
+
8
+ import Root from '$app/root.jsx'
9
+
10
+ export default function create({ url, ...serverInit }) {
11
+ const UnheadProvider = import.meta.env.SSR
12
+ ? ServerUnheadProvider
13
+ : ClientUnheadProvider
14
+ return (
15
+ <UnheadProvider value={serverInit.ctxHydration.useHead}>
16
+ <Root url={url} {...serverInit} />
17
+ </UnheadProvider>
18
+ )
19
+ }
@@ -0,0 +1,7 @@
1
+ import { createRoutes } from '@fastify/react/server'
2
+
3
+ export default {
4
+ routes: createRoutes(import('$app/routes.ts')),
5
+ create: import('$app/create.tsx'),
6
+ context: import('$app/context.ts'),
7
+ }
@@ -0,0 +1,8 @@
1
+ // This file serves as a placeholder
2
+ // if no layouts/default.jsx file is provided
3
+
4
+ import { Suspense } from 'react'
5
+
6
+ export default function Layout({ children }) {
7
+ return <Suspense>{children}</Suspense>
8
+ }
@@ -0,0 +1,20 @@
1
+ import { lazy } from 'react'
2
+
3
+ const DefaultLayout = () => import('$app/layouts/default.tsx')
4
+
5
+ const appLayouts = import.meta.glob('/layouts/*.{jsx,tsx}')
6
+
7
+ if (
8
+ !Object.keys(appLayouts).some((path) =>
9
+ path.match(/\/layouts\/default\.(j|t)sx/),
10
+ )
11
+ ) {
12
+ appLayouts['/layouts/default.tsx'] = DefaultLayout
13
+ }
14
+
15
+ export default Object.fromEntries(
16
+ Object.keys(appLayouts).map((path) => {
17
+ const name = path.slice(9, -4)
18
+ return [name, lazy(appLayouts[path])]
19
+ }),
20
+ )
@@ -0,0 +1,58 @@
1
+ import { createRoot, hydrateRoot } from 'react-dom/client'
2
+ import { hydrateRoutes } from '@fastify/react/client'
3
+ import { createHead } from '@unhead/react/client'
4
+ import routes from '$app/routes.js'
5
+ import create from '$app/create.jsx'
6
+ import * as context from '$app/context.js'
7
+
8
+ async function mountApp (...targets) {
9
+ const ctxHydration = await extendContext(window.route, context)
10
+ const resolvedRoutes = await hydrateRoutes(routes)
11
+ const routeMap = Object.fromEntries(
12
+ resolvedRoutes.map((route) => [route.path, route]),
13
+ )
14
+ const useHead = createHead()
15
+ ctxHydration.useHead = useHead
16
+ ctxHydration.useHead.push(window.route.head)
17
+
18
+ const app = create({
19
+ ctxHydration,
20
+ routes: window.routes,
21
+ routeMap,
22
+ })
23
+
24
+
25
+ let mountTargetFound = false
26
+ for (const target of targets) {
27
+ const targetElem = document.querySelector(target)
28
+ if (targetElem) {
29
+ mountTargetFound = true
30
+ if (ctxHydration.clientOnly) {
31
+ createRoot(targetElem).render(app)
32
+ } else {
33
+ hydrateRoot(targetElem, app)
34
+ }
35
+ break
36
+ }
37
+ }
38
+ if (!mountTargetFound) {
39
+ throw new Error(`No mount element found from provided list of targets: ${targets}`)
40
+ }
41
+ }
42
+
43
+ mountApp('#root', 'main')
44
+
45
+ async function extendContext (ctx, {
46
+ // The route context initialization function
47
+ default: setter,
48
+ // We destructure state here just to discard it from extra
49
+ state,
50
+ // Other named exports from context.js
51
+ ...extra
52
+ }) {
53
+ Object.assign(ctx, extra)
54
+ if (setter) {
55
+ await setter(ctx)
56
+ }
57
+ return ctx
58
+ }
@@ -0,0 +1,82 @@
1
+ const clientFetchMap = new Map()
2
+ const clientResourceMap = new Map()
3
+
4
+ export function waitResource(
5
+ path,
6
+ id,
7
+ promise,
8
+ resourceMap = clientResourceMap,
9
+ ) {
10
+ const resourceId = `${path}:${id}`
11
+ const loaderStatus = resourceMap.get(resourceId)
12
+ if (loaderStatus) {
13
+ if (loaderStatus.error) {
14
+ throw loaderStatus.error
15
+ }
16
+ if (loaderStatus.suspended) {
17
+ throw loaderStatus.promise
18
+ }
19
+ resourceMap.delete(resourceId)
20
+
21
+ return loaderStatus.result
22
+ }
23
+ const loader = {
24
+ suspended: true,
25
+ error: null,
26
+ result: null,
27
+ promise: null,
28
+ }
29
+ loader.promise = promise()
30
+ .then((result) => {
31
+ loader.result = result
32
+ })
33
+ .catch((loaderError) => {
34
+ loader.error = loaderError
35
+ })
36
+ .finally(() => {
37
+ loader.suspended = false
38
+ })
39
+
40
+ resourceMap.set(resourceId, loader)
41
+
42
+ return waitResource(path, id)
43
+ }
44
+
45
+ export function waitFetch(path, options = {}, fetchMap = clientFetchMap) {
46
+ const loaderStatus = fetchMap.get(path)
47
+ if (loaderStatus) {
48
+ if (loaderStatus.error || loaderStatus.data?.statusCode === 500) {
49
+ if (loaderStatus.data?.statusCode === 500) {
50
+ throw new Error(loaderStatus.data.message)
51
+ }
52
+ throw loaderStatus.error
53
+ }
54
+ if (loaderStatus.suspended) {
55
+ throw loaderStatus.promise
56
+ }
57
+ fetchMap.delete(path)
58
+
59
+ return loaderStatus.data
60
+ }
61
+ const loader = {
62
+ suspended: true,
63
+ error: null,
64
+ data: null,
65
+ promise: null,
66
+ }
67
+ loader.promise = fetch(path, options)
68
+ .then((response) => response.json())
69
+ .then((loaderData) => {
70
+ loader.data = loaderData
71
+ })
72
+ .catch((loaderError) => {
73
+ loader.error = loaderError
74
+ })
75
+ .finally(() => {
76
+ loader.suspended = false
77
+ })
78
+
79
+ fetchMap.set(path, loader)
80
+
81
+ return waitFetch(path, options, fetchMap)
82
+ }
@@ -0,0 +1,29 @@
1
+ import { Suspense } from 'react'
2
+ import { Route, Routes } from 'react-router'
3
+ import { AppRoute, Router } from '$app/core.tsx'
4
+
5
+ export default function Root({ url, routes, head, ctxHydration, routeMap }) {
6
+ return (
7
+ <Suspense>
8
+ <Router location={url}>
9
+ <Routes>
10
+ {routes.map(({ path, component: Component }) => (
11
+ <Route
12
+ key={path}
13
+ path={path}
14
+ element={
15
+ <AppRoute
16
+ head={head}
17
+ ctxHydration={ctxHydration}
18
+ ctx={routeMap[path]}
19
+ >
20
+ <Component />
21
+ </AppRoute>
22
+ }
23
+ />
24
+ ))}
25
+ </Routes>
26
+ </Router>
27
+ </Suspense>
28
+ )
29
+ }
@@ -0,0 +1 @@
1
+ export default import.meta.glob('/pages/**/*.{jsx,tsx}')