@octanejs/app-core 0.0.42 → 0.0.44
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 +4 -4
- package/src/server/production.js +119 -44
- package/src/server/router.js +20 -12
- package/types/index.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.44",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@ripple-ts/adapter": "^0.3.
|
|
78
|
+
"@ripple-ts/adapter": "^0.3.125",
|
|
79
79
|
"esbuild": "^0.28.1"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
|
-
"octane": "0.1.
|
|
82
|
+
"octane": "0.1.48"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "0.1.
|
|
86
|
+
"octane": "0.1.48"
|
|
87
87
|
}
|
|
88
88
|
}
|
package/src/server/production.js
CHANGED
|
@@ -54,6 +54,10 @@ import { patch_global_fetch, build_rpc_lookup, is_rpc_request } from '@ripple-ts
|
|
|
54
54
|
|
|
55
55
|
export { resolveOctaneConfig } from '../resolve-config.js';
|
|
56
56
|
|
|
57
|
+
const HEAD_MARKER = '<!--ssr-head-->';
|
|
58
|
+
const BODY_MARKER = '<!--ssr-body-->';
|
|
59
|
+
const BODY_CLOSE_TAG = /<\/body\s*>/i;
|
|
60
|
+
|
|
57
61
|
// A server integration can reload its compiled manifest repeatedly while the
|
|
58
62
|
// process (and global fetch) stays alive. Ripple's fetch patch is deliberately
|
|
59
63
|
// idempotent, so calling it again cannot replace its closed-over handler or
|
|
@@ -124,6 +128,37 @@ function buildRpcDescriptors(rpcModules, hashFn) {
|
|
|
124
128
|
return descriptors;
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Split the immutable, validated production template around both insertion
|
|
133
|
+
* markers once. Dynamic head content is then concatenated into the prepared
|
|
134
|
+
* fragments without rescanning the complete template on every request.
|
|
135
|
+
*
|
|
136
|
+
* `splitSsrTemplate` historically revalidated after head insertion. Preserve
|
|
137
|
+
* that behavior on the exceptional path where inserted content could change
|
|
138
|
+
* the body-marker contract.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} html
|
|
141
|
+
* @returns {(headContent: string) => string[]}
|
|
142
|
+
*/
|
|
143
|
+
function prepareSsrTemplate(html) {
|
|
144
|
+
const [prefix, suffix] = splitSsrTemplate(html);
|
|
145
|
+
const prefixHeadAt = prefix.indexOf(HEAD_MARKER);
|
|
146
|
+
const headInPrefix = prefixHeadAt !== -1;
|
|
147
|
+
const headAt = headInPrefix ? prefixHeadAt : suffix.indexOf(HEAD_MARKER);
|
|
148
|
+
const headSide = headInPrefix ? prefix : suffix;
|
|
149
|
+
const beforeHead = headSide.slice(0, headAt);
|
|
150
|
+
const afterHead = headSide.slice(headAt + HEAD_MARKER.length);
|
|
151
|
+
|
|
152
|
+
return (headContent) => {
|
|
153
|
+
const nextPrefix = headInPrefix ? beforeHead + headContent + afterHead : prefix;
|
|
154
|
+
const nextSuffix = headInPrefix ? suffix : beforeHead + headContent + afterHead;
|
|
155
|
+
if (headContent.includes(BODY_MARKER) || BODY_CLOSE_TAG.test(headContent)) {
|
|
156
|
+
return splitSsrTemplate(nextPrefix + BODY_MARKER + nextSuffix);
|
|
157
|
+
}
|
|
158
|
+
return [nextPrefix, nextSuffix];
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
127
162
|
/**
|
|
128
163
|
* @typedef {import('@octanejs/app-core').RenderRoute} RenderRoute
|
|
129
164
|
* @typedef {import('@octanejs/app-core').Middleware} Middleware
|
|
@@ -133,6 +168,71 @@ function buildRpcDescriptors(rpcModules, hashFn) {
|
|
|
133
168
|
@import { ServerManifest, HandlerOptions, ClientAssetEntry } from '../../types/production.d.ts'
|
|
134
169
|
*/
|
|
135
170
|
|
|
171
|
+
/**
|
|
172
|
+
* @typedef {Object} PreparedRenderRoute
|
|
173
|
+
* @property {number} index
|
|
174
|
+
* @property {string | undefined} assetHead
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Index the production manifest once, matching the router's handler-lifetime
|
|
179
|
+
* snapshot. Integrations create a new handler when replacing that manifest.
|
|
180
|
+
* Asset tags are populated lazily on the first request for each route, avoiding
|
|
181
|
+
* both repeated assembly on hot routes and eager work for routes an isolate may
|
|
182
|
+
* never serve.
|
|
183
|
+
*
|
|
184
|
+
* @param {import('@octanejs/app-core').Route[]} routes
|
|
185
|
+
* @returns {Map<RenderRoute, PreparedRenderRoute>}
|
|
186
|
+
*/
|
|
187
|
+
function prepareRenderRoutes(routes) {
|
|
188
|
+
/** @type {Map<RenderRoute, PreparedRenderRoute>} */
|
|
189
|
+
const prepared = new Map();
|
|
190
|
+
let index = 0;
|
|
191
|
+
for (const route of routes) {
|
|
192
|
+
if (route.type !== 'render') continue;
|
|
193
|
+
// Preserve Array#indexOf semantics if a caller reuses one route object.
|
|
194
|
+
if (!prepared.has(route)) prepared.set(route, { index, assetHead: undefined });
|
|
195
|
+
index++;
|
|
196
|
+
}
|
|
197
|
+
return prepared;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {ServerManifest} manifest
|
|
202
|
+
* @param {RenderRoute} route
|
|
203
|
+
* @param {string | undefined} entryPath
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
function prepareRouteAssetHead(manifest, route, entryPath) {
|
|
207
|
+
/** @type {string[]} */
|
|
208
|
+
const tags = [];
|
|
209
|
+
const clientAssets = manifest.clientAssets;
|
|
210
|
+
if (clientAssets) {
|
|
211
|
+
/** @type {Set<string>} */
|
|
212
|
+
const stylesheets = new Set();
|
|
213
|
+
for (const modulePath of [
|
|
214
|
+
entryPath,
|
|
215
|
+
route.layout,
|
|
216
|
+
manifest.rootBoundary?.pending ? manifest.rootBoundaryEntries?.pending?.path : undefined,
|
|
217
|
+
manifest.rootBoundary?.catch ? manifest.rootBoundaryEntries?.catch?.path : undefined,
|
|
218
|
+
]) {
|
|
219
|
+
if (!modulePath) continue;
|
|
220
|
+
for (const cssFile of clientAssets[modulePath]?.css ?? []) {
|
|
221
|
+
if (stylesheets.has(cssFile)) continue;
|
|
222
|
+
stylesheets.add(cssFile);
|
|
223
|
+
tags.push(`<link rel="stylesheet" href="/${cssFile}">`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Only the page chunk was already eager; do not promote layout, fallback,
|
|
228
|
+
// or island JavaScript while making their server-rendered CSS available.
|
|
229
|
+
const entryAssets = entryPath ? clientAssets?.[entryPath] : undefined;
|
|
230
|
+
if (entryAssets?.js) {
|
|
231
|
+
tags.push(`<link rel="modulepreload" href="/${entryAssets.js}">`);
|
|
232
|
+
}
|
|
233
|
+
return tags.join('\n');
|
|
234
|
+
}
|
|
235
|
+
|
|
136
236
|
/**
|
|
137
237
|
* Create the production request handler from a manifest.
|
|
138
238
|
*
|
|
@@ -150,14 +250,17 @@ function buildRpcDescriptors(rpcModules, hashFn) {
|
|
|
150
250
|
export function createHandler(manifest, deps) {
|
|
151
251
|
const { renderToReadableStream, prerender, htmlTemplate, executeServerFunction } = deps;
|
|
152
252
|
const router = createRouter(manifest.routes);
|
|
253
|
+
const preparedRenderRoutes = prepareRenderRoutes(manifest.routes);
|
|
153
254
|
const globalMiddlewares = manifest.middlewares ?? [];
|
|
154
255
|
const trustProxy = manifest.trustProxy ?? false;
|
|
155
256
|
const rpcPolicy = manifest.rpc;
|
|
156
257
|
const runtime = manifest.runtime;
|
|
157
258
|
validateSsrTemplate(htmlTemplate);
|
|
158
259
|
// Also pin the built-template contract up front. The marker is emitted by
|
|
159
|
-
// the integration's HTML transform and survives source hashing.
|
|
160
|
-
|
|
260
|
+
// the integration's HTML transform and survives source hashing. Prepare the
|
|
261
|
+
// normalized no-nonce template once: this is the common request path, and its
|
|
262
|
+
// static fragments are identical for every request handled by this manifest.
|
|
263
|
+
const splitHydrationTemplate = prepareSsrTemplate(applyHydrationNonce(htmlTemplate, null));
|
|
161
264
|
|
|
162
265
|
// RPC lookup for statically imported `module server` functions
|
|
163
266
|
// (compiler hash → server function).
|
|
@@ -244,6 +347,7 @@ export function createHandler(manifest, deps) {
|
|
|
244
347
|
* @returns {Promise<Response>}
|
|
245
348
|
*/
|
|
246
349
|
async function renderRoute(route, context) {
|
|
350
|
+
const preparedRoute = preparedRenderRoutes.get(route);
|
|
247
351
|
const entryPath = get_route_entry_path(route.entry);
|
|
248
352
|
const exportName = get_route_entry_export_name(route.entry);
|
|
249
353
|
const PageComponent = entryPath
|
|
@@ -288,7 +392,7 @@ export function createHandler(manifest, deps) {
|
|
|
288
392
|
entry: entryPath,
|
|
289
393
|
exportName: exportName ?? null,
|
|
290
394
|
layout: route.layout ?? null,
|
|
291
|
-
routeIndex:
|
|
395
|
+
routeIndex: preparedRoute?.index,
|
|
292
396
|
params: context.params,
|
|
293
397
|
url: requestUrl,
|
|
294
398
|
preHydrate: manifest.preHydrate ?? null,
|
|
@@ -300,35 +404,13 @@ export function createHandler(manifest, deps) {
|
|
|
300
404
|
// server HTML before hydration. Their asset records also include CSS for
|
|
301
405
|
// deferred Hydrate descendants, whose JavaScript must remain lazy. Keep
|
|
302
406
|
// page CSS first, preserve each record's order, and link shared files once.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (clientAssets) {
|
|
308
|
-
/** @type {Set<string>} */
|
|
309
|
-
const stylesheets = new Set();
|
|
310
|
-
for (const modulePath of [
|
|
311
|
-
entryPath,
|
|
312
|
-
route.layout,
|
|
313
|
-
manifest.rootBoundary?.pending ? manifest.rootBoundaryEntries?.pending?.path : undefined,
|
|
314
|
-
manifest.rootBoundary?.catch ? manifest.rootBoundaryEntries?.catch?.path : undefined,
|
|
315
|
-
]) {
|
|
316
|
-
if (!modulePath) continue;
|
|
317
|
-
for (const cssFile of clientAssets[modulePath]?.css ?? []) {
|
|
318
|
-
if (stylesheets.has(cssFile)) continue;
|
|
319
|
-
stylesheets.add(cssFile);
|
|
320
|
-
preloadTags.push(`<link rel="stylesheet" href="/${cssFile}">`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
// Only the page chunk was already eager; do not promote layout, fallback,
|
|
325
|
-
// or island JavaScript while making their server-rendered CSS available.
|
|
326
|
-
if (entryAssets?.js) {
|
|
327
|
-
preloadTags.push(`<link rel="modulepreload" href="/${entryAssets.js}">`);
|
|
407
|
+
let assetHead = preparedRoute?.assetHead;
|
|
408
|
+
if (assetHead === undefined) {
|
|
409
|
+
assetHead = prepareRouteAssetHead(manifest, route, entryPath);
|
|
410
|
+
if (preparedRoute) preparedRoute.assetHead = assetHead;
|
|
328
411
|
}
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
const noncedTemplate = applyHydrationNonce(htmlTemplate, nonce);
|
|
412
|
+
const headContent = assetHead === '' ? dataScript : assetHead + '\n' + dataScript;
|
|
413
|
+
const noncedTemplate = nonce === null ? null : applyHydrationNonce(htmlTemplate, nonce);
|
|
332
414
|
|
|
333
415
|
const status = route.status ?? 200;
|
|
334
416
|
const headers = { 'Content-Type': 'text/html; charset=utf-8' };
|
|
@@ -346,8 +428,12 @@ export function createHandler(manifest, deps) {
|
|
|
346
428
|
// match, and this text now carries author-controlled metadata as well as
|
|
347
429
|
// the serialized route data.
|
|
348
430
|
/** @param {string} hoistedHead */
|
|
349
|
-
const splitAroundBody = (hoistedHead) =>
|
|
350
|
-
|
|
431
|
+
const splitAroundBody = (hoistedHead) => {
|
|
432
|
+
const completeHead = headContent + hoistedHead;
|
|
433
|
+
return noncedTemplate === null
|
|
434
|
+
? splitHydrationTemplate(completeHead)
|
|
435
|
+
: splitSsrTemplate(noncedTemplate.replace(HEAD_MARKER, () => completeHead));
|
|
436
|
+
};
|
|
351
437
|
|
|
352
438
|
if (manifest.render === 'buffered') {
|
|
353
439
|
// Await-everything fallback (`prerender` from octane/static): no
|
|
@@ -397,17 +483,6 @@ export function createHandler(manifest, deps) {
|
|
|
397
483
|
return handler;
|
|
398
484
|
}
|
|
399
485
|
|
|
400
|
-
/**
|
|
401
|
-
* @param {import('@octanejs/app-core').Route[]} routes
|
|
402
|
-
* @param {RenderRoute} route
|
|
403
|
-
* @returns {number | undefined}
|
|
404
|
-
*/
|
|
405
|
-
function getRenderRouteIndex(routes, route) {
|
|
406
|
-
const renderRoutes = routes.filter((r) => r.type === 'render');
|
|
407
|
-
const index = renderRoutes.indexOf(route);
|
|
408
|
-
return index === -1 ? undefined : index;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
486
|
/**
|
|
412
487
|
* Escape script content to prevent XSS in the inline JSON data block.
|
|
413
488
|
* @param {string} str
|
package/src/server/router.js
CHANGED
|
@@ -14,20 +14,20 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* @typedef {Object} CompiledRoute
|
|
16
16
|
* @property {Route} route
|
|
17
|
-
* @property {RegExp} pattern
|
|
17
|
+
* @property {string | RegExp} pattern
|
|
18
18
|
* @property {string[]} paramNames
|
|
19
19
|
* @property {number} specificity - Higher = more specific (static > param > catch-all)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* Compile a route path into an exact string or capturing RegExp
|
|
24
24
|
* Supports:
|
|
25
25
|
* - Static segments: /about, /api/hello
|
|
26
26
|
* - Named params: /posts/:id, /users/:userId/posts/:postId
|
|
27
27
|
* - Catch-all: /docs/*slug
|
|
28
28
|
*
|
|
29
29
|
* @param {string} path
|
|
30
|
-
* @returns {{ pattern: RegExp, paramNames: string[], specificity: number }}
|
|
30
|
+
* @returns {{ pattern: string | RegExp, paramNames: string[], specificity: number }}
|
|
31
31
|
*/
|
|
32
32
|
function compilePath(path) {
|
|
33
33
|
/** @type {string[]} */
|
|
@@ -62,7 +62,9 @@ function compilePath(path) {
|
|
|
62
62
|
})
|
|
63
63
|
.join('/');
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
// Static paths are already exact matchers. Keep RegExp capture work for the
|
|
66
|
+
// parameter and catch-all routes that need it.
|
|
67
|
+
const pattern = paramNames.length === 0 ? path || '/' : new RegExp(`^${regexString || '/'}$`);
|
|
66
68
|
return { pattern, paramNames, specificity };
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -98,24 +100,30 @@ export function createRouter(routes) {
|
|
|
98
100
|
* @returns {RouteMatch | null}
|
|
99
101
|
*/
|
|
100
102
|
match(method, pathname) {
|
|
103
|
+
let normalizedMethod;
|
|
101
104
|
for (const { route, pattern, paramNames } of compiledRoutes) {
|
|
102
105
|
// Check method for ServerRoute
|
|
103
106
|
if (route.type === 'server') {
|
|
104
107
|
const methods = /** @type {ServerRoute} */ (route).methods;
|
|
105
|
-
|
|
108
|
+
normalizedMethod ??= method.toUpperCase();
|
|
109
|
+
if (!methods.includes(normalizedMethod)) {
|
|
106
110
|
continue;
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
if (typeof pattern === 'string') {
|
|
115
|
+
if (pathname === pattern) return { route, params: {} };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
const match = pathname.match(pattern);
|
|
111
|
-
if (match)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
return { route, params };
|
|
120
|
+
if (!match) continue;
|
|
121
|
+
/** @type {Record<string, string>} */
|
|
122
|
+
const params = {};
|
|
123
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
124
|
+
params[paramNames[i]] = decodeURIComponent(match[i + 1]);
|
|
118
125
|
}
|
|
126
|
+
return { route, params };
|
|
119
127
|
}
|
|
120
128
|
return null;
|
|
121
129
|
},
|
package/types/index.d.ts
CHANGED
|
@@ -393,7 +393,7 @@ export interface OctaneConfigOptions {
|
|
|
393
393
|
adapter?: OctaneAdapter;
|
|
394
394
|
/** @experimental Compiler-owned configuration shared by all bundler integrations. */
|
|
395
395
|
compiler?: {
|
|
396
|
-
/**
|
|
396
|
+
/** Assert pure immutable-snapshot renders and reject detectable violations. @default false */
|
|
397
397
|
strong?: boolean;
|
|
398
398
|
renderers?: ExperimentalRendererConfigOptions;
|
|
399
399
|
};
|