@fastify/react 0.1.0

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/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # fastify-dx-react [![NPM version](https://img.shields.io/npm/v/fastify-dx-react.svg?style=flat)](https://www.npmjs.com/package/fastify-dx-react) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/)
2
+
3
+ - [**Introduction**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-react/README.md#introduction)
4
+ - [**Quick Start**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-react/README.md#quick-start)
5
+ - [**Package Scripts**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-react/README.md#package-scripts)
6
+ - [**Basic Setup**](https://github.com/fastify/fastify-dx/blob/main/docs/react/basic-setup.md)
7
+ - [**Project Structure**](https://github.com/fastify/fastify-dx/blob/main/docs/react/project-structure.md)
8
+ - [**Rendering Modes**](https://github.com/fastify/fastify-dx/blob/main/docs/react/rendering-modes.md)
9
+ - [**Routing Configuration**](https://github.com/fastify/fastify-dx/blob/main/docs/react/routing-config.md)
10
+ - [**Data Prefetching**](https://github.com/fastify/fastify-dx/blob/main/docs/react/data-prefetching.md)
11
+ - [**Route Layouts**](https://github.com/fastify/fastify-dx/blob/main/docs/react/route-layouts.md)
12
+ - [**Route Context**](https://github.com/fastify/fastify-dx/blob/main/docs/react/route-context.md)
13
+ - [**Route Enter Event**](https://github.com/fastify/fastify-dx/blob/main/docs/react/route-enter.md)
14
+ - [**Virtual Modules**](https://github.com/fastify/fastify-dx/blob/main/docs/react/virtual-modules.md)
15
+
16
+ ## Introduction
17
+
18
+ **Fastify DX for React** is a renderer adapter for [**fastify-vite**](https://github.com/fastify/fastify-vite).
19
+
20
+ It is a **fast**, **lightweight** alternative to Next.js and Remix packed with **Developer Experience** features.
21
+
22
+ It has an extremely small core (~1k LOC total) and is built on top of [Fastify](https://github.com/fastify/fastify), [Vite](https://vitejs.dev/), [React Router](https://reactrouter.com/docs/en/v6) and [Valtio](https://github.com/pmndrs/valtio).
23
+
24
+ [**See the release notes for the 0.0.1 alpha release**](https://github.com/fastify/fastify-dx/releases/tag/v0.0.1).
25
+
26
+ > At this stage this project is mostly a [**one-man show**](https://github.com/sponsors/galvez), who's devoting all his free time to its completion. Contributions are extremely welcome, as well as bug reports for any issues you may find.
27
+
28
+ In this first alpha release it's still missing a test suite. The same is true for [**fastify-vite**]().
29
+
30
+ It'll move into **beta** status when test suites are added to both packages.
31
+
32
+ ## Quick Start
33
+
34
+ Ensure you have **Node v16+**.
35
+
36
+ Make a copy of [**starters/react**](https://github.com/fastify/fastify-dx/tree/dev/starters/react). If you have [`degit`](https://github.com/Rich-Harris/degit), run the following from a new directory:
37
+
38
+ ```bash
39
+ degit fastify/fastify-dx/starters/react
40
+ ```
41
+
42
+ > **If you're starting a project from scratch**, you'll need these packages installed.
43
+ >
44
+ > ```bash
45
+ > npm i fastify fastify-vite fastify-dx-react -P
46
+ > npm i @vitejs/plugin-react -D
47
+ > ```
48
+
49
+
50
+ Run `npm install`.
51
+
52
+ Run `npm run dev`.
53
+
54
+ Visit `http://localhost:3000/`.
55
+
56
+ ## What's Included
57
+
58
+ That will get you a **starter template** with:
59
+
60
+ - A minimal [Fastify](https://github.com/fastify/fastify) server.
61
+ - Some dummy API routes.
62
+ - A `pages/` folder with some [demo routes](https://github.com/fastify/fastify-dx/tree/dev/starters/react/client/pages).
63
+ - All configuration files.
64
+
65
+ It also includes some _**opinionated**_ essentials:
66
+
67
+ - [**PostCSS Preset Env**](https://www.npmjs.com/package/postcss-preset-env) by [**Jonathan Neal**](https://github.com/jonathantneal), which enables [several modern CSS features](https://preset-env.cssdb.org/), such as [**CSS Nesting**](https://www.w3.org/TR/css-nesting-1/).
68
+
69
+ - [**UnoCSS**](https://github.com/unocss/unocss) by [**Anthony Fu**](https://antfu.me/), which supports all [Tailwind utilities](https://uno.antfu.me/) and many other goodies through its [default preset](https://github.com/unocss/unocss/tree/main/packages/preset-uno).
70
+
71
+ - [**Valtio**](https://github.com/pmndrs/valtio) by [**Daishi Kato**](https://blog.axlight.com/), with a global and SSR-ready store which you can use anywhere.
72
+
73
+
74
+ ## Package Scripts
75
+
76
+ `npm run dev` boots the development server.
77
+
78
+ `npm run build` creates the production bundle.
79
+
80
+ `npm run serve` serves the production bundle.
81
+
82
+ ## Meta
83
+
84
+ Created by [Jonas Galvez](https://github.com/sponsors/galvez), **Engineering Manager** and **Open Sourcerer** at [NearForm](https://nearform.com).
85
+
86
+ ## Sponsors
87
+
88
+ <a href="https://nearform.com"><img width="200px" src="https://user-images.githubusercontent.com/12291/172310344-594669fd-da4c-466b-a250-a898569dfea3.svg"></a>
89
+
90
+ Also [**Duc-Thien Bui**](https://github.com/aecea) and [**Tom Preston-Werner**](https://github.com/mojombo) [via GitHub Sponsors](https://github.com/sponsors/galvez). _Thank you!_
package/index.js ADDED
@@ -0,0 +1,178 @@
1
+ // Used to send a readable stream to reply.send()
2
+ import { Readable } from 'stream'
3
+
4
+ // fastify-vite's minimal HTML templating function,
5
+ // which extracts interpolation variables from comments
6
+ // and returns a function with the generated code
7
+ import { createHtmlTemplateFunction } from '@fastify/vite'
8
+
9
+ // Used to safely serialize JavaScript into
10
+ // <script> tags, preventing a few types of attack
11
+ import devalue from 'devalue'
12
+
13
+ // Small SSR-ready library used to generate
14
+ // <title>, <meta> and <link> elements
15
+ import Head from 'unihead'
16
+
17
+ // Helpers from the Node.js stream library to
18
+ // make it easier to work with renderToPipeableStream()
19
+ import { generateHtmlStream, onAllReady, onShellReady } from './server/stream.js'
20
+
21
+ // Holds the universal route context
22
+ import RouteContext from './server/context.js'
23
+
24
+ export default {
25
+ prepareClient,
26
+ createHtmlFunction,
27
+ createRenderFunction,
28
+ createRouteHandler,
29
+ createRoute,
30
+ }
31
+
32
+ export async function prepareClient ({
33
+ routes: routesPromise,
34
+ context: contextPromise,
35
+ ...others
36
+ }) {
37
+ const context = await contextPromise
38
+ const resolvedRoutes = await routesPromise
39
+ return { context, routes: resolvedRoutes, ...others }
40
+ }
41
+
42
+ // The return value of this function gets registered as reply.html()
43
+ export function createHtmlFunction (source, scope, config) {
44
+ // Templating functions for universal rendering (SSR+CSR)
45
+ const [unHeadSource, unFooterSource] = source.split('<!-- element -->')
46
+ const unHeadTemplate = createHtmlTemplateFunction(unHeadSource)
47
+ const unFooterTemplate = createHtmlTemplateFunction(unFooterSource)
48
+ // Templating functions for server-only rendering (SSR only)
49
+ const [soHeadSource, soFooterSource] = source
50
+ // Unsafe if dealing with user-input, but safe here
51
+ // where we control the index.html source
52
+ .replace(/<script[^>]+type="module"[^>]+>.*?<\/script>/g, '')
53
+ .split('<!-- element -->')
54
+ const soHeadTemplate = createHtmlTemplateFunction(soHeadSource)
55
+ const soFooterTemplate = createHtmlTemplateFunction(soFooterSource)
56
+ // This function gets registered as reply.html()
57
+ return function ({ routes, context, body }) {
58
+ // Decide which templating functions to use, with and without hydration
59
+ const headTemplate = context.serverOnly ? soHeadTemplate : unHeadTemplate
60
+ const footerTemplate = context.serverOnly ? soFooterTemplate : unFooterTemplate
61
+ // Render page-level <head> elements
62
+ const head = new Head(context.head).render()
63
+ // Create readable stream with prepended and appended chunks
64
+ const readable = Readable.from(generateHtmlStream({
65
+ body: body && (
66
+ context.streaming
67
+ ? onShellReady(body)
68
+ : onAllReady(body)
69
+ ),
70
+ head: headTemplate({ ...context, head }),
71
+ footer: () => footerTemplate({
72
+ ...context,
73
+ hydration: '',
74
+ // Decide whether or not to include the hydration script
75
+ ...!context.serverOnly && {
76
+ hydration: (
77
+ '<script>\n' +
78
+ `window.route = ${devalue(context.toJSON())}\n` +
79
+ `window.routes = ${devalue(routes.toJSON())}\n` +
80
+ '</script>'
81
+ )
82
+ },
83
+ }),
84
+ }))
85
+ // Send out header and readable stream with full response
86
+ this.type('text/html')
87
+ this.send(readable)
88
+ }
89
+ }
90
+
91
+ export async function createRenderFunction ({ routes, create }) {
92
+ // create is exported by client/index.js
93
+ return function (req) {
94
+ // Create convenience-access routeMap
95
+ const routeMap = Object.fromEntries(routes.toJSON().map((route) => {
96
+ return [route.path, route]
97
+ }))
98
+ // Creates main React component with all the SSR context it needs
99
+ const app = !req.route.clientOnly && create({
100
+ routes,
101
+ routeMap,
102
+ ctxHydration: req.route,
103
+ url: req.url,
104
+ })
105
+ // Perform SSR, i.e., turn app.instance into an HTML fragment
106
+ // The SSR context data is passed along so it can be inlined for hydration
107
+ return { routes, context: req.route, body: app }
108
+ }
109
+ }
110
+
111
+ export function createRouteHandler (client, scope, config) {
112
+ return function (req, reply) {
113
+ reply.html(reply.render(req))
114
+ return reply
115
+ }
116
+ }
117
+
118
+ export function createRoute ({ client, handler, errorHandler, route }, scope, config) {
119
+ const onRequest = async function onRequest (req, reply) {
120
+ req.route = await RouteContext.create(
121
+ scope,
122
+ req,
123
+ reply,
124
+ route,
125
+ client.context,
126
+ )
127
+ }
128
+ if (route.getData) {
129
+ // If getData is provided, register JSON endpoint for it
130
+ scope.get(`/-/data${route.path}`, {
131
+ onRequest,
132
+ async handler (req, reply) {
133
+ reply.send(await route.getData(req.route))
134
+ },
135
+ })
136
+ }
137
+
138
+ // See https://github.com/fastify/fastify-dx/blob/main/URMA.md
139
+ const hasURMAHooks = Boolean(
140
+ route.getData || route.getMeta || route.onEnter,
141
+ )
142
+
143
+ // Extend with route context initialization module
144
+ RouteContext.extend(client.context)
145
+
146
+ scope.get(route.path, {
147
+ onRequest,
148
+ // If either getData or onEnter are provided,
149
+ // make sure they run before the SSR route handler
150
+ ...hasURMAHooks && {
151
+ async preHandler (req, reply) {
152
+ try {
153
+ if (route.getData) {
154
+ req.route.data = await route.getData(req.route)
155
+ }
156
+ if (route.getMeta) {
157
+ req.route.head = await route.getMeta(req.route)
158
+ }
159
+ if (route.onEnter) {
160
+ if (!req.route.data) {
161
+ req.route.data = {}
162
+ }
163
+ const result = await route.onEnter(req.route)
164
+ Object.assign(req.route.data, result)
165
+ }
166
+ } catch (err) {
167
+ if (config.dev) {
168
+ console.error(err)
169
+ }
170
+ req.route.error = err
171
+ }
172
+ },
173
+ },
174
+ handler,
175
+ errorHandler,
176
+ ...route,
177
+ })
178
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "scripts": {
3
+ "lint": "eslint . --ext .js,.jsx --fix"
4
+ },
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "name": "@fastify/react",
8
+ "version": "0.1.0",
9
+ "files": [
10
+ "virtual/create.jsx",
11
+ "virtual/create.tsx",
12
+ "virtual/root.jsx",
13
+ "virtual/root.tsx",
14
+ "virtual/layouts.js",
15
+ "virtual/layouts/default.jsx",
16
+ "virtual/context.js",
17
+ "virtual/context.ts",
18
+ "virtual/mount.js",
19
+ "virtual/mount.ts",
20
+ "virtual/resource.js",
21
+ "virtual/core.jsx",
22
+ "virtual/routes.js",
23
+ "index.js",
24
+ "plugin.cjs",
25
+ "server/context.js",
26
+ "server/stream.js"
27
+ ],
28
+ "license": "MIT",
29
+ "exports": {
30
+ ".": "./index.js",
31
+ "./plugin": "./plugin.cjs"
32
+ },
33
+ "dependencies": {
34
+ "devalue": "^2.0.1",
35
+ "history": "^5.3.0",
36
+ "minipass": "^3.3.4",
37
+ "react": "^18.2.0",
38
+ "react-dom": "^18.2.0",
39
+ "react-router-dom": "^6.4.3",
40
+ "unihead": "^0.0.6",
41
+ "valtio": "^1.7.2"
42
+ },
43
+ "devDependencies": {
44
+ "@babel/eslint-parser": "^7.16.0",
45
+ "@babel/preset-react": "^7.16.0",
46
+ "@vitejs/plugin-react": "^2.2.0",
47
+ "eslint": "^7.32.0",
48
+ "eslint-config-standard": "^16.0.2",
49
+ "eslint-plugin-import": "^2.22.1",
50
+ "eslint-plugin-node": "^11.1.0",
51
+ "eslint-plugin-promise": "^4.3.1",
52
+ "eslint-plugin-react": "^7.29.4"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
package/plugin.cjs ADDED
@@ -0,0 +1,112 @@
1
+ const { readFileSync, existsSync } = require('fs')
2
+ const { dirname, join, resolve } = require('path')
3
+ const { fileURLToPath } = require('url')
4
+
5
+ function viteReactFastifyDX (config = {}) {
6
+ const prefix = /^\/?dx:/
7
+ const routing = Object.assign({
8
+ globPattern: '/pages/**/*.(jsx|tsx)',
9
+ paramPattern: /\[(\w+)\]/,
10
+ }, config)
11
+ const virtualRoot = resolve(__dirname, 'virtual')
12
+ const virtualModules = [
13
+ 'mount.js',
14
+ 'mount.ts',
15
+ 'resource.js',
16
+ 'resource.ts',
17
+ 'routes.js',
18
+ 'layouts.js',
19
+ 'create.jsx',
20
+ 'create.tsx',
21
+ 'root.jsx',
22
+ 'root.tsx',
23
+ 'layouts/',
24
+ 'context.js',
25
+ 'context.ts',
26
+ 'core.jsx'
27
+ ]
28
+ virtualModules.includes = function (virtual) {
29
+ if (!virtual) {
30
+ return false
31
+ }
32
+ for (const entry of this) {
33
+ if (virtual.startsWith(entry)) {
34
+ return true
35
+ }
36
+ }
37
+ return false
38
+ }
39
+ const virtualModuleInserts = {
40
+ 'routes.js': {
41
+ $globPattern: routing.globPattern,
42
+ $paramPattern: routing.paramPattern,
43
+ }
44
+ }
45
+
46
+ let viteProjectRoot
47
+
48
+ function loadVirtualModuleOverride (virtual) {
49
+ if (!virtualModules.includes(virtual)) {
50
+ return
51
+ }
52
+ const overridePath = resolve(viteProjectRoot, virtual)
53
+ if (existsSync(overridePath)) {
54
+ return overridePath
55
+ }
56
+ }
57
+
58
+ function loadVirtualModule (virtual) {
59
+ if (!virtualModules.includes(virtual)) {
60
+ return
61
+ }
62
+ let code = readFileSync(resolve(virtualRoot, virtual), 'utf8')
63
+ if (virtualModuleInserts[virtual]) {
64
+ for (const [key, value] of Object.entries(virtualModuleInserts[virtual])) {
65
+ code = code.replace(new RegExp(escapeRegExp(key), 'g'), value)
66
+ }
67
+ }
68
+ return {
69
+ code,
70
+ map: null,
71
+ }
72
+ }
73
+
74
+ // Thanks to https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
75
+ function escapeRegExp (s) {
76
+ return s
77
+ .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
78
+ .replace(/-/g, '\\x2d')
79
+ }
80
+
81
+ return {
82
+ name: 'vite-plugin-react-fastify-dx',
83
+ config (config, { command }) {
84
+ if (command === 'build' && config.build?.ssr) {
85
+ config.build.rollupOptions = {
86
+ output: {
87
+ format: 'es',
88
+ },
89
+ }
90
+ }
91
+ },
92
+ configResolved (config) {
93
+ viteProjectRoot = config.root
94
+ },
95
+ async resolveId (id) {
96
+ const [, virtual] = id.split(prefix)
97
+ if (virtual) {
98
+ const override = await loadVirtualModuleOverride(virtual)
99
+ if (override) {
100
+ return override
101
+ }
102
+ return id
103
+ }
104
+ },
105
+ load (id) {
106
+ const [, virtual] = id.split(prefix)
107
+ return loadVirtualModule(virtual)
108
+ },
109
+ }
110
+ }
111
+
112
+ module.exports = viteReactFastifyDX
@@ -0,0 +1,65 @@
1
+
2
+ const routeContextInspect = Symbol.for('nodejs.util.inspect.custom')
3
+
4
+ export default class RouteContext {
5
+ static async create (server, req, reply, route, contextInit) {
6
+ const routeContext = new RouteContext(server, req, reply, route)
7
+ if (contextInit) {
8
+ if (contextInit.state) {
9
+ routeContext.state = contextInit.state()
10
+ }
11
+ if (contextInit.default) {
12
+ await contextInit.default(routeContext)
13
+ }
14
+ }
15
+ return routeContext
16
+ }
17
+
18
+ constructor (server, req, reply, route) {
19
+ this.server = server
20
+ this.req = req
21
+ this.reply = reply
22
+ this.head = {}
23
+ this.state = null
24
+ this.data = route.data
25
+ this.firstRender = true
26
+ this.layout = route.layout
27
+ this.getMeta = !!route.getMeta
28
+ this.getData = !!route.getData
29
+ this.onEnter = !!route.onEnter
30
+ this.streaming = route.streaming
31
+ this.clientOnly = route.clientOnly
32
+ this.serverOnly = route.serverOnly
33
+ }
34
+
35
+ [routeContextInspect] () {
36
+ return {
37
+ ...this,
38
+ server: { [routeContextInspect]: () => '[Server]' },
39
+ req: { [routeContextInspect]: () => '[Request]' },
40
+ reply: { [routeContextInspect]: () => '[Reply]' },
41
+ }
42
+ }
43
+
44
+ toJSON () {
45
+ return {
46
+ state: this.state,
47
+ data: this.data,
48
+ layout: this.layout,
49
+ getMeta: this.getMeta,
50
+ getData: this.getData,
51
+ onEnter: this.onEnter,
52
+ firstRender: this.firstRender,
53
+ clientOnly: this.clientOnly,
54
+ }
55
+ }
56
+ }
57
+
58
+ RouteContext.extend = function (initial) {
59
+ const { default: _, ...extra } = initial
60
+ for (const [prop, value] of Object.entries(extra)) {
61
+ if (prop !== 'data' && prop !== 'state') {
62
+ Object.defineProperty(RouteContext.prototype, prop, value)
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,53 @@
1
+
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
+
6
+ // React 18's preferred server-side rendering function,
7
+ // which enables the combination of React.lazy() and Suspense
8
+ import { renderToPipeableStream } from 'react-dom/server'
9
+
10
+ // Helper function to prepend and append chunks the body stream
11
+ export async function * generateHtmlStream ({ head, body, footer }) {
12
+ yield head
13
+ if (body) {
14
+ for await (const chunk of await body) {
15
+ yield chunk
16
+ }
17
+ }
18
+ yield footer()
19
+ }
20
+
21
+ // Helper function to get an AsyncIterable (via PassThrough)
22
+ // from the renderToPipeableStream() onShellReady event
23
+ export function onShellReady (app) {
24
+ const duplex = new Minipass()
25
+ return new Promise((resolve, reject) => {
26
+ try {
27
+ const pipeable = renderToPipeableStream(app, {
28
+ onShellReady () {
29
+ resolve(pipeable.pipe(duplex))
30
+ },
31
+ })
32
+ } catch (error) {
33
+ resolve(error)
34
+ }
35
+ })
36
+ }
37
+
38
+ // Helper function to get an AsyncIterable (via Minipass)
39
+ // from the renderToPipeableStream() onAllReady event
40
+ export function onAllReady (app) {
41
+ const duplex = new Minipass()
42
+ return new Promise((resolve, reject) => {
43
+ try {
44
+ const pipeable = renderToPipeableStream(app, {
45
+ onAllReady () {
46
+ resolve(pipeable.pipe(duplex))
47
+ },
48
+ })
49
+ } catch (error) {
50
+ resolve(error)
51
+ }
52
+ })
53
+ }
@@ -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,4 @@
1
+ // This file serves as a placeholder
2
+ // if no context.js file is provided
3
+
4
+ export default () => {}
@@ -0,0 +1,145 @@
1
+ import { createContext, useContext, useEffect } from 'react'
2
+ import { useLocation, BrowserRouter, Routes, Route } from 'react-router-dom'
3
+ import { StaticRouter } from 'react-router-dom/server.mjs'
4
+ import { createPath } from 'history'
5
+ import { proxy, useSnapshot } from 'valtio'
6
+ import { waitResource, waitFetch } from '/dx:resource.js'
7
+ import layouts from '/dx:layouts.js'
8
+
9
+ export const isServer = import.meta.env.SSR
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
+
23
+ export function DXApp ({
24
+ url,
25
+ routes,
26
+ head,
27
+ routeMap,
28
+ ctxHydration,
29
+ }) {
30
+ return (
31
+ <Router location={url}>
32
+ <Routes>{
33
+ routes.map(({ path, component: Component }) =>
34
+ <Route
35
+ key={path}
36
+ path={path}
37
+ element={
38
+ <DXRoute
39
+ head={head}
40
+ ctxHydration={ctxHydration}
41
+ ctx={routeMap[path]}>
42
+ <Component />
43
+ </DXRoute>
44
+ } />,
45
+ )
46
+ }</Routes>
47
+ </Router>
48
+ )
49
+ }
50
+
51
+ export function DXRoute ({ head, ctxHydration, ctx, children }) {
52
+ // If running on the server, assume all data
53
+ // functions have already ran through the preHandler hook
54
+ if (isServer) {
55
+ const Layout = layouts[ctxHydration.layout ?? 'default']
56
+ return (
57
+ <RouteContext.Provider value={{
58
+ ...ctx,
59
+ ...ctxHydration,
60
+ state: isServer
61
+ ? ctxHydration.state
62
+ : proxy(ctxHydration.state),
63
+ }}>
64
+ <Layout>
65
+ {children}
66
+ </Layout>
67
+ </RouteContext.Provider>
68
+ )
69
+ }
70
+ // Note that on the client, window.route === ctxHydration
71
+
72
+ // Indicates whether or not this is a first render on the client
73
+ ctx.firstRender = window.route.firstRender
74
+
75
+ // If running on the client, the server context data
76
+ // is still available, hydrated from window.route
77
+ if (ctx.firstRender) {
78
+ ctx.data = window.route.data
79
+ ctx.head = window.route.head
80
+ }
81
+
82
+ const location = useLocation()
83
+ const path = createPath(location)
84
+
85
+ // When the next route renders client-side,
86
+ // force it to execute all URMA hooks again
87
+ useEffect(() => {
88
+ window.route.firstRender = false
89
+ }, [location])
90
+
91
+ // If we have a getData function registered for this route
92
+ if (!ctx.data && ctx.getData) {
93
+ try {
94
+ const { pathname, search } = location
95
+ // If not, fetch data from the JSON endpoint
96
+ ctx.data = waitFetch(`${pathname}${search}`)
97
+ } catch (status) {
98
+ // If it's an actual error...
99
+ if (status instanceof Error) {
100
+ ctx.error = status
101
+ }
102
+ // If it's just a promise (suspended state)
103
+ throw status
104
+ }
105
+ }
106
+
107
+ // Note that ctx.loader() at this point will resolve the
108
+ // memoized module, so there's barely any overhead
109
+
110
+ if (!ctx.firstRender && ctx.getMeta) {
111
+ const updateMeta = async () => {
112
+ const { getMeta } = await ctx.loader()
113
+ head.update(await getMeta(ctx))
114
+ }
115
+ waitResource(path, 'updateMeta', updateMeta)
116
+ }
117
+
118
+ if (!ctx.firstRender && ctx.onEnter) {
119
+ const runOnEnter = async () => {
120
+ const { onEnter } = await ctx.loader()
121
+ const updatedData = await onEnter(ctx)
122
+ if (!ctx.data) {
123
+ ctx.data = {}
124
+ }
125
+ Object.assign(ctx.data, updatedData)
126
+ }
127
+ waitResource(path, 'onEnter', runOnEnter)
128
+ }
129
+
130
+ const Layout = layouts[ctx.layout ?? 'default']
131
+
132
+ return (
133
+ <RouteContext.Provider value={{
134
+ ...ctxHydration,
135
+ ...ctx,
136
+ state: isServer
137
+ ? ctxHydration.state
138
+ : proxy(ctxHydration.state),
139
+ }}>
140
+ <Layout>
141
+ {children}
142
+ </Layout>
143
+ </RouteContext.Provider>
144
+ )
145
+ }
@@ -0,0 +1,7 @@
1
+ import Root from '/dx:root.jsx'
2
+
3
+ export default function create ({ url, ...serverInit }) {
4
+ return (
5
+ <Root url={url} {...serverInit} />
6
+ )
7
+ }
@@ -0,0 +1,7 @@
1
+ import Root from '/dx:root.tsx'
2
+
3
+ export default function create ({ url, ...serverInit }) {
4
+ return (
5
+ <Root url={url} {...serverInit} />
6
+ )
7
+ }
@@ -0,0 +1,12 @@
1
+ // This file serves as a placeholder
2
+ // if no layout.jsx file is provided
3
+
4
+ import { Suspense } from 'react'
5
+
6
+ export default function Layout ({ children }) {
7
+ return (
8
+ <Suspense>
9
+ {children}
10
+ </Suspense>
11
+ )
12
+ }
@@ -0,0 +1,14 @@
1
+ import { lazy } from 'react'
2
+
3
+ const DefaultLayout = () => import('/dx:layouts/default.jsx')
4
+
5
+ const appLayouts = import.meta.glob('/layouts/*.jsx')
6
+
7
+ appLayouts['/layouts/default.jsx'] ??= DefaultLayout
8
+
9
+ export default Object.fromEntries(
10
+ Object.keys(appLayouts).map((path) => {
11
+ const name = path.slice(9, -4)
12
+ return [name, lazy(appLayouts[path])]
13
+ }),
14
+ )
@@ -0,0 +1,47 @@
1
+ import Head from 'unihead/client'
2
+ import { createRoot, hydrateRoot } from 'react-dom/client'
3
+
4
+ import create from '/dx:create.jsx'
5
+ import routesPromise from '/dx:routes.js'
6
+
7
+ mount('main')
8
+
9
+ async function mount (target) {
10
+ if (typeof target === 'string') {
11
+ target = document.querySelector(target)
12
+ }
13
+ const context = await import('/dx:context.js')
14
+ const ctxHydration = await extendContext(window.route, context)
15
+ const head = new Head(window.route.head, window.document)
16
+ const resolvedRoutes = await routesPromise
17
+ const routeMap = Object.fromEntries(
18
+ resolvedRoutes.map((route) => [route.path, route]),
19
+ )
20
+
21
+ const app = create({
22
+ head,
23
+ ctxHydration,
24
+ routes: window.routes,
25
+ routeMap,
26
+ })
27
+ if (ctxHydration.clientOnly) {
28
+ createRoot(target).render(app)
29
+ } else {
30
+ hydrateRoot(target, app)
31
+ }
32
+ }
33
+
34
+ async function extendContext (ctx, {
35
+ // The route context initialization function
36
+ default: setter,
37
+ // We destructure state here just to discard it from extra
38
+ state,
39
+ // Other named exports from context.js
40
+ ...extra
41
+ }) {
42
+ Object.assign(ctx, extra)
43
+ if (setter) {
44
+ await setter(ctx)
45
+ }
46
+ return ctx
47
+ }
@@ -0,0 +1,47 @@
1
+ import Head from 'unihead/client'
2
+ import { createRoot, hydrateRoot } from 'react-dom/client'
3
+
4
+ import create from '/dx:create.tsx'
5
+ import routesPromise from '/dx:routes.js'
6
+
7
+ mount('main')
8
+
9
+ async function mount (target) {
10
+ if (typeof target === 'string') {
11
+ target = document.querySelector(target)
12
+ }
13
+ const context = await import('/dx:context.ts')
14
+ const ctxHydration = await extendContext(window.route, context)
15
+ const head = new Head(window.route.head, window.document)
16
+ const resolvedRoutes = await routesPromise
17
+ const routeMap = Object.fromEntries(
18
+ resolvedRoutes.map((route) => [route.path, route]),
19
+ )
20
+
21
+ const app = create({
22
+ head,
23
+ ctxHydration,
24
+ routes: window.routes,
25
+ routeMap,
26
+ })
27
+ if (ctxHydration.clientOnly) {
28
+ createRoot(target).render(app)
29
+ } else {
30
+ hydrateRoot(target, app)
31
+ }
32
+ }
33
+
34
+ async function extendContext (ctx, {
35
+ // The route context initialization function
36
+ default: setter,
37
+ // We destructure state here just to discard it from extra
38
+ state,
39
+ // Other named exports from context.js
40
+ ...extra
41
+ }) {
42
+ Object.assign(ctx, extra)
43
+ if (setter) {
44
+ await setter(ctx)
45
+ }
46
+ return ctx
47
+ }
@@ -0,0 +1,68 @@
1
+
2
+ const fetchMap = new Map()
3
+ const resourceMap = new Map()
4
+
5
+ export function waitResource (path, id, promise) {
6
+ const resourceId = `${path}:${id}`
7
+ const loader = resourceMap.get(resourceId)
8
+ if (loader) {
9
+ if (loader.error) {
10
+ throw loader.error
11
+ }
12
+ if (loader.suspended) {
13
+ throw loader.promise
14
+ }
15
+ resourceMap.delete(resourceId)
16
+
17
+ return loader.result
18
+ } else {
19
+ const loader = {
20
+ suspended: true,
21
+ error: null,
22
+ result: null,
23
+ promise: null,
24
+ }
25
+ loader.promise = promise()
26
+ .then((result) => { loader.result = result })
27
+ .catch((loaderError) => { loader.error = loaderError })
28
+ .finally(() => { loader.suspended = false })
29
+
30
+ resourceMap.set(resourceId, loader)
31
+
32
+ return waitResource(path, id)
33
+ }
34
+ }
35
+
36
+ export function waitFetch (path) {
37
+ const loader = fetchMap.get(path)
38
+ if (loader) {
39
+ if (loader.error || loader.data?.statusCode === 500) {
40
+ if (loader.data?.statusCode === 500) {
41
+ throw new Error(loader.data.message)
42
+ }
43
+ throw loader.error
44
+ }
45
+ if (loader.suspended) {
46
+ throw loader.promise
47
+ }
48
+ fetchMap.delete(path)
49
+
50
+ return loader.data
51
+ } else {
52
+ const loader = {
53
+ suspended: true,
54
+ error: null,
55
+ data: null,
56
+ promise: null,
57
+ }
58
+ loader.promise = fetch(`/-/data${path}`)
59
+ .then((response) => response.json())
60
+ .then((loaderData) => { loader.data = loaderData })
61
+ .catch((loaderError) => { loader.error = loaderError })
62
+ .finally(() => { loader.suspended = false })
63
+
64
+ fetchMap.set(path, loader)
65
+
66
+ return waitFetch(path)
67
+ }
68
+ }
@@ -0,0 +1,27 @@
1
+ import { Suspense } from 'react'
2
+ import { Routes, Route } from 'react-router-dom'
3
+ import { Router, DXRoute } from '/dx:core.jsx'
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
+ <DXRoute
16
+ head={head}
17
+ ctxHydration={ctxHydration}
18
+ ctx={routeMap[path]}>
19
+ <Component />
20
+ </DXRoute>
21
+ } />,
22
+ )
23
+ }</Routes>
24
+ </Router>
25
+ </Suspense>
26
+ )
27
+ }
@@ -0,0 +1,27 @@
1
+ import { Suspense } from 'react'
2
+ import { Routes, Route } from 'react-router-dom'
3
+ import { Router, DXRoute } from '/dx:core.jsx'
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
+ <DXRoute
16
+ head={head}
17
+ ctxHydration={ctxHydration}
18
+ ctx={routeMap[path]}>
19
+ <Component />
20
+ </DXRoute>
21
+ } />,
22
+ )
23
+ }</Routes>
24
+ </Router>
25
+ </Suspense>
26
+ )
27
+ }
@@ -0,0 +1,121 @@
1
+ /* global $paramPattern */
2
+
3
+ import { lazy } from 'react'
4
+
5
+ export default import.meta.env.SSR
6
+ ? createRoutes(import.meta.glob('$globPattern', { eager: true }))
7
+ : hydrateRoutes(import.meta.glob('$globPattern'))
8
+
9
+ async function createRoutes (from, { param } = { param: $paramPattern }) {
10
+ // Otherwise we get a ReferenceError, but since
11
+ // this function is only ran once, there's no overhead
12
+ class Routes extends Array {
13
+ toJSON () {
14
+ return this.map((route) => {
15
+ return {
16
+ id: route.id,
17
+ path: route.path,
18
+ layout: route.layout,
19
+ getData: !!route.getData,
20
+ getMeta: !!route.getMeta,
21
+ onEnter: !!route.onEnter,
22
+ }
23
+ })
24
+ }
25
+ }
26
+ const importPaths = Object.keys(from)
27
+ const promises = []
28
+ if (Array.isArray(from)) {
29
+ for (const routeDef of from) {
30
+ promises.push(
31
+ getRouteModule(routeDef.path, routeDef.component)
32
+ .then((routeModule) => {
33
+ return {
34
+ id: routeDef.path,
35
+ path: routeDef.path ?? routeModule.path,
36
+ ...routeModule,
37
+ }
38
+ }),
39
+ )
40
+ }
41
+ } else {
42
+ // Ensure that static routes have precedence over the dynamic ones
43
+ for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) {
44
+ promises.push(
45
+ getRouteModule(path, from[path])
46
+ .then((routeModule) => {
47
+ return {
48
+ id: path,
49
+ layout: routeModule.layout,
50
+ path: routeModule.path ?? path
51
+ // Remove /pages and .jsx extension
52
+ .slice(6, -4)
53
+ // Replace [id] with :id
54
+ .replace(param, (_, m) => `:${m}`)
55
+ // Replace '/index' with '/'
56
+ .replace(/\/index$/, '/')
57
+ // Remove trailing slashs
58
+ .replace(/(.+)\/+$/, (...m) => m[1]),
59
+ ...routeModule,
60
+ }
61
+ }),
62
+ )
63
+ }
64
+ }
65
+ return new Routes(...await Promise.all(promises))
66
+ }
67
+
68
+ async function hydrateRoutes (from) {
69
+ if (Array.isArray(from)) {
70
+ from = Object.fromEntries(
71
+ from.map((route) => [route.path, route]),
72
+ )
73
+ }
74
+ return window.routes.map((route) => {
75
+ route.loader = memoImport(from[route.id])
76
+ route.component = lazy(() => route.loader())
77
+ return route
78
+ })
79
+ }
80
+
81
+ function getRouteModuleExports (routeModule) {
82
+ return {
83
+ // The Route component (default export)
84
+ component: routeModule.default,
85
+ // The Layout Route component
86
+ layout: routeModule.layout,
87
+ // Route-level hooks
88
+ getData: routeModule.getData,
89
+ getMeta: routeModule.getMeta,
90
+ onEnter: routeModule.onEnter,
91
+ // Other Route-level settings
92
+ streaming: routeModule.streaming,
93
+ clientOnly: routeModule.clientOnly,
94
+ serverOnly: routeModule.serverOnly,
95
+ }
96
+ }
97
+
98
+ async function getRouteModule (path, routeModule) {
99
+ // const isServer = typeof process !== 'undefined'
100
+ if (typeof routeModule === 'function') {
101
+ routeModule = await routeModule()
102
+ return getRouteModuleExports(routeModule)
103
+ } else {
104
+ return getRouteModuleExports(routeModule)
105
+ }
106
+ }
107
+
108
+ function memoImport (func) {
109
+ // Otherwise we get a ReferenceError, but since this function
110
+ // is only ran once for each route, there's no overhead
111
+ const kFuncExecuted = Symbol('kFuncExecuted')
112
+ const kFuncValue = Symbol('kFuncValue')
113
+ func[kFuncExecuted] = false
114
+ return async function () {
115
+ if (!func[kFuncExecuted]) {
116
+ func[kFuncValue] = await func()
117
+ func[kFuncExecuted] = true
118
+ }
119
+ return func[kFuncValue]
120
+ }
121
+ }