@octanejs/app-core 0.1.9 → 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 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.1.9",
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.15.0"
98
+ "octane": "^0.17.0"
83
99
  },
84
100
  "devDependencies": {
85
101
  "@types/node": "^24.13.3",
86
- "octane": "0.15.0"
102
+ "octane": "0.17.0"
87
103
  }
88
104
  }
@@ -1,5 +1,11 @@
1
1
  // @ts-check
2
- export { hydrateRoot, initializeHydrationEventCapture, Suspense, ErrorBoundary } from 'octane';
2
+ export {
3
+ hydrateRoot,
4
+ __hydrateVoidRoot,
5
+ initializeHydrationEventCapture,
6
+ Suspense,
7
+ ErrorBoundary,
8
+ } from 'octane';
3
9
  import { createElementFromConfig } from 'octane/internal/client';
4
10
 
5
11
  /**
package/src/codegen.js CHANGED
@@ -147,6 +147,18 @@ let lifecycleBootstrap;
147
147
  let independentBootstrap;
148
148
  let disposeIndependentHydration;
149
149
  let incompatibleBuild = false;
150
+ ${
151
+ options.streamedSignals === false
152
+ ? ''
153
+ : `// Destructured so a bundler keeps only these two exports. The router never
154
+ // registers a streamed HTML region, so it joins results without placement.
155
+ const loadSignalHydration = () =>
156
+ import('octane/hydration/streamed-signals').then(
157
+ ({ bootstrapStreamedSignalResults, installSignalDocumentLifecycle }) =>
158
+ ({ bootstrapStreamedSignalResults, installSignalDocumentLifecycle }),
159
+ );
160
+ `
161
+ }
150
162
  ${
151
163
  options.devClientBuild
152
164
  ? `
@@ -209,7 +221,12 @@ function importModule(path) {
209
221
  return loader ? loader() : dynamicImport(path);
210
222
  }
211
223
 
212
- const independentStyles = new Map();
224
+ ${
225
+ options.independentHydration === false
226
+ ? `// This build compiled no independent islands.
227
+ async function bootstrapIndependentIslands() {}
228
+ `
229
+ : `const independentStyles = new Map();
213
230
 
214
231
  function clientAssetPath(path) {
215
232
  return path.startsWith('/') ? path : '/' + path;
@@ -280,12 +297,18 @@ async function bootstrapIndependentIslands(target) {
280
297
  });
281
298
  return independentBootstrap;
282
299
  }
283
-
284
- async function bootstrapDocumentLifecycle() {
300
+ `
301
+ }
302
+ ${
303
+ options.streamedSignals === false
304
+ ? `// This build compiled no streamed signals.
305
+ async function bootstrapDocumentLifecycle() {}
306
+ `
307
+ : `async function bootstrapDocumentLifecycle() {
285
308
  if (clientBuildId === null || !documentData?.streamedSignals) return;
286
309
  if (lifecycleBootstrap) return lifecycleBootstrap;
287
310
  lifecycleBootstrap = (async () => {
288
- signalHydrationModule ??= await import('octane/hydration/streamed-signals');
311
+ signalHydrationModule ??= await loadSignalHydration();
289
312
  if (incompatibleBuild) return;
290
313
  documentLifecycle = signalHydrationModule.installSignalDocumentLifecycle({
291
314
  document, buildId: clientBuildId, documentId: documentData.streamedSignals.documentId,
@@ -298,7 +321,8 @@ async function bootstrapDocumentLifecycle() {
298
321
  })();
299
322
  return lifecycleBootstrap;
300
323
  }
301
-
324
+ `
325
+ }
302
326
  `;
303
327
  }
304
328
 
@@ -345,6 +369,20 @@ async function bootstrapDocumentLifecycle() {
345
369
  * app-level client router uses to commit its match tree so the first
346
370
  * hydration pass adopts the same resolved tree the server rendered.
347
371
  *
372
+ * A build that knows its whole client graph narrows the entry to it. Each flag
373
+ * defaults to the general entry, and `false` drops that feature's code:
374
+ * - `rootBoundary`: the config names a root pending or catch boundary.
375
+ * - `independentHydration` / `streamedSignals`: some compiled module has an
376
+ * independent island / streamed signal state, so its lazy runtime loads.
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
383
+ * build that proved every page and layout it can render is a void `@{}`
384
+ * body, with no root boundary, may set it (see compiler/void-roots.js).
385
+ *
348
386
  * `getComponentExport` mirrors routes.js `get_component_export` (route named
349
387
  * export > default > first PascalCase) so server and client pick the SAME
350
388
  * component.
@@ -359,16 +397,40 @@ async function bootstrapDocumentLifecycle() {
359
397
  * resolveImport?: (id: string) => string,
360
398
  * runtimeModuleId?: string,
361
399
  * generatedBy?: string,
400
+ * rootBoundary?: boolean,
401
+ * data?: boolean,
402
+ * dataRootModuleId?: string,
403
+ * seedsModuleId?: string,
404
+ * voidRoot?: boolean,
405
+ * independentHydration?: boolean,
406
+ * streamedSignals?: boolean,
362
407
  * }} [options]
363
408
  * @returns {string}
364
409
  */
