@octanejs/app-core 0.0.1

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.
@@ -0,0 +1,335 @@
1
+ // @ts-check
2
+ /**
3
+ * Production fetch-handler factory + config re-exports.
4
+ *
5
+ * `createHandler(manifest, deps)` is the runtime entry the generated server
6
+ * bundle (dist/server/entry.js) calls in production. It is designed to be
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).
11
+ *
12
+ * The render path mirrors the DEV middleware's `handleRenderRoute`
13
+ * (server/render-route.js) byte-for-byte in everything hydration can see —
14
+ * the same `renderToReadableStream` engine, the same `#__octane_data` payload
15
+ * (same keys, same order), and the same template-prefix → render-stream →
16
+ * template-suffix assembly — so `hydrateRoot()` adopts a production response
17
+ * exactly as it adopts a dev one. Deliberate differences: the template is the
18
+ * BUILT dist/client/index.html (hashed hydrate script already in place, so
19
+ * nothing is injected per-request), per-route `<link rel=stylesheet/modulepreload>`
20
+ * tags from the client manifest join the head, and render errors produce a
21
+ * plain 500 (no dev stack page). Keep the two files in sync when the shape
22
+ * changes.
23
+ */
24
+
25
+ import { createRouter } from './router.js';
26
+ import { createContext, runMiddlewareChain } from './middleware.js';
27
+ import { handleServerRoute } from './server-route.js';
28
+ import { composeHtmlStream } from './html-stream.js';
29
+ import {
30
+ applyHydrationNonce,
31
+ getContextNonce,
32
+ nonceAttribute,
33
+ splitSsrTemplate,
34
+ validateSsrTemplate,
35
+ } from './html-template.js';
36
+ import {
37
+ createLayoutWrapper,
38
+ createPropsWrapper,
39
+ createRootBoundaryWrapper,
40
+ } from './component-wrappers.js';
41
+ export {
42
+ createLayoutWrapper,
43
+ createPropsWrapper,
44
+ createRootBoundaryWrapper,
45
+ } from './component-wrappers.js';
46
+ import {
47
+ get_component_export,
48
+ get_route_entry_export_name,
49
+ get_route_entry_path,
50
+ } from '../routes.js';
51
+ import {
52
+ patch_global_fetch,
53
+ build_rpc_lookup,
54
+ is_rpc_request,
55
+ handle_rpc_request,
56
+ } from '@ripple-ts/adapter/rpc';
57
+
58
+ export { resolveOctaneConfig } from '../resolve-config.js';
59
+
60
+ // A server integration can reload its compiled manifest repeatedly while the
61
+ // process (and global fetch) stays alive. Ripple's fetch patch is deliberately
62
+ // idempotent, so calling it again cannot replace its closed-over handler or
63
+ // async context. Keep one process-wide dispatcher instead: every new
64
+ // createHandler() updates the target while reusing the context captured by the
65
+ // first patch. Symbol.for makes this survive app-core being bundled/evaluated
66
+ // again by a dev server.
67
+ const FETCH_COORDINATOR_KEY = Symbol.for('octane.app-core.fetch-coordinator');
68
+
69
+ /**
70
+ * @typedef {Object} FetchCoordinator
71
+ * @property {import('@ripple-ts/adapter/rpc').AsyncContext} asyncContext
72
+ * @property {((request: Request) => Promise<Response>) | null} handler
73
+ */
74
+
75
+ /**
76
+ * @param {import('@ripple-ts/adapter').RuntimePrimitives | undefined} runtime
77
+ * @returns {FetchCoordinator | null}
78
+ */
79
+ function getFetchCoordinator(runtime) {
80
+ if (!runtime) return null;
81
+ const globals =
82
+ /** @type {typeof globalThis & { [FETCH_COORDINATOR_KEY]?: FetchCoordinator }} */ (globalThis);
83
+ let coordinator = globals[FETCH_COORDINATOR_KEY];
84
+ if (coordinator) return coordinator;
85
+
86
+ const asyncContext = runtime.createAsyncContext();
87
+ coordinator = { asyncContext, handler: null };
88
+ // Publish before installing the dispatcher so another evaluated app-core
89
+ // copy observes the same mutable coordinator.
90
+ globals[FETCH_COORDINATOR_KEY] = coordinator;
91
+ const fetchHandle = patch_global_fetch(asyncContext);
92
+ const shared = coordinator;
93
+ fetchHandle.set_handler((request) => {
94
+ if (!shared.handler) {
95
+ return Promise.resolve(new Response('Octane handler is not ready', { status: 503 }));
96
+ }
97
+ return shared.handler(request);
98
+ });
99
+ return coordinator;
100
+ }
101
+
102
+ /**
103
+ * @typedef {import('@octanejs/app-core').RenderRoute} RenderRoute
104
+ * @typedef {import('@octanejs/app-core').Middleware} Middleware
105
+ * @typedef {import('@octanejs/app-core').Context} Context
106
+ */
107
+ /**
108
+ @import { ServerManifest, HandlerOptions, ClientAssetEntry } from '../../types/production.d.ts'
109
+ */
110
+
111
+ /**
112
+ * Create the production request handler from a manifest.
113
+ *
114
+ * The returned function is a standard Web `fetch`-style handler:
115
+ * `(request: Request) => Promise<Response>` — the generated server entry boots
116
+ * it behind the adapter's `serve()` (or the built-in Node server), and
117
+ * serverless wrappers import it directly.
118
+ *
119
+ * @param {ServerManifest} manifest
120
+ * @param {HandlerOptions} deps
121
+ * @returns {(request: Request) => Promise<Response>}
122
+ */
123
+ export function createHandler(manifest, deps) {
124
+ const { renderToReadableStream, prerender, htmlTemplate, executeServerFunction } = deps;
125
+ const router = createRouter(manifest.routes);
126
+ const globalMiddlewares = manifest.middlewares ?? [];
127
+ const trustProxy = manifest.trustProxy ?? false;
128
+ const runtime = manifest.runtime;
129
+ validateSsrTemplate(htmlTemplate);
130
+ // Also pin the built-template contract up front. The marker is emitted by
131
+ // the integration's HTML transform and survives source hashing.
132
+ applyHydrationNonce(htmlTemplate, null);
133
+
134
+ // RPC lookup for statically imported `module server` functions
135
+ // (compiler hash → server function).
136
+ const rpcLookup =
137
+ manifest.rpcModules && runtime ? build_rpc_lookup(manifest.rpcModules, runtime.hash) : null;
138
+
139
+ // Request-scoped async context + same-origin fetch short-circuit: fetch()
140
+ // during SSR that resolves to this origin routes through the handler
141
+ // in-process instead of a network round-trip.
142
+ const fetchCoordinator = getFetchCoordinator(runtime);
143
+ const asyncContext = fetchCoordinator?.asyncContext;
144
+
145
+ const handler = async function handler(/** @type {Request} */ request) {
146
+ const url = new URL(request.url);
147
+ const method = request.method;
148
+
149
+ if (is_rpc_request(url.pathname)) {
150
+ if (!rpcLookup || !asyncContext) {
151
+ return new Response(JSON.stringify({ error: 'RPC is not configured' }), {
152
+ status: 404,
153
+ headers: { 'Content-Type': 'application/json' },
154
+ });
155
+ }
156
+ return handle_rpc_request(request, {
157
+ resolveFunction(/** @type {string} */ hash) {
158
+ const entry = rpcLookup.get(hash);
159
+ if (!entry) return null;
160
+ const fn = entry.serverObj[entry.funcName];
161
+ return typeof fn === 'function' ? fn : null;
162
+ },
163
+ executeServerFunction,
164
+ asyncContext,
165
+ trustProxy,
166
+ });
167
+ }
168
+
169
+ const match = router.match(method, url.pathname);
170
+ if (!match) {
171
+ // Static assets never reach here (the static layer — the built-in Node
172
+ // server, or the platform's file serving — runs first); an app with a
173
+ // catch-all RenderRoute matches everything else, so this is only hit
174
+ // when no catch-all exists.
175
+ return new Response('Not Found', { status: 404 });
176
+ }
177
+
178
+ const context = createContext(request, match.params);
179
+
180
+ try {
181
+ if (match.route.type === 'render') {
182
+ return await runMiddlewareChain(
183
+ context,
184
+ globalMiddlewares,
185
+ match.route.before || [],
186
+ async () => renderRoute(/** @type {RenderRoute} */ (match.route), context),
187
+ [],
188
+ );
189
+ }
190
+ return await handleServerRoute(match.route, context, globalMiddlewares);
191
+ } catch (error) {
192
+ console.error('[octane] Request error:', error);
193
+ return new Response('Internal Server Error', { status: 500 });
194
+ }
195
+ };
196
+
197
+ if (fetchCoordinator) fetchCoordinator.handler = handler;
198
+
199
+ /**
200
+ * Render a RenderRoute — the production twin of dev's `handleRenderRoute`.
201
+ *
202
+ * @param {RenderRoute} route
203
+ * @param {Context} context
204
+ * @returns {Promise<Response>}
205
+ */
206
+ async function renderRoute(route, context) {
207
+ const entryPath = get_route_entry_path(route.entry);
208
+ const exportName = get_route_entry_export_name(route.entry);
209
+ const PageComponent = entryPath
210
+ ? get_component_export(manifest.components[entryPath] ?? {}, exportName)
211
+ : null;
212
+ if (!PageComponent) {
213
+ throw new Error(`Component not found for route ${route.path}`);
214
+ }
215
+
216
+ // Identical props to dev: `{ params, url }`, url origin-free so the client
217
+ // re-renders the exact string.
218
+ const requestUrl = context.url.pathname + context.url.search;
219
+ const pageProps = { params: context.params, url: requestUrl, state: context.state };
220
+ const nonce = getContextNonce(context);
221
+
222
+ let RootComponent;
223
+ if (route.layout) {
224
+ const LayoutComponent = get_component_export(manifest.layouts[route.layout] ?? {}, undefined);
225
+ if (!LayoutComponent) {
226
+ throw new Error(`No layout component found for ${route.layout}`);
227
+ }
228
+ RootComponent = createLayoutWrapper(
229
+ /** @type {any} */ (LayoutComponent),
230
+ /** @type {any} */ (PageComponent),
231
+ pageProps,
232
+ );
233
+ } else {
234
+ RootComponent = createPropsWrapper(/** @type {any} */ (PageComponent), pageProps);
235
+ }
236
+ RootComponent = createRootBoundaryWrapper(
237
+ RootComponent,
238
+ {
239
+ pending: manifest.rootBoundary?.pending ?? null,
240
+ catch: manifest.rootBoundary?.catch ?? null,
241
+ },
242
+ deps,
243
+ );
244
+
245
+ // The hydration payload — SAME keys, SAME order as dev render-route.js, so
246
+ // the data script is byte-identical between dev and production.
247
+ const routeData = JSON.stringify({
248
+ entry: entryPath,
249
+ exportName: exportName ?? null,
250
+ layout: route.layout ?? null,
251
+ routeIndex: getRenderRouteIndex(manifest.routes, route),
252
+ params: context.params,
253
+ url: requestUrl,
254
+ preHydrate: manifest.preHydrate ?? null,
255
+ rootBoundary: manifest.rootBoundaryEntries ?? { pending: null, catch: null },
256
+ });
257
+ const dataScript = `<script id="__octane_data" type="application/json"${nonceAttribute(nonce)}>${escapeScript(routeData)}</script>`;
258
+
259
+ // Per-route asset hints from the client manifest: stylesheet links so
260
+ // page CSS applies before hydration, modulepreload so the page chunk
261
+ // downloads in parallel with the hydrate entry (which the template's own
262
+ // script tag already references).
263
+ /** @type {string[]} */
264
+ const preloadTags = [];
265
+ const entryAssets = entryPath ? manifest.clientAssets?.[entryPath] : undefined;
266
+ if (entryAssets) {
267
+ for (const cssFile of entryAssets.css) {
268
+ preloadTags.push(`<link rel="stylesheet" href="/${cssFile}">`);
269
+ }
270
+ if (entryAssets.js) {
271
+ preloadTags.push(`<link rel="modulepreload" href="/${entryAssets.js}">`);
272
+ }
273
+ }
274
+
275
+ const headContent = [...preloadTags, dataScript].join('\n');
276
+ const html = applyHydrationNonce(htmlTemplate, nonce).replace('<!--ssr-head-->', headContent);
277
+
278
+ const status = route.status ?? 200;
279
+ const headers = { 'Content-Type': 'text/html; charset=utf-8' };
280
+
281
+ const [prefix, suffix] = splitSsrTemplate(html);
282
+
283
+ if (manifest.render === 'buffered') {
284
+ // Await-everything fallback (`prerender` from octane/static): no
285
+ // streaming, one document. The deduped scoped-style tags lead the body
286
+ // markup inside #root — the same position they hold in the streamed
287
+ // shell — so hydrateRoot's leading-style skip applies unchanged.
288
+ const { html: body, css } = await prerender(RootComponent, undefined, {
289
+ nonce: nonce ?? undefined,
290
+ signal: context.request.signal,
291
+ onError(/** @type {unknown} */ error) {
292
+ console.error('[octane] SSR render error:', error);
293
+ },
294
+ });
295
+ return new Response(prefix + css + body + suffix, { status, headers });
296
+ }
297
+
298
+ // Streaming (default): shell flushes at first await, suspense segments
299
+ // stream out-of-order behind it — identical to dev.
300
+ /** @type {ReadableStream<Uint8Array>} */
301
+ const renderStream = await renderToReadableStream(RootComponent, undefined, {
302
+ nonce: nonce ?? undefined,
303
+ signal: context.request.signal,
304
+ onError(/** @type {unknown} */ error) {
305
+ console.error('[octane] SSR render error:', error);
306
+ },
307
+ });
308
+
309
+ const body = composeHtmlStream(prefix, renderStream, suffix);
310
+
311
+ return new Response(body, { status, headers });
312
+ }
313
+
314
+ return handler;
315
+ }
316
+
317
+ /**
318
+ * @param {import('@octanejs/app-core').Route[]} routes
319
+ * @param {RenderRoute} route
320
+ * @returns {number | undefined}
321
+ */
322
+ function getRenderRouteIndex(routes, route) {
323
+ const renderRoutes = routes.filter((r) => r.type === 'render');
324
+ const index = renderRoutes.indexOf(route);
325
+ return index === -1 ? undefined : index;
326
+ }
327
+
328
+ /**
329
+ * Escape script content to prevent XSS in the inline JSON data block.
330
+ * @param {string} str
331
+ * @returns {string}
332
+ */
333
+ function escapeScript(str) {
334
+ return str.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
335
+ }
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ /**
3
+ * @typedef {import('@octanejs/app-core').Route} Route
4
+ * @typedef {import('@octanejs/app-core').RenderRoute} RenderRoute
5
+ * @typedef {import('@octanejs/app-core').ServerRoute} ServerRoute
6
+ */
7
+
8
+ /**
9
+ * @typedef {Object} RouteMatch
10
+ * @property {Route} route
11
+ * @property {Record<string, string>} params
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} CompiledRoute
16
+ * @property {Route} route
17
+ * @property {RegExp} pattern
18
+ * @property {string[]} paramNames
19
+ * @property {number} specificity - Higher = more specific (static > param > catch-all)
20
+ */
21
+
22
+ /**
23
+ * Convert a route path pattern to a RegExp
24
+ * Supports:
25
+ * - Static segments: /about, /api/hello
26
+ * - Named params: /posts/:id, /users/:userId/posts/:postId
27
+ * - Catch-all: /docs/*slug
28
+ *
29
+ * @param {string} path
30
+ * @returns {{ pattern: RegExp, paramNames: string[], specificity: number }}
31
+ */
32
+ function compilePath(path) {
33
+ /** @type {string[]} */
34
+ const paramNames = [];
35
+ let specificity = 0;
36
+
37
+ // Escape special regex characters except our param syntax
38
+ const regexString = path
39
+ .split('/')
40
+ .map((segment) => {
41
+ if (!segment) return '';
42
+
43
+ // Catch-all param: *slug
44
+ if (segment.startsWith('*')) {
45
+ const paramName = segment.slice(1);
46
+ paramNames.push(paramName);
47
+ specificity += 1; // Lowest specificity
48
+ return '(.+)';
49
+ }
50
+
51
+ // Named param: :id
52
+ if (segment.startsWith(':')) {
53
+ const paramName = segment.slice(1);
54
+ paramNames.push(paramName);
55
+ specificity += 10; // Medium specificity
56
+ return '([^/]+)';
57
+ }
58
+
59
+ // Static segment
60
+ specificity += 100; // Highest specificity
61
+ return escapeRegex(segment);
62
+ })
63
+ .join('/');
64
+
65
+ const pattern = new RegExp(`^${regexString || '/'}$`);
66
+ return { pattern, paramNames, specificity };
67
+ }
68
+
69
+ /**
70
+ * Escape special regex characters
71
+ * @param {string} str
72
+ * @returns {string}
73
+ */
74
+ function escapeRegex(str) {
75
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
+ }
77
+
78
+ /**
79
+ * Create a router from a list of routes
80
+ * @param {Route[]} routes
81
+ * @returns {{ match: (method: string, pathname: string) => RouteMatch | null }}
82
+ */
83
+ export function createRouter(routes) {
84
+ /** @type {CompiledRoute[]} */
85
+ const compiledRoutes = routes.map((route) => {
86
+ const { pattern, paramNames, specificity } = compilePath(route.path);
87
+ return { route, pattern, paramNames, specificity };
88
+ });
89
+
90
+ // Sort by specificity (higher first) for correct matching order
91
+ compiledRoutes.sort((a, b) => b.specificity - a.specificity);
92
+
93
+ return {
94
+ /**
95
+ * Match a request to a route
96
+ * @param {string} method
97
+ * @param {string} pathname
98
+ * @returns {RouteMatch | null}
99
+ */
100
+ match(method, pathname) {
101
+ for (const { route, pattern, paramNames } of compiledRoutes) {
102
+ // Check method for ServerRoute
103
+ if (route.type === 'server') {
104
+ const methods = /** @type {ServerRoute} */ (route).methods;
105
+ if (!methods.includes(method.toUpperCase())) {
106
+ continue;
107
+ }
108
+ }
109
+
110
+ 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 };
118
+ }
119
+ }
120
+ return null;
121
+ },
122
+ };
123
+ }