@octanejs/app-core 0.0.2 → 0.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.2",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -79,10 +79,10 @@
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "0.1.6"
82
+ "octane": "0.1.10"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.3.0",
86
- "octane": "0.1.6"
86
+ "octane": "0.1.10"
87
87
  }
88
88
  }
package/src/codegen.js CHANGED
@@ -95,11 +95,21 @@ export function write_project_generated_file(options, name, source) {
95
95
  * `staticEntries` (production builds) lists every module path the server can
96
96
  * name in #__octane_data — page entries, layouts, and the preHydrate hook.
97
97
  * Each becomes a STATIC `() => import('/src/…')` in a lookup map, so Rollup
98
- * sees, chunks, and hashes them; the runtime falls back to the hidden dynamic
98
+ * sees, chunks, and hashes them; the runtime falls back to a native dynamic
99
99
  * import only for paths outside the map (the dev case, where the map is empty
100
- * and the integration serves any module by URL).
100
+ * and the integration serves any module by URL). The fallback resolves the
101
+ * project-root ID against the browsing context's real location. Project IDs
102
+ * are root-absolute, so this has the same URL semantics as importing from this
103
+ * generated module while remaining immune to an authored `<base>` element.
104
+ * (Rspack rewrites `import.meta.url` to `document.baseURI` in classic output,
105
+ * so it cannot be used directly here.) Vite serves `.tsrx` modules through its
106
+ * canonical `?import` URL but leaves ordinary JavaScript/TypeScript URLs
107
+ * unchanged, while Rspack honors its ignore hint. Keeping the import native
108
+ * avoids requiring `unsafe-eval` under a nonce-based Content Security Policy.
101
109
  *
102
110
  * octane specifics:
111
+ * - `initializeHydrationEventCapture()` runs before any async route work so
112
+ * interaction boundaries can preserve intent that precedes `hydrateRoot()`.
103
113
  * - `import { hydrateRoot } from 'octane'` (NO `mount`).
104
114
  * - `hydrateRoot(container, body, props)` signature (container FIRST, React-18
105
115
  * shape) — no `{ target, props }` wrapper.
