@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 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.2.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.16.0"
98
+ "octane": "^0.17.0"
83
99
  },
84
100
  "devDependencies": {
85
101
  "@types/node": "^24.13.3",
86
- "octane": "0.16.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
- * - `voidRoot: true` hydrates without returned-value reconciliation. Only a
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
@@ -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;