@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.42",
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.118",
78
+ "@ripple-ts/adapter": "^0.3.125",
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "0.1.46"
82
+ "octane": "0.1.48"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.1.46"
86
+ "octane": "0.1.48"
87
87
  }
88
88
  }
@@ -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
- applyHydrationNonce(htmlTemplate, null);
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: getRenderRouteIndex(manifest.routes, route),
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
- /** @type {string[]} */
304
- const preloadTags = [];
305
- const clientAssets = manifest.clientAssets;
306
- const entryAssets = entryPath ? clientAssets?.[entryPath] : undefined;
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 headContent = [...preloadTags, dataScript].join('\n');
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
- splitSsrTemplate(noncedTemplate.replace('<!--ssr-head-->', () => headContent + hoistedHead));
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
@@ -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
- * Convert a route path pattern to a RegExp
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
- const pattern = new RegExp(`^${regexString || '/'}$`);
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
- if (!methods.includes(method.toUpperCase())) {
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
- /** @type {Record<string, string>} */
113
- const params = {};
114
- for (let i = 0; i < paramNames.length; i++) {
115
- params[paramNames[i]] = decodeURIComponent(match[i + 1]);
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
- /** Reject unsafe state updates and ref writes in application-owned modules. @default false */
396
+ /** Assert pure immutable-snapshot renders and reject detectable violations. @default false */
397
397
  strong?: boolean;
398
398
  renderers?: ExperimentalRendererConfigOptions;
399
399
  };