@octanejs/app-core 0.2.0 → 0.3.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 +2 -0
- package/package.json +19 -3
- package/src/codegen.js +42 -4
- package/src/html.js +1 -0
- package/src/index.js +1 -0
- package/src/resolve-config.js +42 -1
- package/src/seeds.js +168 -0
- package/src/server/data-route.js +29 -0
- package/src/server/html-stream.js +12 -3
- package/src/server/production.js +20 -31
- package/src/server/route-data.js +1 -0
- package/src/server/rpc-registry.js +28 -0
- package/src/server/server-entry.js +15 -0
- package/src/server/server-functions.js +91 -0
- package/templates/data-root.tsrx +15 -0
- package/types/codegen.d.ts +13 -0
- package/types/data-root.d.ts +5 -0
- package/types/html.d.ts +14 -0
- package/types/index.d.ts +47 -0
- package/types/production.d.ts +17 -0
- package/types/seeds.d.ts +7 -0
- package/types/server-functions.d.ts +50 -0
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Bundler-neutral app primitives shared by Octane's Vite and Rsbuild integrations.
|
|
|
8
8
|
- `@octanejs/app-core/config-loader` loads TypeScript config files with either an injected development module runner or a neutral esbuild evaluator. `loadOctaneConfigWithMetadata` reports existing and missing dependencies for watch and cache invalidation.
|
|
9
9
|
- `@octanejs/app-core/routes` and `/middleware` provide the router and Fetch API request pipeline.
|
|
10
10
|
- `@octanejs/app-core/html`, `/production`, and `/node` provide streaming HTML composition, the production Fetch handler, and the optional Node HTTP bridge.
|
|
11
|
+
- `@octanejs/app-core/seeds` reads route seeds in the browser (`readRouteSeed`), which the server writes with `routeSeed(context, name, produce)` once a page has rendered. `@octanejs/app-core/data-root` is the `.tsrx` wrapper the config's `data` module renders routes inside.
|
|
12
|
+
- `@octanejs/app-core/server-functions` serves `module server` functions from a Node server an integration does not own, under any bundler: `createServerFunctions({ modules, middlewares })` answers their calls and runs other requests so a server render's calls run in process.
|
|
11
13
|
- `@octanejs/app-core/codegen` generates client hydration entries, template-free server manifests, and production server entries. Runtime module IDs, integration module IDs, generated-file directories, and application import specifiers are explicit inputs so generators remain usable across bundlers and renderer targets.
|
|
12
14
|
|
|
13
15
|
Development integrations should inject their native module runner into `loadOctaneConfigWithMetadata`. The neutral evaluator is intended for configuration/build discovery: it bundles static JS, TS, and server-compiled TSRX helpers, while preserving dynamic application imports as lazy watched references so config loading never traverses renderer-only asset graphs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
24
|
"types",
|
|
25
|
+
"templates",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
@@ -72,6 +73,21 @@
|
|
|
72
73
|
"types": "./types/node.d.ts",
|
|
73
74
|
"import": "./src/server/node-http.js",
|
|
74
75
|
"default": "./src/server/node-http.js"
|
|
76
|
+
},
|
|
77
|
+
"./seeds": {
|
|
78
|
+
"types": "./types/seeds.d.ts",
|
|
79
|
+
"import": "./src/seeds.js",
|
|
80
|
+
"default": "./src/seeds.js"
|
|
81
|
+
},
|
|
82
|
+
"./data-root": {
|
|
83
|
+
"types": "./types/data-root.d.ts",
|
|
84
|
+
"import": "./templates/data-root.tsrx",
|
|
85
|
+
"default": "./templates/data-root.tsrx"
|
|
86
|
+
},
|
|
87
|
+
"./server-functions": {
|
|
88
|
+
"types": "./types/server-functions.d.ts",
|
|
89
|
+
"import": "./src/server/server-functions.js",
|
|
90
|
+
"default": "./src/server/server-functions.js"
|
|
75
91
|
}
|
|
76
92
|
},
|
|
77
93
|
"dependencies": {
|
|
@@ -79,10 +95,10 @@
|
|
|
79
95
|
"esbuild": "^0.28.1"
|
|
80
96
|
},
|
|
81
97
|
"peerDependencies": {
|
|
82
|
-
"octane": "^0.
|
|
98
|
+
"octane": "^0.17.0"
|
|
83
99
|
},
|
|
84
100
|
"devDependencies": {
|
|
85
101
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "0.
|
|
102
|
+
"octane": "0.17.0"
|
|
87
103
|
}
|
|
88
104
|
}
|
package/src/codegen.js
CHANGED
|
@@ -374,7 +374,12 @@ async function bootstrapDocumentLifecycle() {}
|
|
|
374
374
|
* - `rootBoundary`: the config names a root pending or catch boundary.
|
|
375
375
|
* - `independentHydration` / `streamedSignals`: some compiled module has an
|
|
376
376
|
* independent island / streamed signal state, so its lazy runtime loads.
|
|
377
|
-
* - `
|
|
377
|
+
* - `data: true`: the config names a `data` module. The entry waits for the
|
|
378
|
+
* whole document, opens the page's store from the server's seed, and
|
|
379
|
+
* renders the route inside `DataRoot`, as the server did. Off by default,
|
|
380
|
+
* so an app without one never loads `octane/data`.
|
|
381
|
+
* - `voidRoot: true` hydrates without returned-value reconciliation. A `data`
|
|
382
|
+
* entry ignores it: its root is `DataRoot`, which no build proves void. Only a
|
|
378
383
|
* build that proved every page and layout it can render is a void `@{}`
|
|
379
384
|
* body, with no root boundary, may set it (see compiler/void-roots.js).
|
|
380
385
|
*
|
|
@@ -393,6 +398,9 @@ async function bootstrapDocumentLifecycle() {}
|
|
|
393
398
|
* runtimeModuleId?: string,
|
|
394
399
|
* generatedBy?: string,
|
|
395
400
|
* rootBoundary?: boolean,
|
|
401
|
+
* data?: boolean,
|
|
402
|
+
* dataRootModuleId?: string,
|
|
403
|
+
* seedsModuleId?: string,
|
|
396
404
|
* voidRoot?: boolean,
|
|
397
405
|
* independentHydration?: boolean,
|
|
398
406
|
* streamedSignals?: boolean,
|
|
@@ -403,15 +411,26 @@ export function create_client_entry_source(options = {}) {
|
|
|
403
411
|
const runtimeModuleId =
|
|
404
412
|
options.runtimeModuleId ?? fileURLToPath(new URL('./client-entry-runtime.js', import.meta.url));
|
|
405
413
|
const rootBoundary = options.rootBoundary !== false;
|
|
414
|
+
const data = options.data === true;
|
|
415
|
+
const dataImports = data
|
|
416
|
+
? `import { DataRoot } from ${JSON.stringify(
|
|
417
|
+
options.dataRootModuleId ??
|
|
418
|
+
fileURLToPath(new URL('../templates/data-root.tsrx', import.meta.url)),
|
|
419
|
+
)};
|
|
420
|
+
import { readRouteSeed } from ${JSON.stringify(
|
|
421
|
+
options.seedsModuleId ?? fileURLToPath(new URL('./seeds.js', import.meta.url)),
|
|
422
|
+
)};`
|
|
423
|
+
: '';
|
|
406
424
|
const imports = [
|
|
407
|
-
options.voidRoot ? '__hydrateVoidRoot as hydrateRoot' : 'hydrateRoot',
|
|
425
|
+
options.voidRoot && !data ? '__hydrateVoidRoot as hydrateRoot' : 'hydrateRoot',
|
|
408
426
|
'initializeHydrationEventCapture',
|
|
409
427
|
...(rootBoundary ? ['Suspense', 'ErrorBoundary', 'createElement'] : []),
|
|
410
428
|
];
|
|
411
429
|
return (
|
|
412
430
|
client_entry_prelude(
|
|
413
431
|
options,
|
|
414
|
-
`import { ${imports.join(', ')} } from ${JSON.stringify(runtimeModuleId)}
|
|
432
|
+
`import { ${imports.join(', ')} } from ${JSON.stringify(runtimeModuleId)};` +
|
|
433
|
+
(dataImports === '' ? '' : '\n' + dataImports),
|
|
415
434
|
) +
|
|
416
435
|
`function getComponentExport(module, exportName) {
|
|
417
436
|
// Explicit export name requires an exact match; do NOT fall back, so a
|
|
@@ -559,7 +578,26 @@ ${
|
|
|
559
578
|
Content = withRootBoundary(Content, rootBoundary);
|
|
560
579
|
`
|
|
561
580
|
: ''
|
|
562
|
-
}
|
|
581
|
+
}${
|
|
582
|
+
data
|
|
583
|
+
? ` if (data.data) {
|
|
584
|
+
// The store's seed follows the page's content, so wait for the whole document.
|
|
585
|
+
if (document.readyState === 'loading') {
|
|
586
|
+
await new Promise((ready) => document.addEventListener('DOMContentLoaded', ready, { once: true }));
|
|
587
|
+
}
|
|
588
|
+
const dataModule = await importModule(data.data);
|
|
589
|
+
if (incompatibleBuild) return;
|
|
590
|
+
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
591
|
+
const store = dataModule.default(readRouteSeed('data'), null);
|
|
592
|
+
const routeContent = Content;
|
|
593
|
+
Content = (props, scope) => DataRoot({
|
|
594
|
+
store,
|
|
595
|
+
children: (_props, childScope) => routeContent(props, childScope),
|
|
596
|
+
}, scope);
|
|
597
|
+
}
|
|
598
|
+
`
|
|
599
|
+
: ''
|
|
600
|
+
}
|
|
563
601
|
if (incompatibleBuild) return;
|
|
564
602
|
if (documentLifecycle && !await documentLifecycle.whenActive()) return;
|
|
565
603
|
hydrateRoot(target, Content, undefined,
|
package/src/html.js
CHANGED
package/src/index.js
CHANGED
package/src/resolve-config.js
CHANGED
|
@@ -182,6 +182,34 @@ export function resolveOctaneConfig(raw, options = {}) {
|
|
|
182
182
|
if (raw.compiler?.strong !== undefined && typeof raw.compiler.strong !== 'boolean') {
|
|
183
183
|
throw new Error('[octane] compiler.strong must be a boolean when provided.');
|
|
184
184
|
}
|
|
185
|
+
const strongDependencies = raw.compiler?.strongDependencies;
|
|
186
|
+
if (strongDependencies !== undefined) {
|
|
187
|
+
if (
|
|
188
|
+
!strongDependencies ||
|
|
189
|
+
typeof strongDependencies !== 'object' ||
|
|
190
|
+
Array.isArray(strongDependencies)
|
|
191
|
+
) {
|
|
192
|
+
throw new Error('[octane] compiler.strongDependencies must be an object when provided.');
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
strongDependencies.require !== undefined &&
|
|
196
|
+
typeof strongDependencies.require !== 'boolean'
|
|
197
|
+
) {
|
|
198
|
+
throw new Error('[octane] compiler.strongDependencies.require must be a boolean.');
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
strongDependencies.allow !== undefined &&
|
|
202
|
+
(!Array.isArray(strongDependencies.allow) ||
|
|
203
|
+
strongDependencies.allow.some((name) => typeof name !== 'string'))
|
|
204
|
+
) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
'[octane] compiler.strongDependencies.allow must be an array of package names.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (strongDependencies.require === true && raw.compiler?.strong !== true) {
|
|
210
|
+
throw new Error('[octane] compiler.strongDependencies.require needs compiler.strong: true.');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
185
213
|
|
|
186
214
|
if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
|
|
187
215
|
throw new Error('[octane] router.routes must be an array.');
|
|
@@ -203,6 +231,11 @@ export function resolveOctaneConfig(raw, options = {}) {
|
|
|
203
231
|
|
|
204
232
|
validate_root_boundary(raw.rootBoundary);
|
|
205
233
|
|
|
234
|
+
if (raw.data !== undefined && (typeof raw.data !== 'string' || !raw.data.startsWith('/'))) {
|
|
235
|
+
// Both the server and the browser import it, by its project-root ID.
|
|
236
|
+
throw new Error("[octane] data must be a project-root module ID (e.g. '/src/data.ts').");
|
|
237
|
+
}
|
|
238
|
+
|
|
206
239
|
if (raw.server?.trustProxy !== undefined && typeof raw.server.trustProxy !== 'boolean') {
|
|
207
240
|
throw new Error('[octane] server.trustProxy must be a boolean when provided.');
|
|
208
241
|
}
|
|
@@ -265,13 +298,21 @@ export function resolveOctaneConfig(raw, options = {}) {
|
|
|
265
298
|
adapter: raw.adapter,
|
|
266
299
|
compiler: {
|
|
267
300
|
strong: raw.compiler?.strong ?? false,
|
|
268
|
-
|
|
301
|
+
strongDependencies: {
|
|
302
|
+
require: strongDependencies?.require ?? false,
|
|
303
|
+
allow: [...(strongDependencies?.allow ?? [])],
|
|
304
|
+
},
|
|
305
|
+
// App config declares no Valdi target, so its normalized form has none.
|
|
306
|
+
renderers: /** @type {ResolvedOctaneConfig['compiler']['renderers']} */ (
|
|
307
|
+
normalizeRendererConfig(raw.compiler?.renderers)
|
|
308
|
+
),
|
|
269
309
|
},
|
|
270
310
|
router: {
|
|
271
311
|
routes: raw.router?.routes ?? [],
|
|
272
312
|
preHydrate: raw.router?.preHydrate,
|
|
273
313
|
},
|
|
274
314
|
rootBoundary: raw.rootBoundary ?? {},
|
|
315
|
+
data: raw.data,
|
|
275
316
|
middlewares: raw.middlewares ?? [],
|
|
276
317
|
platform: {
|
|
277
318
|
env: raw.platform?.env ?? {},
|
package/src/seeds.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Route seeds: text a server render hands to the browser, such as a data
|
|
4
|
+
* store's loaded values, so the page hydrates from them instead of loading
|
|
5
|
+
* them again.
|
|
6
|
+
*
|
|
7
|
+
* A seed is produced once the render has finished streaming, because the
|
|
8
|
+
* values it carries load while the page renders. Each one is written as a
|
|
9
|
+
* JSON script just before `</body>`, after the page's content, so a client
|
|
10
|
+
* reads it once the document has been parsed. This module has no server
|
|
11
|
+
* imports: the browser reads seeds through it too.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SEED_ID = '__octane_seed:';
|
|
15
|
+
const SEED_NAME = /^[\w.-]+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{
|
|
19
|
+
* seeds: Map<string, () => unknown>,
|
|
20
|
+
* releases: Array<() => void>,
|
|
21
|
+
* ended: boolean,
|
|
22
|
+
* stream: ReadableStream<Uint8Array> | null,
|
|
23
|
+
* }} RouteSeeds
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {WeakMap<object, RouteSeeds>} */
|
|
27
|
+
const routes = new WeakMap();
|
|
28
|
+
|
|
29
|
+
/** @param {object} context */
|
|
30
|
+
function routeOf(context) {
|
|
31
|
+
let route = routes.get(context);
|
|
32
|
+
if (!route) {
|
|
33
|
+
routes.set(context, (route = { seeds: new Map(), releases: [], ended: false, stream: null }));
|
|
34
|
+
}
|
|
35
|
+
return route;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Hand the browser `produce()`'s text under `name`, read there with
|
|
40
|
+
* `readRouteSeed(name)`. `produce` runs once the page has finished rendering,
|
|
41
|
+
* and not at all when the render fails.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('@octanejs/app-core').Context} context
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @param {() => string | Promise<string>} produce
|
|
46
|
+
* @returns {void}
|
|
47
|
+
*/
|
|
48
|
+
export function routeSeed(context, name, produce) {
|
|
49
|
+
if (!SEED_NAME.test(name)) {
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
`[octane] A route seed's name may only use letters, digits, "_", "." and "-": ${JSON.stringify(name)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
routeOf(context).seeds.set(name, produce);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The text the server render seeded under `name`, or `null` when there is
|
|
59
|
+
* none, such as on a page rendered only in the browser. Read it once the
|
|
60
|
+
* document has been parsed.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} name
|
|
63
|
+
* @returns {string | null}
|
|
64
|
+
*/
|
|
65
|
+
export function readRouteSeed(name) {
|
|
66
|
+
const script = typeof document === 'undefined' ? null : document.getElementById(SEED_ID + name);
|
|
67
|
+
return script === null ? null : JSON.parse(script.textContent ?? 'null');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @internal Run `release` once the route's response has ended, however it
|
|
72
|
+
* ends: completed, cancelled, or failed before it began.
|
|
73
|
+
* @param {object} context
|
|
74
|
+
* @param {() => void} release
|
|
75
|
+
*/
|
|
76
|
+
export function onRouteEnd(context, release) {
|
|
77
|
+
const route = routeOf(context);
|
|
78
|
+
if (route.ended) release();
|
|
79
|
+
else route.releases.push(release);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @internal The route's seed scripts, produced now that its render has
|
|
84
|
+
* finished. A seed that fails to produce is left out, so the browser loads
|
|
85
|
+
* what it would have carried.
|
|
86
|
+
* @param {object} context
|
|
87
|
+
* @param {string} nonceAttribute ` nonce="…"`, or empty
|
|
88
|
+
* @returns {Promise<string>}
|
|
89
|
+
*/
|
|
90
|
+
export async function routeSeedScripts(context, nonceAttribute) {
|
|
91
|
+
const route = routes.get(context);
|
|
92
|
+
if (!route || route.seeds.size === 0) return '';
|
|
93
|
+
let html = '';
|
|
94
|
+
for (const [name, produce] of route.seeds) {
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = String(await produce());
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(`[octane] Route seed ${JSON.stringify(name)} failed:`, error);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// A JSON string, escaped so no part of it can close the script element.
|
|
103
|
+
const json = JSON.stringify(text).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
104
|
+
html += `<script id="${SEED_ID}${name}" type="application/json"${nonceAttribute}>${json}</script>`;
|
|
105
|
+
}
|
|
106
|
+
return html;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @internal End a route whose request failed. A response already streaming is
|
|
111
|
+
* cancelled, which stops its render and ends the route; otherwise the route
|
|
112
|
+
* ends now.
|
|
113
|
+
* @param {object} context
|
|
114
|
+
*/
|
|
115
|
+
export function failRoute(context) {
|
|
116
|
+
const stream = routes.get(context)?.stream;
|
|
117
|
+
if (stream && !stream.locked) {
|
|
118
|
+
void stream.cancel().catch(() => {});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
endRoute(context);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @internal End the route: run each release once.
|
|
126
|
+
* @param {object} context
|
|
127
|
+
*/
|
|
128
|
+
export function endRoute(context) {
|
|
129
|
+
const route = routes.get(context);
|
|
130
|
+
if (!route || route.ended) return;
|
|
131
|
+
route.ended = true;
|
|
132
|
+
for (const release of route.releases.splice(0)) {
|
|
133
|
+
try {
|
|
134
|
+
release();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('[octane] Releasing a route failed:', error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @internal Put `html` just before the document's last `</body>`, or at its
|
|
143
|
+
* end when it has none.
|
|
144
|
+
* @param {string} document
|
|
145
|
+
* @param {string} html
|
|
146
|
+
*/
|
|
147
|
+
export function beforeBodyEnd(document, html) {
|
|
148
|
+
if (html === '') return document;
|
|
149
|
+
const at = document.toLowerCase().lastIndexOf('</body');
|
|
150
|
+
return at === -1 ? document + html : document.slice(0, at) + html + document.slice(at);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @internal The `composeHtmlStream` ending for a route: its seeds go before
|
|
155
|
+
* `</body>`, and the route ends with the stream.
|
|
156
|
+
* @param {object} context
|
|
157
|
+
* @param {string} nonceAttribute
|
|
158
|
+
*/
|
|
159
|
+
export function routeEnding(context, nonceAttribute) {
|
|
160
|
+
return {
|
|
161
|
+
hold: (/** @type {ReadableStream<Uint8Array>} */ stream) => {
|
|
162
|
+
routeOf(context).stream = stream;
|
|
163
|
+
},
|
|
164
|
+
tail: async (/** @type {string} */ suffix) =>
|
|
165
|
+
beforeBodyEnd(suffix, await routeSeedScripts(context, nonceAttribute)),
|
|
166
|
+
end: () => endRoute(context),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The config's `data` module on the server: open one store per request, render
|
|
4
|
+
* the route inside it, seed its loaded values for the browser once the render
|
|
5
|
+
* has finished, and retire it when the response ends.
|
|
6
|
+
*/
|
|
7
|
+
import { onRouteEnd, routeSeed } from '../seeds.js';
|
|
8
|
+
|
|
9
|
+
/** The seed the browser's store starts from. */
|
|
10
|
+
export const DATA_SEED = 'data';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('@octanejs/app-core').Context} context
|
|
14
|
+
* @param {import('@octanejs/app-core/production').RouteDataEntry | null | undefined} data
|
|
15
|
+
* @param {(props?: any, scope?: any, extra?: any) => any} Root
|
|
16
|
+
*/
|
|
17
|
+
export function provideRouteData(context, data, Root) {
|
|
18
|
+
if (!data) return Root;
|
|
19
|
+
const store = data.open(null, context);
|
|
20
|
+
onRouteEnd(context, () => void store.retire());
|
|
21
|
+
routeSeed(context, DATA_SEED, () => store.dehydrate());
|
|
22
|
+
const { Root: DataRoot } = data;
|
|
23
|
+
return function RouteData(/** @type {any} */ props, /** @type {any} */ scope) {
|
|
24
|
+
// As a layout's `children`: a body closing over the route's props.
|
|
25
|
+
const children = (/** @type {any} */ _props, /** @type {any} */ childScope) =>
|
|
26
|
+
Root(props, childScope, undefined);
|
|
27
|
+
return DataRoot({ store, children }, scope, undefined);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* @param {ReadableStream<Uint8Array>} renderStream
|
|
11
11
|
* @param {string} suffix
|
|
12
12
|
* @param {string} [afterShell] Owned bootstrap after the renderer's first complete shell chunk.
|
|
13
|
+
* @param {{ tail(suffix: string): Promise<string>, end(): void, hold?(stream: ReadableStream<Uint8Array>): void }} [ending]
|
|
14
|
+
* Computes the suffix once the render has finished, runs `end` however the stream
|
|
15
|
+
* ends, and is handed the stream, so a failed request can cancel it.
|
|
13
16
|
* @returns {ReadableStream<Uint8Array>}
|
|
14
17
|
*/
|
|
15
|
-
export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '') {
|
|
18
|
+
export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '', ending) {
|
|
16
19
|
const encoder = new TextEncoder();
|
|
17
20
|
const reader = renderStream.getReader();
|
|
18
21
|
let phase = 0; // 0 prefix, 1 renderer, 2 suffix, 3 closed
|
|
@@ -25,7 +28,7 @@ export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '')
|
|
|
25
28
|
reader.releaseLock();
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
const stream = new ReadableStream(
|
|
29
32
|
{
|
|
30
33
|
async pull(controller) {
|
|
31
34
|
try {
|
|
@@ -56,8 +59,10 @@ export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '')
|
|
|
56
59
|
|
|
57
60
|
if (phase === 2) {
|
|
58
61
|
phase = 3;
|
|
59
|
-
|
|
62
|
+
const tail = ending ? await ending.tail(suffix) : suffix;
|
|
63
|
+
if (tail !== '') controller.enqueue(encoder.encode(tail));
|
|
60
64
|
controller.close();
|
|
65
|
+
ending?.end();
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
68
|
return;
|
|
@@ -66,6 +71,7 @@ export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '')
|
|
|
66
71
|
release();
|
|
67
72
|
phase = 3;
|
|
68
73
|
controller.error(error);
|
|
74
|
+
ending?.end();
|
|
69
75
|
}
|
|
70
76
|
},
|
|
71
77
|
async cancel(reason) {
|
|
@@ -74,9 +80,12 @@ export function composeHtmlStream(prefix, renderStream, suffix, afterShell = '')
|
|
|
74
80
|
await reader.cancel(reason);
|
|
75
81
|
} finally {
|
|
76
82
|
release();
|
|
83
|
+
ending?.end();
|
|
77
84
|
}
|
|
78
85
|
},
|
|
79
86
|
},
|
|
80
87
|
{ highWaterMark: 0 },
|
|
81
88
|
);
|
|
89
|
+
ending?.hold?.(stream);
|
|
90
|
+
return stream;
|
|
82
91
|
}
|
package/src/server/production.js
CHANGED
|
@@ -28,10 +28,12 @@ import { handleRpcRequest } from './rpc.js';
|
|
|
28
28
|
import { setRequestContextSource } from './request-context.js';
|
|
29
29
|
import { createServerCallHost } from './server-calls.js';
|
|
30
30
|
import { runServerRequest } from './signal-owners.js';
|
|
31
|
-
import {
|
|
31
|
+
import { buildRpcDescriptors } from './rpc-registry.js';
|
|
32
32
|
import { handleServerRoute } from './server-route.js';
|
|
33
33
|
import { composeHtmlStream } from './html-stream.js';
|
|
34
34
|
import { serializeRouteData } from './route-data.js';
|
|
35
|
+
import { provideRouteData } from './data-route.js';
|
|
36
|
+
import { beforeBodyEnd, endRoute, failRoute, routeEnding, routeSeedScripts } from '../seeds.js';
|
|
35
37
|
import {
|
|
36
38
|
applyHydrationNonce,
|
|
37
39
|
getContextNonce,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
import { patch_global_fetch, build_rpc_lookup, is_rpc_request } from '@ripple-ts/adapter/rpc';
|
|
60
62
|
|
|
61
63
|
export { resolveOctaneConfig } from '../resolve-config.js';
|
|
64
|
+
export { provideRouteData } from './data-route.js';
|
|
62
65
|
|
|
63
66
|
const HEAD_MARKER = '<!--ssr-head-->';
|
|
64
67
|
const BODY_MARKER = '<!--ssr-body-->';
|
|
@@ -106,34 +109,6 @@ function getFetchCoordinator(runtime) {
|
|
|
106
109
|
return coordinator;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
/**
|
|
110
|
-
* Name every server function id, built once per handler.
|
|
111
|
-
*
|
|
112
|
-
* `build_rpc_lookup` keeps only the namespace object and export name, but a
|
|
113
|
-
* middleware policy needs the declaring module before the function is resolved.
|
|
114
|
-
*
|
|
115
|
-
* @param {Record<string, Record<string, Function>>} rpcModules
|
|
116
|
-
* @param {(value: string) => string} hashFn
|
|
117
|
-
* @returns {Map<string, { module: string, export: string }>}
|
|
118
|
-
*/
|
|
119
|
-
function buildRpcDescriptors(rpcModules, hashFn) {
|
|
120
|
-
/** @type {Map<string, { module: string, export: string }>} */
|
|
121
|
-
const descriptors = new Map();
|
|
122
|
-
for (const [entryPath, serverObj] of Object.entries(rpcModules)) {
|
|
123
|
-
for (const funcName of Object.keys(serverObj)) {
|
|
124
|
-
const id = hashFn(entryPath + '#' + funcName);
|
|
125
|
-
// Each manifest entry is listed once, so an id already taken here is a
|
|
126
|
-
// genuine collision. `build_rpc_lookup` would resolve it by overwriting.
|
|
127
|
-
const existing = descriptors.get(id);
|
|
128
|
-
if (existing !== undefined) {
|
|
129
|
-
throw rpcIdCollision(id, existing, { module: entryPath, export: funcName });
|
|
130
|
-
}
|
|
131
|
-
descriptors.set(id, { module: entryPath, export: funcName });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return descriptors;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
112
|
/**
|
|
138
113
|
* Split the immutable, validated production template around both insertion
|
|
139
114
|
* markers once. Dynamic head content is then concatenated into the prepared
|
|
@@ -465,6 +440,7 @@ export function createHandler(manifest, deps) {
|
|
|
465
440
|
}
|
|
466
441
|
return await handleServerRoute(match.route, context, globalMiddlewares);
|
|
467
442
|
} catch (error) {
|
|
443
|
+
failRoute(context);
|
|
468
444
|
console.error('[octane] Request error:', error);
|
|
469
445
|
return new Response('Internal Server Error', { status: 500 });
|
|
470
446
|
}
|
|
@@ -537,6 +513,7 @@ export function createHandler(manifest, deps) {
|
|
|
537
513
|
},
|
|
538
514
|
deps,
|
|
539
515
|
);
|
|
516
|
+
RootComponent = provideRouteData(context, manifest.data, RootComponent);
|
|
540
517
|
|
|
541
518
|
// Share payload ordering and script escaping with the dev handler.
|
|
542
519
|
const streamedSignals =
|
|
@@ -554,6 +531,7 @@ export function createHandler(manifest, deps) {
|
|
|
554
531
|
params: context.params,
|
|
555
532
|
url: requestUrl,
|
|
556
533
|
preHydrate: manifest.preHydrate ?? null,
|
|
534
|
+
data: manifest.data?.path ?? null,
|
|
557
535
|
rootBoundary: manifest.rootBoundaryEntries ?? { pending: null, catch: null },
|
|
558
536
|
clientBuild,
|
|
559
537
|
streamedSignals,
|
|
@@ -617,7 +595,12 @@ export function createHandler(manifest, deps) {
|
|
|
617
595
|
},
|
|
618
596
|
});
|
|
619
597
|
const [prefix, suffix] = splitAroundBody(head ?? '');
|
|
620
|
-
|
|
598
|
+
const seeds = await routeSeedScripts(context, nonceAttribute(nonce));
|
|
599
|
+
endRoute(context);
|
|
600
|
+
return new Response(prefix + css + body + beforeBodyEnd(suffix, seeds), {
|
|
601
|
+
status,
|
|
602
|
+
headers,
|
|
603
|
+
});
|
|
621
604
|
}
|
|
622
605
|
|
|
623
606
|
// Streaming (default): shell flushes at first await, suspense segments
|
|
@@ -645,7 +628,13 @@ export function createHandler(manifest, deps) {
|
|
|
645
628
|
});
|
|
646
629
|
|
|
647
630
|
const [prefix, suffix, afterShell] = splitAroundBody(hoistedHead, earlyHydration);
|
|
648
|
-
const body = composeHtmlStream(
|
|
631
|
+
const body = composeHtmlStream(
|
|
632
|
+
prefix,
|
|
633
|
+
renderStream,
|
|
634
|
+
suffix,
|
|
635
|
+
afterShell,
|
|
636
|
+
routeEnding(context, nonceAttribute(nonce)),
|
|
637
|
+
);
|
|
649
638
|
|
|
650
639
|
return new Response(body, { status, headers });
|
|
651
640
|
}
|
package/src/server/route-data.js
CHANGED
|
@@ -16,6 +16,7 @@ export function serializeRouteData(data) {
|
|
|
16
16
|
params: data.params,
|
|
17
17
|
url: data.url,
|
|
18
18
|
preHydrate: data.preHydrate ?? null,
|
|
19
|
+
data: data.data ?? null,
|
|
19
20
|
rootBoundary: data.rootBoundary ?? { pending: null, catch: null },
|
|
20
21
|
clientBuild: data.clientBuild,
|
|
21
22
|
streamedSignals: data.streamedSignals,
|
|
@@ -60,3 +60,31 @@ class RpcRegistry extends Map {
|
|
|
60
60
|
export function createRpcRegistry() {
|
|
61
61
|
return new RpcRegistry();
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Name every server function id, built once per handler or server.
|
|
66
|
+
*
|
|
67
|
+
* `build_rpc_lookup` keeps only the namespace object and export name, but a
|
|
68
|
+
* middleware policy needs the declaring module before the function is resolved.
|
|
69
|
+
*
|
|
70
|
+
* @param {Record<string, Record<string, Function>>} rpcModules
|
|
71
|
+
* @param {(value: string) => string} hashFn
|
|
72
|
+
* @returns {Map<string, { module: string, export: string }>}
|
|
73
|
+
*/
|
|
74
|
+
export function buildRpcDescriptors(rpcModules, hashFn) {
|
|
75
|
+
/** @type {Map<string, { module: string, export: string }>} */
|
|
76
|
+
const descriptors = new Map();
|
|
77
|
+
for (const [entryPath, serverObj] of Object.entries(rpcModules)) {
|
|
78
|
+
for (const funcName of Object.keys(serverObj)) {
|
|
79
|
+
const id = hashFn(entryPath + '#' + funcName);
|
|
80
|
+
// Each manifest entry is listed once, so an id already taken here is a
|
|
81
|
+
// genuine collision. `build_rpc_lookup` would resolve it by overwriting.
|
|
82
|
+
const existing = descriptors.get(id);
|
|
83
|
+
if (existing !== undefined) {
|
|
84
|
+
throw rpcIdCollision(id, existing, { module: entryPath, export: funcName });
|
|
85
|
+
}
|
|
86
|
+
descriptors.set(id, { module: entryPath, export: funcName });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return descriptors;
|
|
90
|
+
}
|
|
@@ -38,6 +38,8 @@ import { get_route_entry_export_name, get_route_entry_path } from '../routes.js'
|
|
|
38
38
|
* @property {Route[]} routes - Route definitions from octane.config.ts
|
|
39
39
|
* @property {string} octaneConfigPath - Absolute path to octane.config.ts
|
|
40
40
|
* @property {RootBoundaryOptions} [rootBoundary] - Importable app-wide boundary entries
|
|
41
|
+
* @property {string} [data] - The config's `data` module: project-root ID of its store opener
|
|
42
|
+
* @property {string} [dataRootModuleId] - App-core's `DataRoot` module ID
|
|
41
43
|
* @property {string[]} [rpcModulePaths] - Project-root module IDs containing `module server`
|
|
42
44
|
* @property {Record<string, ClientAssetEntry>} [clientAssetMap] - Route entry path → built client asset paths
|
|
43
45
|
* @property {string} [clientAssetMapFile] - JSON asset map, resolved beside the built server entry at runtime
|
|
@@ -67,6 +69,8 @@ export function generateServerEntry(options) {
|
|
|
67
69
|
routes,
|
|
68
70
|
octaneConfigPath,
|
|
69
71
|
rootBoundary = {},
|
|
72
|
+
data,
|
|
73
|
+
dataRootModuleId = '@octanejs/app-core/data-root',
|
|
70
74
|
rpcModulePaths = [],
|
|
71
75
|
clientAssetMap = {},
|
|
72
76
|
clientAssetMapFile,
|
|
@@ -150,6 +154,15 @@ export function generateServerEntry(options) {
|
|
|
150
154
|
);
|
|
151
155
|
}
|
|
152
156
|
}
|
|
157
|
+
if (data) {
|
|
158
|
+
import_lines.push(
|
|
159
|
+
`import * as _data from ${JSON.stringify(import_specifier(data))};`,
|
|
160
|
+
`import { DataRoot } from ${JSON.stringify(dataRootModuleId)};`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const data_entry = data
|
|
164
|
+
? `{ path: ${JSON.stringify(data)}, open: _data.default, Root: DataRoot }`
|
|
165
|
+
: 'null';
|
|
153
166
|
|
|
154
167
|
// The manifest maps MODULE PATHS to module namespaces; createHandler picks
|
|
155
168
|
// the export per-route with the same `get_component_export` dev uses.
|
|
@@ -325,6 +338,7 @@ export const manifest = {
|
|
|
325
338
|
rootBoundary,
|
|
326
339
|
rootBoundaryEntries,
|
|
327
340
|
preHydrate: octaneConfig.router.preHydrate ?? null,
|
|
341
|
+
data: ${data_entry},
|
|
328
342
|
rpcModules,
|
|
329
343
|
runtime,
|
|
330
344
|
clientAssets,
|
|
@@ -448,6 +462,7 @@ export const handler = createHandler(
|
|
|
448
462
|
rootBoundary,
|
|
449
463
|
rootBoundaryEntries,
|
|
450
464
|
preHydrate: octaneConfig.router.preHydrate ?? null,
|
|
465
|
+
data: ${data_entry},
|
|
451
466
|
rpcModules,
|
|
452
467
|
runtime,
|
|
453
468
|
clientAssets,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* `module server` functions for a Node server an Octane integration does not
|
|
4
|
+
* own: one with its own routing, or pages another renderer draws. It applies
|
|
5
|
+
* the same boundary as `@octanejs/vite-plugin` and `@octanejs/rsbuild-plugin`,
|
|
6
|
+
* so it works under any bundler that compiles `.tsrx` for the server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { build_rpc_lookup, is_rpc_request } from '@ripple-ts/adapter/rpc';
|
|
12
|
+
import {
|
|
13
|
+
executeServerFunction,
|
|
14
|
+
executeServerFunctionBatch,
|
|
15
|
+
executeServerFunctionStream,
|
|
16
|
+
} from 'octane/server';
|
|
17
|
+
import { createContext, runMiddlewareChain } from './middleware.js';
|
|
18
|
+
import { buildRpcDescriptors } from './rpc-registry.js';
|
|
19
|
+
import { handleRpcRequest } from './rpc.js';
|
|
20
|
+
import { setRequestContextSource } from './request-context.js';
|
|
21
|
+
import { createServerCallHost } from './server-calls.js';
|
|
22
|
+
import { runServerRequest } from './signal-owners.js';
|
|
23
|
+
|
|
24
|
+
/** The compiler's function id: SHA-256 hex of `"<module>#<export>"`, 8 characters. */
|
|
25
|
+
const hash = (/** @type {string} */ value) =>
|
|
26
|
+
createHash('sha256').update(value).digest('hex').slice(0, 8);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('@octanejs/app-core/server-functions').ServerFunctionsOptions} options
|
|
30
|
+
* @returns {import('@octanejs/app-core/server-functions').ServerFunctions}
|
|
31
|
+
*/
|
|
32
|
+
export function createServerFunctions(options) {
|
|
33
|
+
/** @type {Record<string, Record<string, Function>>} */
|
|
34
|
+
const namespaces = {};
|
|
35
|
+
for (const [id, module] of Object.entries(options.modules)) {
|
|
36
|
+
const server = /** @type {Record<string, Function> | undefined} */ (module._$_server_$_);
|
|
37
|
+
if (server === undefined) {
|
|
38
|
+
throw new TypeError(`[octane] ${id} has no \`module server\` block in this build`);
|
|
39
|
+
}
|
|
40
|
+
namespaces[id] = server;
|
|
41
|
+
}
|
|
42
|
+
const lookup = build_rpc_lookup(namespaces, hash);
|
|
43
|
+
const descriptors = buildRpcDescriptors(namespaces, hash);
|
|
44
|
+
const middlewares = options.middlewares ?? [];
|
|
45
|
+
const storage = new AsyncLocalStorage();
|
|
46
|
+
/** @type {import('@octanejs/app-core').RpcRequestOptions['asyncContext']} */
|
|
47
|
+
const asyncContext = {
|
|
48
|
+
run: (store, fn) => storage.run(store, fn),
|
|
49
|
+
getStore: () => storage.getStore(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
handle(request, platform) {
|
|
54
|
+
if (!is_rpc_request(new URL(request.url).pathname)) return null;
|
|
55
|
+
return handleRpcRequest(request, {
|
|
56
|
+
resolveFunction(id) {
|
|
57
|
+
const entry = lookup.get(id);
|
|
58
|
+
const fn = entry?.serverObj[entry.funcName];
|
|
59
|
+
return typeof fn === 'function' ? fn : null;
|
|
60
|
+
},
|
|
61
|
+
describeFunction: (id) => descriptors.get(id) ?? null,
|
|
62
|
+
executeServerFunction,
|
|
63
|
+
streamServerFunction: executeServerFunctionStream,
|
|
64
|
+
batchServerFunctions: executeServerFunctionBatch,
|
|
65
|
+
asyncContext,
|
|
66
|
+
middlewares,
|
|
67
|
+
trustProxy: options.trustProxy,
|
|
68
|
+
allowedOrigins: options.allowedOrigins,
|
|
69
|
+
maxBodyBytes: options.maxBodyBytes,
|
|
70
|
+
resultLimits: options.resultLimits,
|
|
71
|
+
platform,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
respond(request, respond, platform) {
|
|
75
|
+
const context = createContext(request, {}, platform);
|
|
76
|
+
const origin = context.url.origin;
|
|
77
|
+
setRequestContextSource(asyncContext);
|
|
78
|
+
return runServerRequest(
|
|
79
|
+
asyncContext,
|
|
80
|
+
{
|
|
81
|
+
origin,
|
|
82
|
+
platform,
|
|
83
|
+
context,
|
|
84
|
+
serverCallHost: createServerCallHost(context, { asyncContext, middlewares, origin }),
|
|
85
|
+
},
|
|
86
|
+
undefined,
|
|
87
|
+
() => runMiddlewareChain(context, middlewares, [], async () => respond(context)),
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route's data store around its content. App-core renders it as each
|
|
3
|
+
* route's outermost component when the config names a `data` module.
|
|
4
|
+
*
|
|
5
|
+
* A codegen template, not a module app code imports: the generated server and
|
|
6
|
+
* client entries import it, so each app compiles it with its own toolchain and
|
|
7
|
+
* its server markup and browser adoption line up as a layout's do.
|
|
8
|
+
*/
|
|
9
|
+
import type { OctaneNode } from 'octane';
|
|
10
|
+
import type { DataStore } from 'octane/data';
|
|
11
|
+
import { DataProvider } from 'octane/data/octane';
|
|
12
|
+
|
|
13
|
+
export function DataRoot(props: { store: DataStore; children?: OctaneNode }) @{
|
|
14
|
+
<DataProvider store={props.store}>{props.children}</DataProvider>
|
|
15
|
+
}
|
package/types/codegen.d.ts
CHANGED
|
@@ -45,6 +45,15 @@ export interface ClientEntryOptions {
|
|
|
45
45
|
resolveImport?: (id: string) => string;
|
|
46
46
|
runtimeModuleId?: string;
|
|
47
47
|
generatedBy?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The config names a `data` module: open the page's store from the server's
|
|
50
|
+
* seed and render the route inside `DataRoot`. Off by default.
|
|
51
|
+
*/
|
|
52
|
+
data?: boolean;
|
|
53
|
+
/** App-core's `DataRoot` module, compiled for the client. */
|
|
54
|
+
dataRootModuleId?: string;
|
|
55
|
+
/** App-core's browser-safe seeds module. */
|
|
56
|
+
seedsModuleId?: string;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export function create_client_entry_source(options?: ClientEntryOptions): string;
|
|
@@ -55,6 +64,10 @@ export interface ServerEntryOptions {
|
|
|
55
64
|
routes: Route[];
|
|
56
65
|
octaneConfigPath: string;
|
|
57
66
|
rootBoundary?: RootBoundaryOptions;
|
|
67
|
+
/** The config's `data` module: project-root ID of its store opener. */
|
|
68
|
+
data?: string;
|
|
69
|
+
/** App-core's `DataRoot` module, compiled for the server. */
|
|
70
|
+
dataRootModuleId?: string;
|
|
58
71
|
rpcModulePaths?: string[];
|
|
59
72
|
clientAssetMap?: Record<string, ClientAssetEntry>;
|
|
60
73
|
/** JSON file resolved beside the built server entry at module evaluation. */
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OctaneNode } from 'octane';
|
|
2
|
+
import type { DataStore } from 'octane/data';
|
|
3
|
+
|
|
4
|
+
/** The route's data store around its content; app-core renders it for the config's `data` module. */
|
|
5
|
+
export function DataRoot(props: { store: DataStore; children?: OctaneNode }): OctaneNode;
|
package/types/html.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface RouteHydrationData {
|
|
|
10
10
|
params: Record<string, string>;
|
|
11
11
|
url: string;
|
|
12
12
|
preHydrate?: string | null;
|
|
13
|
+
/** The config's `data` module, which the client entry opens the page's store with. */
|
|
14
|
+
data?: string | null;
|
|
13
15
|
rootBoundary?: ServerManifest['rootBoundaryEntries'];
|
|
14
16
|
clientBuild?: ClientBuildManifest;
|
|
15
17
|
streamedSignals?: { buildId: string; documentId: string };
|
|
@@ -23,7 +25,19 @@ export function composeHtmlStream(
|
|
|
23
25
|
renderStream: ReadableStream<Uint8Array>,
|
|
24
26
|
suffix: string,
|
|
25
27
|
afterShell?: string,
|
|
28
|
+
ending?: RouteEnding,
|
|
26
29
|
): ReadableStream<Uint8Array>;
|
|
30
|
+
/** Computes a stream's suffix once its render has finished, and runs `end` however it ends. */
|
|
31
|
+
export interface RouteEnding {
|
|
32
|
+
tail(suffix: string): Promise<string>;
|
|
33
|
+
end(): void;
|
|
34
|
+
/** Handed the composed stream, so a failed request can cancel it. */
|
|
35
|
+
hold?(stream: ReadableStream<Uint8Array>): void;
|
|
36
|
+
}
|
|
37
|
+
/** A route's seeds go before `</body>`, and the route ends with the stream. */
|
|
38
|
+
export function routeEnding(context: object, nonceAttribute: string): RouteEnding;
|
|
39
|
+
/** End a route whose response failed before streaming: release what it opened. */
|
|
40
|
+
export function endRoute(context: object): void;
|
|
27
41
|
export function validateSsrTemplate(html: string): void;
|
|
28
42
|
export function injectHydrationEntry(html: string, source: string, nonce?: string | null): string;
|
|
29
43
|
export function splitSsrTemplate(html: string): [prefix: string, suffix: string];
|
package/types/index.d.ts
CHANGED
|
@@ -457,6 +457,15 @@ export interface OctaneConfigOptions {
|
|
|
457
457
|
compiler?: {
|
|
458
458
|
/** Assert pure immutable-snapshot renders and reject detectable violations. @default false */
|
|
459
459
|
strong?: boolean;
|
|
460
|
+
/**
|
|
461
|
+
* What Strong application modules may import from other Octane packages.
|
|
462
|
+
* With `require: true` (which needs `strong: true`), an import that reaches
|
|
463
|
+
* a module in another package compiling without Strong mode, or an
|
|
464
|
+
* `octane/react` boundary, fails the build unless `allow` names its package
|
|
465
|
+
* (or `'octane/react'`). Packages declare Strong with `"octane": { "strong": true }`
|
|
466
|
+
* or a list of paths in their package.json. @default { require: false, allow: [] }
|
|
467
|
+
*/
|
|
468
|
+
strongDependencies?: { require?: boolean; allow?: readonly string[] };
|
|
460
469
|
renderers?: ExperimentalRendererConfigOptions;
|
|
461
470
|
};
|
|
462
471
|
router?: {
|
|
@@ -474,6 +483,13 @@ export interface OctaneConfigOptions {
|
|
|
474
483
|
* selects a named export.
|
|
475
484
|
*/
|
|
476
485
|
rootBoundary?: RootBoundaryOptions;
|
|
486
|
+
/**
|
|
487
|
+
* Project-root module ID (e.g. '/src/data.ts') whose default export is a
|
|
488
|
+
* {@link RouteDataOpener}. Every render route then runs inside the store it
|
|
489
|
+
* opens: one per request on the server, seeded into the page once it has
|
|
490
|
+
* rendered, and one in the browser, started from that seed.
|
|
491
|
+
*/
|
|
492
|
+
data?: string;
|
|
477
493
|
/** Global middlewares applied to all routes */
|
|
478
494
|
middlewares?: Middleware[];
|
|
479
495
|
platform?: {
|
|
@@ -523,6 +539,7 @@ export interface ResolvedOctaneConfig {
|
|
|
523
539
|
compiler: {
|
|
524
540
|
/** @default false */
|
|
525
541
|
strong: boolean;
|
|
542
|
+
strongDependencies: { require: boolean; allow: string[] };
|
|
526
543
|
renderers: ExperimentalResolvedRendererConfig;
|
|
527
544
|
};
|
|
528
545
|
router: {
|
|
@@ -530,6 +547,7 @@ export interface ResolvedOctaneConfig {
|
|
|
530
547
|
preHydrate?: string;
|
|
531
548
|
};
|
|
532
549
|
rootBoundary: RootBoundaryOptions;
|
|
550
|
+
data?: string;
|
|
533
551
|
/** @default [] */
|
|
534
552
|
middlewares: Middleware[];
|
|
535
553
|
platform: {
|
|
@@ -619,3 +637,32 @@ export interface LoadedOctaneConfig {
|
|
|
619
637
|
dependencies: string[];
|
|
620
638
|
missingDependencies: string[];
|
|
621
639
|
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The default export of the config's `data` module: open a data store, such as
|
|
643
|
+
* `octane/data`'s, for a route. On the server `seed` is `null` and `context`
|
|
644
|
+
* is the request's, once its middleware has run; in the browser `seed` is the
|
|
645
|
+
* server's `dehydrate()` text, or `null` on a page rendered only there, and
|
|
646
|
+
* `context` is `null`.
|
|
647
|
+
*/
|
|
648
|
+
export type RouteDataOpener = (seed: string | null, context: Context | null) => RouteDataStore;
|
|
649
|
+
|
|
650
|
+
/** What app-core needs of a route's data store. `octane/data`'s `DataStore` is one. */
|
|
651
|
+
export interface RouteDataStore {
|
|
652
|
+
/** Its loaded values, for the browser's store to start from. */
|
|
653
|
+
dehydrate(): string;
|
|
654
|
+
/** End it on the server once the response is complete. */
|
|
655
|
+
retire(): unknown;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Hand the browser `produce()`'s text under `name`, read there with
|
|
660
|
+
* `readRouteSeed(name)` from `@octanejs/app-core/seeds`. `produce` runs once
|
|
661
|
+
* the page has finished rendering, and not at all when the render fails; the
|
|
662
|
+
* text is written as a script just before `</body>`.
|
|
663
|
+
*/
|
|
664
|
+
export function routeSeed(
|
|
665
|
+
context: Context,
|
|
666
|
+
name: string,
|
|
667
|
+
produce: () => string | Promise<string>,
|
|
668
|
+
): void;
|
package/types/production.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface ServerManifest {
|
|
|
72
72
|
};
|
|
73
73
|
/** config `router.preHydrate`, serialized into #__octane_data for the client entry */
|
|
74
74
|
preHydrate?: string | null;
|
|
75
|
+
/** The config's `data` module, opened per request, and the root it renders routes inside. */
|
|
76
|
+
data?: RouteDataEntry | null;
|
|
75
77
|
/** Map of entry path → `module server` namespace for RPC support */
|
|
76
78
|
rpcModules?: Record<string, Record<string, Function>>;
|
|
77
79
|
/** Platform primitives (adapter's, or the generated entry's Node defaults) */
|
|
@@ -142,3 +144,18 @@ export function createRootBoundaryWrapper(
|
|
|
142
144
|
boundary: { pending?: Component | null; catch?: Component | null },
|
|
143
145
|
runtime: { Suspense: Component; ErrorBoundary: Component; createElement: Function },
|
|
144
146
|
): Component;
|
|
147
|
+
|
|
148
|
+
export interface RouteDataEntry {
|
|
149
|
+
/** The module's project-root ID, serialized for the client entry. */
|
|
150
|
+
path: string;
|
|
151
|
+
open: import('@octanejs/app-core').RouteDataOpener;
|
|
152
|
+
/** `DataRoot` from `@octanejs/app-core/data-root`, compiled for the server. */
|
|
153
|
+
Root: Component<{ store: import('@octanejs/app-core').RouteDataStore; children?: unknown }>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Render a route inside the config's `data` store, opened for this request and seeded once it has rendered. */
|
|
157
|
+
export function provideRouteData(
|
|
158
|
+
context: import('@octanejs/app-core').Context,
|
|
159
|
+
data: RouteDataEntry | null | undefined,
|
|
160
|
+
Root: Component,
|
|
161
|
+
): Component;
|
package/types/seeds.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text a server render seeded under `name` with `routeSeed`, or `null`
|
|
3
|
+
* when there is none, such as on a page rendered only in the browser. Read it
|
|
4
|
+
* once the document has been parsed.
|
|
5
|
+
*/
|
|
6
|
+
export function readRouteSeed(name: string): string | null;
|
|
7
|
+
export { routeSeed } from '@octanejs/app-core';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Context, Middleware } from '@octanejs/app-core';
|
|
2
|
+
import type { ServerResultLimits } from 'octane/server';
|
|
3
|
+
|
|
4
|
+
export interface ServerFunctionsOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The server build of each module that declares a `module server` block,
|
|
7
|
+
* keyed by the root-relative path the compiler named it by, such as
|
|
8
|
+
* `'/src/data.tsrx'`:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import * as data from './data.tsrx';
|
|
12
|
+
* createServerFunctions({ modules: { '/src/data.tsrx': data } });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
modules: Record<string, Record<string, unknown>>;
|
|
16
|
+
/** Run before every server function call, in process or over RPC, as app config middleware does. */
|
|
17
|
+
middlewares?: Middleware[];
|
|
18
|
+
/** Trust X-Forwarded-Proto/Host when deriving the request origin. */
|
|
19
|
+
trustProxy?: boolean;
|
|
20
|
+
/** Origins other than the request's own that may call server functions. */
|
|
21
|
+
allowedOrigins?: readonly string[];
|
|
22
|
+
/** The largest request body a call may send. */
|
|
23
|
+
maxBodyBytes?: number;
|
|
24
|
+
resultLimits?: ServerResultLimits;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ServerFunctions {
|
|
28
|
+
/**
|
|
29
|
+
* Answer a browser's call to a server function, with the same origin
|
|
30
|
+
* checks, body limit and middleware as an Octane app server. Returns `null`
|
|
31
|
+
* for any request that is not one.
|
|
32
|
+
*/
|
|
33
|
+
handle(request: Request, platform?: unknown): Promise<Response> | null;
|
|
34
|
+
/**
|
|
35
|
+
* Answer any other request through the middleware. A server function that
|
|
36
|
+
* `respond` calls, such as one a page's server render reads, runs in process
|
|
37
|
+
* under the middleware's context.
|
|
38
|
+
*/
|
|
39
|
+
respond(
|
|
40
|
+
request: Request,
|
|
41
|
+
respond: (context: Context) => Response | Promise<Response>,
|
|
42
|
+
platform?: unknown,
|
|
43
|
+
): Promise<Response>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Serve `module server` functions from a Node server an Octane integration does
|
|
48
|
+
* not own, under any bundler that compiles `.tsrx` for the server.
|
|
49
|
+
*/
|
|
50
|
+
export function createServerFunctions(options: ServerFunctionsOptions): ServerFunctions;
|