@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 +3 -3
- package/src/codegen.js +40 -16
- package/src/config-loader.js +6 -3
- package/src/resolve-config.js +15 -2
- package/src/server/component-wrappers.js +16 -12
- package/src/server/node-http.js +172 -13
- package/src/server/production.js +3 -3
- package/types/config.d.ts +8 -0
- package/types/index.d.ts +110 -1
- package/types/production.d.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
82
|
+
"octane": "0.1.10"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.3.0",
|
|
86
|
-
"octane": "0.1.
|
|
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
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
package/src/config-loader.js
CHANGED
|
@@ -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
|
|
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(
|
|
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;
|
package/src/resolve-config.js
CHANGED
|
@@ -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
|
|
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
|
-
* `
|
|
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
|
}
|
package/src/server/node-http.js
CHANGED
|
@@ -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
|
-
|
|
216
|
+
headers.forEach((value, key) => {
|
|
76
217
|
nodeResponse.setHeader(key, value);
|
|
77
218
|
});
|
|
78
|
-
if (
|
|
79
|
-
const reader =
|
|
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',
|
|
212
|
-
|
|
213
|
-
res.setHeader(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
402
|
+
await sendWebResponseForRequest(res, response, req);
|
|
244
403
|
})().catch((error) => {
|
|
245
404
|
console.error('[octane] Request error:', error);
|
|
246
405
|
if (!res.headersSent) {
|
package/src/server/production.js
CHANGED
|
@@ -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
|
|
9
|
-
* `resolveOctaneConfig` is re-exported from resolve-config.js, not
|
|
10
|
-
*
|
|
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;
|
package/types/production.d.ts
CHANGED
|
@@ -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
|
|