365
410
  export function create_client_entry_source(options = {}) {
366
411
  const runtimeModuleId =
367
412
  options.runtimeModuleId ?? fileURLToPath(new URL('./client-entry-runtime.js', import.meta.url));
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
+ : '';
424
+ const imports = [
425
+ options.voidRoot && !data ? '__hydrateVoidRoot as hydrateRoot' : 'hydrateRoot',
426
+ 'initializeHydrationEventCapture',
427
+ ...(rootBoundary ? ['Suspense', 'ErrorBoundary', 'createElement'] : []),
428
+ ];
368
429
  return (
369
430
  client_entry_prelude(
370
431
  options,
371
- `import { hydrateRoot, initializeHydrationEventCapture, Suspense, ErrorBoundary, createElement } from ${JSON.stringify(runtimeModuleId)};`,
432
+ `import { ${imports.join(', ')} } from ${JSON.stringify(runtimeModuleId)};` +
433
+ (dataImports === '' ? '' : '\n' + dataImports),
372
434
  ) +
373
435
  `function getComponentExport(module, exportName) {
374
436
  // Explicit export name requires an exact match; do NOT fall back, so a
@@ -377,7 +439,9 @@ export function create_client_entry_source(options = {}) {
377
439
  if (typeof module.default === 'function') return module.default;
378
440
  return Object.entries(module).find(([key, value]) => typeof value === 'function' && /^[A-Z]/.test(key))?.[1];
379
441
  }
380
-
442
+ ${
443
+ rootBoundary
444
+ ? `
381
445
  function withRootBoundary(content, boundary) {
382
446
  let body = content;
383
447
  // Keep ErrorBoundary closest to the route. Suspense may retain its pending
@@ -401,7 +465,9 @@ function withRootBoundary(content, boundary) {
401
465
  }
402
466
  return body;
403
467
  }
404
-
468
+ `
469
+ : ''
470
+ }
405
471
  (async () => {
406
472
  try {
407
473
  const el = document.getElementById('__octane_data');
@@ -428,11 +494,15 @@ function withRootBoundary(content, boundary) {
428
494
  )) throw new Error('[octane] Invalid streamed signal document identity.');
429
495
  if (globalThis.__octaneStreamedSignalSelections !== undefined) {
430
496
  if (!data.streamedSignals) throw new Error('[octane] Missing streamed signal document identity.');
431
- signalHydrationModule = await import('octane/hydration/streamed-signals');
497
+ ${
498
+ options.streamedSignals === false
499
+ ? ` throw new Error('[octane] Streamed signals reached a client build that compiled none.');`
500
+ : ` signalHydrationModule ??= await loadSignalHydration();
432
501
  if (incompatibleBuild) return;
433
- streamedHydration = signalHydrationModule.bootstrapStreamedSignalHydration(data.streamedSignals);
502
+ streamedHydration = signalHydrationModule.bootstrapStreamedSignalResults(data.streamedSignals);
434
503
  signalOwner = streamedHydration.signalOwner;
435
- await bootstrapDocumentLifecycle();
504
+ await bootstrapDocumentLifecycle();`
505
+ }
436
506
  }
437
507
  if (!data.entry) {
438
508
  console.error('[octane] Unable to hydrate: no route entry in #__octane_data.');
@@ -489,7 +559,9 @@ function withRootBoundary(content, boundary) {
489
559
  Content = (_props, scope, extra) => Component({ params, url }, scope, extra);
490
560
  }
491
561
 
492
- const rootBoundary = { pending: null, catch: null };
562
+ ${
563
+ rootBoundary
564
+ ? ` const rootBoundary = { pending: null, catch: null };
493
565
  for (const kind of ['pending', 'catch']) {
494
566
  const entry = data.rootBoundary?.[kind];
495
567
  if (!entry) continue;
@@ -503,10 +575,32 @@ function withRootBoundary(content, boundary) {
503
575
  }
504
576
  rootBoundary[kind] = Boundary;
505
577
  }
506
-
578
+ Content = withRootBoundary(Content, rootBoundary);
579
+ `
580
+ : ''
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
+ }
507
601
  if (incompatibleBuild) return;
508
602
  if (documentLifecycle && !await documentLifecycle.whenActive()) return;
509
- hydrateRoot(target, withRootBoundary(Content, rootBoundary), undefined,
603
+ hydrateRoot(target, Content, undefined,
510
604
  signalOwner === undefined ? undefined : { signalOwner });
511
605
  } catch (error) {
512
606
  console.error('[octane] Failed to bootstrap client hydration.', error);
@@ -564,11 +658,15 @@ export function create_islands_entry_source(options = {}) {
564
658
  )) throw new Error('[octane] Invalid streamed signal document identity.');
565
659
  if (globalThis.__octaneStreamedSignalSelections !== undefined) {
566
660
  if (!data.streamedSignals) throw new Error('[octane] Missing streamed signal document identity.');
567
- signalHydrationModule = await import('octane/hydration/streamed-signals');
661
+ ${
662
+ options.streamedSignals === false
663
+ ? ` throw new Error('[octane] Streamed signals reached a client build that compiled none.');`
664
+ : ` signalHydrationModule ??= await loadSignalHydration();
568
665
  if (incompatibleBuild) return;
569
- streamedHydration = signalHydrationModule.bootstrapStreamedSignalHydration(data.streamedSignals);
666
+ streamedHydration = signalHydrationModule.bootstrapStreamedSignalResults(data.streamedSignals);
570
667
  signalOwner = streamedHydration.signalOwner;
571
- await bootstrapDocumentLifecycle();
668
+ await bootstrapDocumentLifecycle();`
669
+ }
572
670
  }
573
671
 
574
672
  // The shell stays server-owned: only its independent islands activate.
package/src/html.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { composeHtmlStream } from './server/html-stream.js';
2
+ export { endRoute, routeEnding } from './seeds.js';
2
3
  export { serializeRouteData } from './server/route-data.js';
3
4
  export {
4
5
  HYDRATION_NONCE_PLACEHOLDER,
package/src/index.js CHANGED
@@ -25,3 +25,4 @@ export {
25
25
  export { runServerRequest } from './server/signal-owners.js';
26
26
  export { createServerCallHost } from './server/server-calls.js';
27
27
  export { createRpcRegistry } from './server/rpc-registry.js';
28
+ export { routeSeed } from './seeds.js';
@@ -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
- renderers: normalizeRendererConfig(raw.compiler?.renderers),
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
- return new ReadableStream(
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
- if (suffix !== '') controller.enqueue(encoder.encode(suffix));
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
  }
@@ -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 { rpcIdCollision } from './rpc-registry.js';
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
- return new Response(prefix + css + body + suffix, { status, headers });
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(prefix, renderStream, suffix, afterShell);
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
  }
@@ -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
+ }
@@ -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;
@@ -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;
@@ -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;