@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.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +88 -0
- package/src/codegen.js +297 -0
- package/src/config-loader.js +307 -0
- package/src/config.js +14 -0
- package/src/constants.js +5 -0
- package/src/html.js +10 -0
- package/src/index.js +18 -0
- package/src/middleware.js +3 -0
- package/src/resolve-config.js +177 -0
- package/src/routes.js +135 -0
- package/src/server/component-wrappers.js +99 -0
- package/src/server/html-stream.js +74 -0
- package/src/server/html-template.js +136 -0
- package/src/server/middleware.js +127 -0
- package/src/server/node-http.js +262 -0
- package/src/server/production.js +335 -0
- package/src/server/router.js +123 -0
- package/src/server/server-entry.js +436 -0
- package/src/server/server-route.js +47 -0
- package/types/codegen.d.ts +67 -0
- package/types/config-loader.d.ts +26 -0
- package/types/config.d.ts +26 -0
- package/types/html.d.ts +14 -0
- package/types/index.d.ts +319 -0
- package/types/middleware.d.ts +3 -0
- package/types/node.d.ts +31 -0
- package/types/production.d.ts +98 -0
- package/types/routes.d.ts +17 -0
|
@@ -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
|
+
}
|