@@ -146,7 +156,9 @@ export function create_client_entry_source(options = {}) {
146
156
  return `// Auto-generated by ${generatedBy}.
147
157
  // This file is written to the active integration's project cache.
148
158
 
149
- import { hydrateRoot, Suspense, ErrorBoundary, createElement } from ${JSON.stringify(runtimeModuleId)};
159
+ import { hydrateRoot, initializeHydrationEventCapture, Suspense, ErrorBoundary, createElement } from ${JSON.stringify(runtimeModuleId)};
160
+
161
+ initializeHydrationEventCapture();
150
162
 
151
163
  // Static import map (production): every module the server may name in
152
164
  // #__octane_data, as bundle-analyzable dynamic imports. Empty in dev.
@@ -154,11 +166,20 @@ const routeModules = {
154
166
  ${static_map_lines}
155
167
  };
156
168
 
157
- // Keep the fallback hidden from static import analysis. Some integrations
158
- // rewrite variable dynamic imports into queried URLs, which can evaluate as a
159
- // SECOND browser module instance and stop pages/preHydrate hooks sharing
160
- // singletons with statically imported copies of the same files.
161
- const dynamicImport = new Function('specifier', 'return import(specifier)');
169
+ // Keep the fallback native and canonical. Vite serves static \`.tsrx\` imports
170
+ // through \`?import\`, but ordinary JS/TS imports without it. Resolve that same
171
+ // root-absolute URL against the browsing context's real location (not a
172
+ // possibly-authored document <base>) so Vite's variable-import helper leaves
173
+ // it unchanged; Rspack honors webpackIgnore. Pages/preHydrate hooks then share
174
+ // module singletons with static imports. A Function constructor breaks CSP.
175
+ const dynamicImport = (specifier) => {
176
+ const url = new URL(specifier, globalThis.location.href);
177
+ if (url.pathname.endsWith('.tsrx') && !url.searchParams.has('import')) {
178
+ const query = url.search.slice(1);
179
+ url.search = query ? '?import&' + query : '?import';
180
+ }
181
+ return import(/* @vite-ignore */ /* webpackIgnore: true */ url.href);
182
+ };
162
183
 
163
184
  function importModule(path) {
164
185
  const loader = routeModules[path];
@@ -175,14 +196,9 @@ function getComponentExport(module, exportName) {
175
196
 
176
197
  function withRootBoundary(content, boundary) {
177
198
  let body = content;
178
- if (boundary.pending) {
179
- const child = body;
180
- const Pending = boundary.pending;
181
- body = (props, scope) => Suspense({
182
- fallback: createElement(Pending, {}),
183
- children: (_props, childScope) => child(props, childScope),
184
- }, scope);
185
- }
199
+ // Keep ErrorBoundary closest to the route. Suspense may retain its pending
200
+ // shell for an unhandled server render error, so it must wrap the configured
201
+ // catch boundary rather than hiding route errors from it.
186
202
  if (boundary.catch) {
187
203
  const child = body;
188
204
  const Catch = boundary.catch;
@@ -191,6 +207,14 @@ function withRootBoundary(content, boundary) {
191
207
  children: (_props, childScope) => child(props, childScope),
192
208
  }, scope);
193
209
  }
210
+ if (boundary.pending) {
211
+ const child = body;
212
+ const Pending = boundary.pending;
213
+ body = (props, scope) => Suspense({
214
+ fallback: createElement(Pending, {}),
215
+ children: (_props, childScope) => child(props, childScope),
216
+ }, scope);
217
+ }
194
218
  return body;
195
219
  }
196
220
 
@@ -27,8 +27,9 @@ const DEFAULT_CONFIG_FILE = 'octane.config.ts';
27
27
  const OCTANE_EXTENSION_PATTERN = /\.tsrx$/;
28
28
  const BUILTINS = new Set([...builtinModules, ...builtinModules.map((id) => `node:${id}`)]);
29
29
 
30
- // Validation + defaults have no compiler/esbuild imports and remain safe to
31
- // include in production server bundles.
30
+ // Validation + defaults have no compiler transform/esbuild imports and remain
31
+ // safe to include in production server bundles. Their renderer config helper
32
+ // is a dependency-free compiler subpath.
32
33
  export { resolveOctaneConfig } from './resolve-config.js';
33
34
 
34
35
  /**
@@ -236,7 +237,9 @@ async function evaluateConfigModule(root, configPath, configuredCacheDir) {
236
237
  const contentHash = createHash('sha256').update(output).digest('hex').slice(0, 16);
237
238
  let configModule;
238
239
  try {
239
- configModule = await import(`${pathToFileURL(outputPath).href}?v=${contentHash}`);
240
+ configModule = await import(
241
+ /* @vite-ignore */ `${pathToFileURL(outputPath).href}?v=${contentHash}`
242
+ );
240
243
  } catch (error) {
241
244
  attachDependencyMetadata(error, dependencies, missingDependencies);
242
245
  throw error;
@@ -2,16 +2,19 @@
2
2
  /**
3
3
  * Config validation + defaults — `resolveOctaneConfig` and its validators.
4
4
  *
5
- * Kept in a module with NO heavy imports (no bundler or octane/compiler) because
5
+ * Kept in a module with NO heavy imports (no bundler or compiler transform) because
6
6
  * it is part of the PRODUCTION server bundle's graph: the generated server
7
7
  * entry re-resolves octane.config.ts through it at boot, and the whole
8
8
  * `@octanejs/app-core/production` graph is bundled into dist/server/entry.js.
9
9
  * The file-loading half (`loadOctaneConfig`) lives in
10
- * `load-config.js` and re-exports everything here.
10
+ * `config-loader.js` and re-exports everything here.
11
+ * `octane/compiler/renderers` is intentionally a dependency-free config helper.
11
12
  */
12
13
 
13
14
  /** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/app-core' */
14
15
 
16
+ import { normalizeRendererConfig } from 'octane/compiler/renderers';
17
+
15
18
  import { DEFAULT_OUTDIR } from './constants.js';
16
19
 
17
20
  /**
@@ -122,6 +125,13 @@ export function resolveOctaneConfig(raw, options = {}) {
122
125
  }
123
126
  }
124
127
 
128
+ if (
129
+ raw.compiler !== undefined &&
130
+ (!raw.compiler || typeof raw.compiler !== 'object' || Array.isArray(raw.compiler))
131
+ ) {
132
+ throw new Error('[octane] compiler must be an object when provided.');
133
+ }
134
+
125
135
  if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
126
136
  throw new Error('[octane] router.routes must be an array.');
127
137
  }
@@ -160,6 +170,9 @@ export function resolveOctaneConfig(raw, options = {}) {
160
170
  target: raw.build?.target,
161
171
  },
162
172
  adapter: raw.adapter,
173
+ compiler: {
174
+ renderers: normalizeRendererConfig(raw.compiler?.renderers),
175
+ },
163
176
  router: {
164
177
  routes: raw.router?.routes ?? [],
165
178
  preHydrate: raw.router?.preHydrate,
@@ -69,6 +69,22 @@ export function createLayoutWrapper(Layout, Page, pageProps) {
69
69
  export function createRootBoundaryWrapper(Content, boundary, runtime) {
70
70
  let body = Content;
71
71
 
72
+ // Compose the catch boundary closest to the route. A Suspense boundary is
73
+ // allowed to retain its pending shell when an unhandled server render error
74
+ // reaches it, so putting Suspense inside ErrorBoundary would prevent the
75
+ // configured catch component from observing ordinary route errors.
76
+ if (boundary.catch) {
77
+ const child = body;
78
+ const Catch = boundary.catch;
79
+ body = function RootErrorBoundary(props, scope) {
80
+ const children = (/** @type {any} */ _props, /** @type {any} */ childScope) =>
81
+ child(props, childScope, undefined);
82
+ const fallback = (/** @type {unknown} */ error, /** @type {() => void} */ reset) =>
83
+ runtime.createElement(Catch, { error, reset });
84
+ return runtime.ErrorBoundary({ fallback, children }, scope, undefined);
85
+ };
86
+ }
87
+
72
88
  if (boundary.pending) {
73
89
  const child = body;
74
90
  const Pending = boundary.pending;
@@ -83,17 +99,5 @@ export function createRootBoundaryWrapper(Content, boundary, runtime) {
83
99
  };
84
100
  }
85
101
 
86
- if (boundary.catch) {
87
- const child = body;
88
- const Catch = boundary.catch;
89
- body = function RootErrorBoundary(props, scope) {
90
- const children = (/** @type {any} */ _props, /** @type {any} */ childScope) =>
91
- child(props, childScope, undefined);
92
- const fallback = (/** @type {unknown} */ error, /** @type {() => void} */ reset) =>
93
- runtime.createElement(Catch, { error, reset });
94
- return runtime.ErrorBoundary({ fallback, children }, scope, undefined);
95
- };
96
- }
97
-
98
102
  return body;
99
103
  }
@@ -12,7 +12,121 @@
12
12
  import http from 'node:http';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { Readable } from 'node:stream';
15
+ import { Duplex, pipeline, Readable } from 'node:stream';
16
+ import { constants as zlibConstants, createGzip } from 'node:zlib';
17
+
18
+ const MIN_COMPRESSION_BYTES = 1024;
19
+
20
+ /**
21
+ * Read one request header in the comma-joined form HTTP negotiation expects.
22
+ * @param {import('node:http').IncomingMessage} request
23
+ * @param {string} name
24
+ * @returns {string | null}
25
+ */
26
+ function getRequestHeader(request, name) {
27
+ const value = request.headers[name];
28
+ if (value === undefined) return null;
29
+ return Array.isArray(value) ? value.join(',') : value;
30
+ }
31
+
32
+ /**
33
+ * Return the client's quality for one content coding. An explicit coding wins
34
+ * over `*`, including an explicit `q=0` exclusion.
35
+ * @param {string | null} header
36
+ * @param {string} coding
37
+ */
38
+ function encodingQuality(header, coding) {
39
+ if (header === null || header.trim() === '') return 0;
40
+ /** @type {number | null} */
41
+ let exact = null;
42
+ /** @type {number | null} */
43
+ let wildcard = null;
44
+ for (const entry of header.split(',')) {
45
+ const [rawToken, ...parameters] = entry.split(';');
46
+ const token = rawToken.trim().toLowerCase();
47
+ if (token !== coding && token !== '*') continue;
48
+
49
+ let quality = 1;
50
+ for (const parameter of parameters) {
51
+ const match = /^\s*q\s*=\s*([^\s]+)\s*$/i.exec(parameter);
52
+ if (!match) continue;
53
+ // RFC 9110 qvalues are 0..1 with at most three fractional digits.
54
+ quality = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(match[1]) ? Number(match[1]) : 0;
55
+ }
56
+
57
+ if (token === coding && exact === null) exact = quality;
58
+ if (token === '*' && wildcard === null) wildcard = quality;
59
+ }
60
+ return exact ?? wildcard ?? 0;
61
+ }
62
+
63
+ /** @param {string | null} contentType */
64
+ function isCompressibleContentType(contentType) {
65
+ if (!contentType) return false;
66
+ const type = contentType.split(';', 1)[0].trim().toLowerCase();
67
+ if (type === 'text/event-stream') return false;
68
+ if (type.startsWith('text/')) return true;
69
+ if (type === 'image/svg+xml') return true;
70
+ if (type === 'application/wasm') return true;
71
+ if (type === 'font/ttf' || type === 'font/otf') return true;
72
+ if (type.endsWith('+json') || type.endsWith('+xml')) return true;
73
+ return (
74
+ type === 'application/json' ||
75
+ type === 'application/javascript' ||
76
+ type === 'application/x-javascript' ||
77
+ type === 'application/xml' ||
78
+ type === 'application/xhtml+xml' ||
79
+ type === 'application/rss+xml' ||
80
+ type === 'application/atom+xml'
81
+ );
82
+ }
83
+
84
+ /** @param {Headers} headers */
85
+ function appendAcceptEncodingVary(headers) {
86
+ const vary = headers.get('Vary');
87
+ if (!vary) {
88
+ headers.set('Vary', 'Accept-Encoding');
89
+ return;
90
+ }
91
+ if (vary.trim() === '*') return;
92
+ if (vary.split(',').some((value) => value.trim().toLowerCase() === 'accept-encoding')) {
93
+ return;
94
+ }
95
+ headers.set('Vary', `${vary}, Accept-Encoding`);
96
+ }
97
+
98
+ /**
99
+ * Decide whether this representation is eligible for negotiated gzip. Eligible
100
+ * identity responses still gain `Vary: Accept-Encoding`, so shared caches do
101
+ * not reuse them for gzip-capable clients.
102
+ *
103
+ * @param {import('node:http').IncomingMessage} request
104
+ * @param {number} status
105
+ * @param {Headers} headers
106
+ * @param {boolean} hasBody
107
+ */
108
+ function shouldGzip(request, status, headers, hasBody) {
109
+ const method = (request.method || 'GET').toUpperCase();
110
+ if (!hasBody || method === 'HEAD') return false;
111
+ if (status < 200 || status === 204 || status === 205 || status === 206 || status === 304) {
112
+ return false;
113
+ }
114
+ if (getRequestHeader(request, 'range') !== null || headers.has('Content-Range')) return false;
115
+ if (headers.has('Content-Encoding')) return false;
116
+ if (/(?:^|,)\s*no-transform\s*(?:,|$)/i.test(headers.get('Cache-Control') || '')) {
117
+ return false;
118
+ }
119
+ if (!isCompressibleContentType(headers.get('Content-Type'))) return false;
120
+
121
+ const rawLength = headers.get('Content-Length');
122
+ if (rawLength !== null) {
123
+ const length = Number(rawLength);
124
+ if (Number.isFinite(length) && length < MIN_COMPRESSION_BYTES) return false;
125
+ }
126
+
127
+ appendAcceptEncodingVary(headers);
128
+ return encodingQuality(getRequestHeader(request, 'accept-encoding'), 'gzip') > 0;
129
+ }
16
130
 
17
131
  /**
18
132
  * Convert a Node.js IncomingMessage to a Web Request.
@@ -70,13 +184,40 @@ export function nodeRequestToWebRequest(nodeRequest) {
70
184
  * @param {Response} webResponse
71
185
  */
72
186
  export async function sendWebResponse(nodeResponse, webResponse) {
187
+ return sendWebResponseForRequest(nodeResponse, webResponse);
188
+ }
189
+
190
+ /**
191
+ * The built-in server supplies the request so this transport layer can
192
+ * negotiate compression. Serverless adapters keep calling `sendWebResponse`
193
+ * without transport compression because their host owns content encoding.
194
+ *
195
+ * @param {import('node:http').ServerResponse} nodeResponse
196
+ * @param {Response} webResponse
197
+ * @param {import('node:http').IncomingMessage} [nodeRequest]
198
+ */
199
+ async function sendWebResponseForRequest(nodeResponse, webResponse, nodeRequest) {
200
+ const headers = new Headers(webResponse.headers);
201
+ /** @type {ReadableStream<Uint8Array> | null} */
202
+ let body = webResponse.body;
203
+ if (nodeRequest && body && shouldGzip(nodeRequest, webResponse.status, headers, true)) {
204
+ headers.set('Content-Encoding', 'gzip');
205
+ headers.delete('Content-Length');
206
+ // Sync-flush each input chunk so an SSR shell stays progressively
207
+ // observable instead of waiting for the final segment to close gzip.
208
+ const gzip = Duplex.toWeb(createGzip({ flush: zlibConstants.Z_SYNC_FLUSH }));
209
+ body = body.pipeThrough(
210
+ /** @type {ReadableWritablePair<Uint8Array, Uint8Array>} */ (/** @type {unknown} */ (gzip)),
211
+ );
212
+ }
213
+
73
214
  nodeResponse.statusCode = webResponse.status;
74
215
  if (webResponse.statusText) nodeResponse.statusMessage = webResponse.statusText;
75
- webResponse.headers.forEach((value, key) => {
216
+ headers.forEach((value, key) => {
76
217
  nodeResponse.setHeader(key, value);
77
218
  });
78
- if (webResponse.body) {
79
- const reader = webResponse.body.getReader();
219
+ if (body) {
220
+ const reader = body.getReader();
80
221
  let disconnected = nodeResponse.destroyed;
81
222
  let disconnectReason = new Error('The client disconnected while streaming the response.');
82
223
  /** @type {Promise<void> | null} */
@@ -207,17 +348,35 @@ export function serveStaticFile(req, res, staticDir) {
207
348
  if (!stat.isFile()) return false;
208
349
 
209
350
  const ext = path.extname(filePath).toLowerCase();
351
+ const headers = new Headers({
352
+ 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
353
+ 'Content-Length': String(stat.size),
354
+ 'Cache-Control':
355
+ pathname.startsWith('/assets/') || pathname.startsWith('/static/')
356
+ ? 'public, max-age=31536000, immutable'
357
+ : 'public, max-age=0, must-revalidate',
358
+ });
359
+ const gzip = shouldGzip(req, 200, headers, method !== 'HEAD');
360
+ if (gzip) {
361
+ headers.set('Content-Encoding', 'gzip');
362
+ headers.delete('Content-Length');
363
+ }
364
+
210
365
  res.statusCode = 200;
211
- res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
212
- res.setHeader('Content-Length', stat.size);
213
- res.setHeader(
214
- 'Cache-Control',
215
- pathname.startsWith('/assets/') || pathname.startsWith('/static/')
216
- ? 'public, max-age=31536000, immutable'
217
- : 'public, max-age=0, must-revalidate',
218
- );
366
+ res.setHeader('Content-Type', /** @type {string} */ (headers.get('Content-Type')));
367
+ const contentLength = headers.get('Content-Length');
368
+ if (contentLength !== null) res.setHeader('Content-Length', contentLength);
369
+ res.setHeader('Cache-Control', /** @type {string} */ (headers.get('Cache-Control')));
370
+ const contentEncoding = headers.get('Content-Encoding');
371
+ if (contentEncoding !== null) res.setHeader('Content-Encoding', contentEncoding);
372
+ const vary = headers.get('Vary');
373
+ if (vary !== null) res.setHeader('Vary', vary);
219
374
  if (method === 'HEAD') {
220
375
  res.end();
376
+ } else if (gzip) {
377
+ pipeline(fs.createReadStream(filePath), createGzip(), res, (error) => {
378
+ if (error && !res.destroyed) res.destroy(error);
379
+ });
221
380
  } else {
222
381
  fs.createReadStream(filePath).pipe(res);
223
382
  }
@@ -240,7 +399,7 @@ export function createNodeServer(handler, options = {}) {
240
399
  (async () => {
241
400
  if (staticDir && serveStaticFile(req, res, staticDir)) return;
242
401
  const response = await handler(nodeRequestToWebRequest(req));
243
- await sendWebResponse(res, response);
402
+ await sendWebResponseForRequest(res, response, req);
244
403
  })().catch((error) => {
245
404
  console.error('[octane] Request error:', error);
246
405
  if (!res.headersSent) {
@@ -5,9 +5,9 @@
5
5
  * `createHandler(manifest, deps)` is the runtime entry the generated server
6
6
  * bundle (dist/server/entry.js) calls in production. It is designed to be
7
7
  * BUNDLED: platform-agnostic (no Node imports — platform capabilities come via
8
- * `manifest.runtime`), and free of vite / octane-compiler imports (which is why
9
- * `resolveOctaneConfig` is re-exported from resolve-config.js, not
10
- * load-config.js).
8
+ * `manifest.runtime`), and free of Vite / compiler-transform imports (which is
9
+ * why `resolveOctaneConfig` is re-exported from resolve-config.js, not
10
+ * config-loader.js). Its renderer config helper is dependency-free.
11
11
  *
12
12
  * The render path mirrors the DEV middleware's `handleRenderRoute`
13
13
  * (server/render-route.js) byte-for-byte in everything hydration can see —
package/types/config.d.ts CHANGED
@@ -12,6 +12,14 @@ export type {
12
12
  AdapterServeFunction,
13
13
  BuildTarget,
14
14
  Context,
15
+ ExperimentalRendererBoundaryOptions,
16
+ ExperimentalRendererConfigOptions,
17
+ ExperimentalRendererRegistryEntry,
18
+ ExperimentalRendererRuleOptions,
19
+ ExperimentalResolvedRendererBoundary,
20
+ ExperimentalResolvedRendererConfig,
21
+ ExperimentalResolvedRendererRegistryEntry,
22
+ ExperimentalResolvedRendererRule,
15
23
  Middleware,
16
24
  OctaneAdapter,
17
25
  OctaneConfigOptions,
package/types/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export const OCTANE_NONCE_STATE_KEY: 'octane.nonce';
10
10
  export const DEFAULT_OUTDIR: 'dist';
11
11
  export const ENTRY_FILENAME: 'entry.js';
12
12
  export function resolveOctaneConfig(
13
- raw: OctaneConfigOptions,
13
+ raw: OctaneConfigOptions | ResolvedOctaneConfig,
14
14
  options?: { requireAdapter?: boolean },
15
15
  ): ResolvedOctaneConfig;
16
16
 
@@ -177,6 +177,108 @@ export interface RootBoundaryOptions {
177
177
  catch?: RenderRouteEntry;
178
178
  }
179
179
 
180
+ /**
181
+ * @experimental Universal renderer configuration is an internal-first API and
182
+ * may change while the first non-DOM renderer is validated.
183
+ */
184
+ export interface ExperimentalRendererRuleOptions {
185
+ /** Glob or globs matched against canonical project-relative module IDs. */
186
+ include: string | readonly string[];
187
+ /** Optional glob or globs that remove files from this rule. */
188
+ exclude?: string | readonly string[];
189
+ /** Renderer alias declared in `registry`, or the built-in `dom` alias. */
190
+ renderer: string;
191
+ }
192
+
193
+ /**
194
+ * @experimental A string selects the universal compiler target. The object
195
+ * form carries explicit target metadata for normalized configs and future
196
+ * renderer integrations. The `dom` alias itself is reserved by Octane.
197
+ */
198
+ export type ExperimentalRendererRegistryEntry =
199
+ | string
200
+ | {
201
+ module: string;
202
+ target?: 'dom' | 'universal';
203
+ /** Explicit server policy; universal renderers currently support client-only or unsupported. */
204
+ server?: 'render' | 'client-only' | 'unsupported';
205
+ /** JSX import-source module used for file-local intrinsic element types. */
206
+ intrinsics?: string;
207
+ /** Policy for authored text children. @default 'reject' */
208
+ text?: 'reject' | 'ignore' | 'host';
209
+ /** Serializable feature flags consumed by compiler and runtime integrations. */
210
+ capabilities?: readonly string[];
211
+ };
212
+
213
+ /**
214
+ * @experimental Static metadata for a component prop whose contents are owned
215
+ * by another renderer. Boundary declarations are keyed by the component's
216
+ * public module ID and export name in {@link ExperimentalRendererConfigOptions}.
217
+ */
218
+ export interface ExperimentalRendererBoundaryOptions {
219
+ /** Renderer that owns the boundary component itself. */
220
+ ownerRenderer: string;
221
+ /** Renderer used to lower and execute the declared child region. */
222
+ childRenderer: string;
223
+ /** Component prop containing the renderer-owned region, usually `children`. */
224
+ prop: string;
225
+ /** Omit a client-only child region from server output. */
226
+ server?: 'omit-child';
227
+ }
228
+
229
+ /** @experimental See {@link ExperimentalRendererRuleOptions}. */
230
+ export interface ExperimentalRendererConfigOptions {
231
+ /** Renderer aliases mapped to package/project-root module IDs or explicit descriptors. */
232
+ registry?: Record<string, ExperimentalRendererRegistryEntry>;
233
+ /**
234
+ * Boundary metadata keyed first by stable package/project-root module ID,
235
+ * then by the component's export name (`default` for a default export).
236
+ */
237
+ boundaries?: Readonly<
238
+ Record<string, Readonly<Record<string, ExperimentalRendererBoundaryOptions>>>
239
+ >;
240
+ /** Renderer used when no rule matches. @default 'dom' */
241
+ default?: string;
242
+ /** Ordered filename rules. The first matching rule wins. */
243
+ rules?: readonly ExperimentalRendererRuleOptions[];
244
+ }
245
+
246
+ /** @experimental Canonical form used by compiler integrations and cache keys. */
247
+ export interface ExperimentalResolvedRendererRule {
248
+ readonly include: readonly string[];
249
+ readonly exclude: readonly string[];
250
+ readonly renderer: string;
251
+ }
252
+
253
+ /** @experimental Canonical form used by compiler integrations and cache keys. */
254
+ export interface ExperimentalResolvedRendererRegistryEntry {
255
+ readonly module: string;
256
+ readonly target: 'dom' | 'universal';
257
+ readonly server: 'render' | 'client-only' | 'unsupported';
258
+ readonly intrinsics?: string;
259
+ readonly text: 'reject' | 'ignore' | 'host';
260
+ readonly capabilities: readonly string[];
261
+ }
262
+
263
+ /** @experimental Canonical renderer-owned child-region metadata. */
264
+ export interface ExperimentalResolvedRendererBoundary {
265
+ readonly ownerRenderer: string;
266
+ readonly childRenderer: string;
267
+ readonly prop: string;
268
+ readonly server?: 'omit-child';
269
+ }
270
+
271
+ /** @experimental Canonical form used by compiler integrations and cache keys. */
272
+ export interface ExperimentalResolvedRendererConfig {
273
+ readonly registry: Readonly<Record<string, ExperimentalResolvedRendererRegistryEntry>>;
274
+ readonly boundaries: Readonly<
275
+ Record<string, Readonly<Record<string, ExperimentalResolvedRendererBoundary>>>
276
+ >;
277
+ readonly default: string;
278
+ readonly rules: readonly ExperimentalResolvedRendererRule[];
279
+ readonly signature: string;
280
+ }
281
+
180
282
  export interface OctaneConfigOptions {
181
283
  build?: {
182
284
  /** Output directory for the production build. @default 'dist' */
@@ -185,6 +287,10 @@ export interface OctaneConfigOptions {
185
287
  target?: BuildTarget;
186
288
  };
187
289
  adapter?: OctaneAdapter;
290
+ /** @experimental Compiler-owned configuration shared by all bundler integrations. */
291
+ compiler?: {
292
+ renderers?: ExperimentalRendererConfigOptions;
293
+ };
188
294
  router?: {
189
295
  routes: Route[];
190
296
  /**
@@ -234,6 +340,9 @@ export interface ResolvedOctaneConfig {
234
340
  target?: BuildTarget;
235
341
  };
236
342
  adapter?: OctaneAdapter;
343
+ compiler: {
344
+ renderers: ExperimentalResolvedRendererConfig;
345
+ };
237
346
  router: {
238
347
  routes: Route[];
239
348
  preHydrate?: string;
@@ -9,7 +9,7 @@ import type {
9
9
  import type { RenderResult, StreamOptions, RenderOptions } from 'octane/server';
10
10
 
11
11
  export function resolveOctaneConfig(
12
- raw: OctaneConfigOptions,
12
+ raw: OctaneConfigOptions | ResolvedOctaneConfig,
13
13
  options?: { requireAdapter?: boolean },
14
14
  ): ResolvedOctaneConfig;
15
15