@jk2908/solas 0.1.0
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 +333 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +219 -0
- package/dist/error-boundary.d.ts +1 -0
- package/dist/error-boundary.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +235 -0
- package/dist/internal/build.d.ts +104 -0
- package/dist/internal/build.js +633 -0
- package/dist/internal/codegen/config.d.ts +5 -0
- package/dist/internal/codegen/config.js +19 -0
- package/dist/internal/codegen/environments.d.ts +12 -0
- package/dist/internal/codegen/environments.js +42 -0
- package/dist/internal/codegen/manifest.d.ts +5 -0
- package/dist/internal/codegen/manifest.js +15 -0
- package/dist/internal/codegen/maps.d.ts +5 -0
- package/dist/internal/codegen/maps.js +75 -0
- package/dist/internal/codegen/utils.d.ts +1 -0
- package/dist/internal/codegen/utils.js +2 -0
- package/dist/internal/env/browser.d.ts +4 -0
- package/dist/internal/env/browser.js +58 -0
- package/dist/internal/env/request-context.d.ts +19 -0
- package/dist/internal/env/request-context.js +2 -0
- package/dist/internal/env/rsc.d.ts +39 -0
- package/dist/internal/env/rsc.js +368 -0
- package/dist/internal/env/ssr.d.ts +42 -0
- package/dist/internal/env/ssr.js +149 -0
- package/dist/internal/env/utils.d.ts +2 -0
- package/dist/internal/env/utils.js +28 -0
- package/dist/internal/metadata.d.ts +81 -0
- package/dist/internal/metadata.js +185 -0
- package/dist/internal/navigation/http-exception-boundary.d.ts +12 -0
- package/dist/internal/navigation/http-exception-boundary.js +48 -0
- package/dist/internal/navigation/http-exception.d.ts +33 -0
- package/dist/internal/navigation/http-exception.js +45 -0
- package/dist/internal/navigation/link.d.ts +13 -0
- package/dist/internal/navigation/link.js +63 -0
- package/dist/internal/navigation/redirect-boundary.d.ts +12 -0
- package/dist/internal/navigation/redirect-boundary.js +39 -0
- package/dist/internal/navigation/redirect.d.ts +21 -0
- package/dist/internal/navigation/redirect.js +63 -0
- package/dist/internal/navigation/use-search-params.d.ts +1 -0
- package/dist/internal/navigation/use-search-params.js +13 -0
- package/dist/internal/prerender.d.ts +151 -0
- package/dist/internal/prerender.js +422 -0
- package/dist/internal/render/head.d.ts +4 -0
- package/dist/internal/render/head.js +38 -0
- package/dist/internal/render/tree.d.ts +47 -0
- package/dist/internal/render/tree.js +108 -0
- package/dist/internal/router/create-router.d.ts +6 -0
- package/dist/internal/router/create-router.js +95 -0
- package/dist/internal/router/pattern.d.ts +8 -0
- package/dist/internal/router/pattern.js +31 -0
- package/dist/internal/router/prefetcher.d.ts +47 -0
- package/dist/internal/router/prefetcher.js +90 -0
- package/dist/internal/router/resolver.d.ts +174 -0
- package/dist/internal/router/resolver.js +356 -0
- package/dist/internal/router/router-context.d.ts +11 -0
- package/dist/internal/router/router-context.js +7 -0
- package/dist/internal/router/router-provider.d.ts +6 -0
- package/dist/internal/router/router-provider.js +131 -0
- package/dist/internal/router/router.d.ts +79 -0
- package/dist/internal/router/router.js +417 -0
- package/dist/internal/router/use-router.d.ts +5 -0
- package/dist/internal/router/use-router.js +5 -0
- package/dist/internal/server/cookies.d.ts +6 -0
- package/dist/internal/server/cookies.js +17 -0
- package/dist/internal/server/dynamic.d.ts +9 -0
- package/dist/internal/server/dynamic.js +22 -0
- package/dist/internal/server/headers.d.ts +5 -0
- package/dist/internal/server/headers.js +19 -0
- package/dist/internal/server/url.d.ts +5 -0
- package/dist/internal/server/url.js +16 -0
- package/dist/internal/ui/defaults/error.d.ts +4 -0
- package/dist/internal/ui/defaults/error.js +6 -0
- package/dist/internal/ui/error-boundary.d.ts +26 -0
- package/dist/internal/ui/error-boundary.js +41 -0
- package/dist/navigation.d.ts +6 -0
- package/dist/navigation.js +6 -0
- package/dist/prerender.d.ts +1 -0
- package/dist/prerender.js +1 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.js +4 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +4 -0
- package/dist/solas.d.ts +32 -0
- package/dist/solas.js +125 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +1 -0
- package/dist/utils/compress.d.ts +11 -0
- package/dist/utils/compress.js +76 -0
- package/dist/utils/context.d.ts +6 -0
- package/dist/utils/context.js +25 -0
- package/dist/utils/cookies.d.ts +3 -0
- package/dist/utils/cookies.js +35 -0
- package/dist/utils/export-reader.d.ts +29 -0
- package/dist/utils/export-reader.js +117 -0
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +72 -0
- package/dist/utils/logger.d.ts +52 -0
- package/dist/utils/logger.js +105 -0
- package/dist/utils/time.d.ts +4 -0
- package/dist/utils/time.js +29 -0
- package/package.json +111 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { match as createMatch } from 'path-to-regexp';
|
|
3
|
+
import { Solas } from '../../solas';
|
|
4
|
+
import { maybeActionWithParsedFormData } from '../env/rsc';
|
|
5
|
+
import { HttpException } from '../navigation/http-exception';
|
|
6
|
+
import { toPathPattern } from './pattern';
|
|
7
|
+
/**
|
|
8
|
+
* Handle routing and matching for server requests
|
|
9
|
+
*/
|
|
10
|
+
export class Router {
|
|
11
|
+
opts;
|
|
12
|
+
static #matchers = new WeakMap();
|
|
13
|
+
#routes = {
|
|
14
|
+
// exact match by method + path
|
|
15
|
+
static: new Map(),
|
|
16
|
+
dynamic: {
|
|
17
|
+
// candidate routes bucketed by segment length
|
|
18
|
+
byLength: new Map(),
|
|
19
|
+
// fast path for static prefixes
|
|
20
|
+
byPrefix: new Map(),
|
|
21
|
+
},
|
|
22
|
+
// wildcard routes checked last, narrowed by first literal segment when possible
|
|
23
|
+
wildcard: {
|
|
24
|
+
byPrefix: new Map(),
|
|
25
|
+
fallback: [],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
#middleware = { global: [] };
|
|
29
|
+
#onError;
|
|
30
|
+
constructor(opts = {}) {
|
|
31
|
+
this.opts = opts;
|
|
32
|
+
this.fetch = this.fetch.bind(this);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Register middleware for all routes
|
|
36
|
+
*/
|
|
37
|
+
use(...middleware) {
|
|
38
|
+
this.#middleware.global.push(...middleware);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Register an error handler for routing failures
|
|
43
|
+
*/
|
|
44
|
+
error(handler) {
|
|
45
|
+
this.#onError = handler;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Register a route handler
|
|
50
|
+
*/
|
|
51
|
+
add(path, method, handler, params, middleware = []) {
|
|
52
|
+
const segments = Router.#split(path);
|
|
53
|
+
const tokens = [];
|
|
54
|
+
let score = 0;
|
|
55
|
+
let wildcard = false;
|
|
56
|
+
// turn the route path into tokens once so registration and matching can
|
|
57
|
+
// share the same specificity rules
|
|
58
|
+
for (const segment of segments) {
|
|
59
|
+
if (segment === '*') {
|
|
60
|
+
wildcard = true;
|
|
61
|
+
tokens.push({ kind: 'wildcard', value: params?.[0] ?? '*' });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (segment.startsWith(':')) {
|
|
65
|
+
tokens.push({ kind: 'dynamic', value: segment.slice(1) });
|
|
66
|
+
score += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
tokens.push({ kind: 'static', value: segment });
|
|
70
|
+
score += 2;
|
|
71
|
+
}
|
|
72
|
+
const route = {
|
|
73
|
+
path,
|
|
74
|
+
method: method.toUpperCase(),
|
|
75
|
+
handler,
|
|
76
|
+
middleware: [...middleware],
|
|
77
|
+
tokens,
|
|
78
|
+
length: segments.length,
|
|
79
|
+
score,
|
|
80
|
+
wildcard,
|
|
81
|
+
};
|
|
82
|
+
// static route, easy map set
|
|
83
|
+
if (!path.includes(':') && !path.includes('*')) {
|
|
84
|
+
this.#routes.static.set(`${route.method}:${path}`, route);
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
// wildcard route, push to end of list
|
|
88
|
+
if (wildcard) {
|
|
89
|
+
const prefix = route.tokens[0]?.kind === 'static' ? route.tokens[0].value : undefined;
|
|
90
|
+
if (prefix) {
|
|
91
|
+
const prefixed = this.#routes.wildcard.byPrefix.get(prefix) ?? [];
|
|
92
|
+
prefixed.push(route);
|
|
93
|
+
this.#routes.wildcard.byPrefix.set(prefix, prefixed);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.#routes.wildcard.fallback.push(route);
|
|
97
|
+
}
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
// dynamic routes are looked up through two indexes; one grouped
|
|
101
|
+
// by segment count, and one grouped by the first static segment
|
|
102
|
+
const bucket = this.#routes.dynamic.byLength.get(route.length) ?? [];
|
|
103
|
+
bucket.push(route);
|
|
104
|
+
this.#routes.dynamic.byLength.set(route.length, bucket);
|
|
105
|
+
// only routes that start with a literal segment go into the prefix index.
|
|
106
|
+
// Routes that start dynamically still fall back to the length-based
|
|
107
|
+
// lookup, so this shortcut doesn't accidentally skip a better match
|
|
108
|
+
const prefix = route.tokens[0]?.kind === 'static' ? route.tokens[0].value : undefined;
|
|
109
|
+
if (prefix) {
|
|
110
|
+
const prefixed = this.#routes.dynamic.byPrefix.get(prefix) ?? [];
|
|
111
|
+
prefixed.push(route);
|
|
112
|
+
this.#routes.dynamic.byPrefix.set(prefix, prefixed);
|
|
113
|
+
}
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Match a path and method, returning params and route
|
|
118
|
+
*/
|
|
119
|
+
match(path, method) {
|
|
120
|
+
const direct = this.#routes.static.get(`${method}:${path}`);
|
|
121
|
+
// direct match - quick return
|
|
122
|
+
if (direct)
|
|
123
|
+
return { route: direct, params: {} };
|
|
124
|
+
// HEAD falls back to GET when HEAD is not explicitly defined
|
|
125
|
+
if (method === 'HEAD') {
|
|
126
|
+
const directGet = this.#routes.static.get(`GET:${path}`);
|
|
127
|
+
if (directGet)
|
|
128
|
+
return { route: directGet, params: {} };
|
|
129
|
+
}
|
|
130
|
+
// else dynamic/wildcard match
|
|
131
|
+
const segments = Router.#split(path);
|
|
132
|
+
// try the leading-static prefix bucket first
|
|
133
|
+
const prefixed = this.#routes.dynamic.byPrefix.get(segments[0] ?? '');
|
|
134
|
+
const prefixedMatch = prefixed ? Router.#pick(prefixed, segments, method) : null;
|
|
135
|
+
if (prefixedMatch)
|
|
136
|
+
return prefixedMatch;
|
|
137
|
+
// if the prefix bucket has no winner, fall back to all dynamic
|
|
138
|
+
// routes with the same segment count
|
|
139
|
+
const dynamicMatch = Router.#pick(this.#routes.dynamic.byLength.get(segments.length) ?? [], segments, method);
|
|
140
|
+
if (dynamicMatch)
|
|
141
|
+
return dynamicMatch;
|
|
142
|
+
// finally check wildcard routes, prefixed first, then fully generic ones
|
|
143
|
+
const wildcardPrefixed = this.#routes.wildcard.byPrefix.get(segments[0] ?? '');
|
|
144
|
+
const wildcardMatch = wildcardPrefixed
|
|
145
|
+
? Router.#pick(wildcardPrefixed, segments, method)
|
|
146
|
+
: null;
|
|
147
|
+
if (wildcardMatch)
|
|
148
|
+
return wildcardMatch;
|
|
149
|
+
const wildcardFallbackMatch = Router.#pick(this.#routes.wildcard.fallback, segments, method);
|
|
150
|
+
if (wildcardFallbackMatch)
|
|
151
|
+
return wildcardFallbackMatch;
|
|
152
|
+
// no match
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Handle an incoming request
|
|
157
|
+
*/
|
|
158
|
+
async fetch(req) {
|
|
159
|
+
const url = new URL(req.url);
|
|
160
|
+
const path = Router.#normalise(url.pathname, this.opts.trailingSlash);
|
|
161
|
+
let match = null;
|
|
162
|
+
let action = false;
|
|
163
|
+
try {
|
|
164
|
+
if (path !== url.pathname) {
|
|
165
|
+
// rebuild the request with the canonical pathname so downstream code
|
|
166
|
+
// sees the same url the router matched against
|
|
167
|
+
url.pathname = path;
|
|
168
|
+
req = new Request(url.toString(), req);
|
|
169
|
+
}
|
|
170
|
+
const { action: isAction, formData: parsedFormData } = await maybeActionWithParsedFormData(req);
|
|
171
|
+
action = isAction;
|
|
172
|
+
const method = req.method.toUpperCase();
|
|
173
|
+
// action requests stay on the same pathname only the method is
|
|
174
|
+
// normalised to GET this lets page/layout routes match for
|
|
175
|
+
// rerender action execution still reads POST body and
|
|
176
|
+
// may redirect()
|
|
177
|
+
match = this.match(path, action ? 'GET' : method);
|
|
178
|
+
if (!match) {
|
|
179
|
+
const error = new HttpException(404, 'Not found');
|
|
180
|
+
// unmatched requests still pass through the shared error hook with the
|
|
181
|
+
// same request metadata shape as matched requests
|
|
182
|
+
return (this.#onError?.(error, Object.assign(req, {
|
|
183
|
+
[Solas.Config.REQUEST_META]: { match: null, error, action },
|
|
184
|
+
})) ?? new Response(error.message, { status: error.status }));
|
|
185
|
+
}
|
|
186
|
+
const matched = match;
|
|
187
|
+
// attach routing state to the request once so middleware and handlers can
|
|
188
|
+
// read the same per-request metadata
|
|
189
|
+
const request = Object.assign(req, {
|
|
190
|
+
[Solas.Config.REQUEST_META]: { match: matched, action, parsedFormData },
|
|
191
|
+
});
|
|
192
|
+
// global middleware stays outside route middleware by preserving
|
|
193
|
+
// registration order here before composition in #run
|
|
194
|
+
const stack = [...this.#middleware.global, ...matched.route.middleware];
|
|
195
|
+
return this.#run(stack, request, () => matched.route.handler?.(request) ?? new Response('Not found', { status: 404 }));
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
// normalise unknown throwables so the error hook always receives an Error
|
|
199
|
+
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
|
|
200
|
+
const request = Object.assign(req, {
|
|
201
|
+
[Solas.Config.REQUEST_META]: { match, error, action },
|
|
202
|
+
});
|
|
203
|
+
if (this.#onError)
|
|
204
|
+
return this.#onError(error, request);
|
|
205
|
+
if (error instanceof HttpException) {
|
|
206
|
+
return new Response(error.message, { status: error.status });
|
|
207
|
+
}
|
|
208
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Run middleware stack
|
|
213
|
+
*/
|
|
214
|
+
#run(stack, req, next) {
|
|
215
|
+
// compose middleware stack
|
|
216
|
+
let run = () => Promise.resolve(next());
|
|
217
|
+
// unwind stack
|
|
218
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
219
|
+
const handler = stack[i];
|
|
220
|
+
const prev = run;
|
|
221
|
+
run = () => Promise.resolve(handler(req, prev));
|
|
222
|
+
}
|
|
223
|
+
// run composed middleware stack
|
|
224
|
+
return run();
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Serve static assets from the output directory
|
|
228
|
+
* @note generated /assets/* handlers bypass +middleware conventions
|
|
229
|
+
*/
|
|
230
|
+
static static(config) {
|
|
231
|
+
return async (req) => {
|
|
232
|
+
const pathname = new URL(req.url).pathname;
|
|
233
|
+
const outDir = path.resolve(Solas.Config.OUT_DIR);
|
|
234
|
+
const staticRoot = path.resolve(outDir, 'client');
|
|
235
|
+
let decodedPathname = pathname;
|
|
236
|
+
try {
|
|
237
|
+
// validate any percent-encoding before resolving the asset path
|
|
238
|
+
decodedPathname = decodeURIComponent(pathname);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return new Response('Bad Request', { status: 400 });
|
|
242
|
+
}
|
|
243
|
+
const relativePath = decodedPathname.replace(/^\/+/, '');
|
|
244
|
+
const filePath = path.resolve(staticRoot, relativePath);
|
|
245
|
+
// keep asset requests pinned under the client output root even if the
|
|
246
|
+
// incoming path contains traversal segments
|
|
247
|
+
if (filePath !== staticRoot && !filePath.startsWith(`${staticRoot}${path.sep}`)) {
|
|
248
|
+
return new Response('Forbidden', { status: 403 });
|
|
249
|
+
}
|
|
250
|
+
// emitted assets are fingerprinted so they can be cached aggressively
|
|
251
|
+
return Router.serve(filePath, req, config.precompress, {
|
|
252
|
+
'Cache-Control': 'public, immutable, max-age=31536000',
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Serve a file with optional compression content negotiation
|
|
258
|
+
*/
|
|
259
|
+
static async serve(filePath, req, precompress = false, headers = {}) {
|
|
260
|
+
const accept = req.headers.get('accept-encoding') ?? '';
|
|
261
|
+
let file = Bun.file(filePath);
|
|
262
|
+
let encoding = null;
|
|
263
|
+
if (precompress) {
|
|
264
|
+
// prefer a precompressed variant when the client accepts it and one was emitted
|
|
265
|
+
if (accept.includes('br')) {
|
|
266
|
+
const brotli = Bun.file(`${filePath}.br`);
|
|
267
|
+
if (await brotli.exists()) {
|
|
268
|
+
file = brotli;
|
|
269
|
+
encoding = 'br';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!(await file.exists())) {
|
|
274
|
+
return new Response('Not found', { status: 404 });
|
|
275
|
+
}
|
|
276
|
+
// get mime type from original path, not compressed variant
|
|
277
|
+
const mimeType = Bun.file(filePath).type;
|
|
278
|
+
const res = new Response(file, {
|
|
279
|
+
headers: {
|
|
280
|
+
'Content-Type': headers['Content-Type'] ?? mimeType,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
284
|
+
res.headers.set(key, value);
|
|
285
|
+
}
|
|
286
|
+
if (precompress)
|
|
287
|
+
res.headers.set('Vary', 'Accept-Encoding');
|
|
288
|
+
if (encoding)
|
|
289
|
+
res.headers.set('Content-Encoding', encoding);
|
|
290
|
+
return res;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Normalise a path based on router options
|
|
294
|
+
*/
|
|
295
|
+
static #normalise(path, trailingSlash = true) {
|
|
296
|
+
if (!trailingSlash) {
|
|
297
|
+
// collapse non-root trailing slashes when the router runs in slashless mode
|
|
298
|
+
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
|
299
|
+
}
|
|
300
|
+
if (path === '/')
|
|
301
|
+
return path;
|
|
302
|
+
// otherwise make non-root paths canonical with a trailing slash
|
|
303
|
+
return path.endsWith('/') ? path : `${path}/`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Split a path into segments
|
|
307
|
+
*/
|
|
308
|
+
static #split(path) {
|
|
309
|
+
if (path === '/')
|
|
310
|
+
return [];
|
|
311
|
+
const parts = [];
|
|
312
|
+
let start = 0;
|
|
313
|
+
// walk the string once so we avoid empty segments from repeated or edge slashes
|
|
314
|
+
for (let i = 0; i <= path.length; i += 1) {
|
|
315
|
+
const char = path[i];
|
|
316
|
+
if (char !== '/' && i !== path.length)
|
|
317
|
+
continue;
|
|
318
|
+
if (i > start) {
|
|
319
|
+
parts.push(path.slice(start, i));
|
|
320
|
+
}
|
|
321
|
+
start = i + 1;
|
|
322
|
+
}
|
|
323
|
+
return parts;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Get or create a path matcher for a route using path-to-regexp
|
|
327
|
+
*/
|
|
328
|
+
static #getMatcher(route) {
|
|
329
|
+
const cached = Router.#matchers.get(route);
|
|
330
|
+
if (cached)
|
|
331
|
+
return cached;
|
|
332
|
+
// convert route tokens back into a path pattern for path-to-regexp to compile
|
|
333
|
+
const { path } = toPathPattern(route.path, route.tokens.filter(token => token.kind !== 'static').map(token => token.value));
|
|
334
|
+
// create a matcher function for this route and cache it
|
|
335
|
+
const matcher = createMatch(path, {
|
|
336
|
+
decode: false,
|
|
337
|
+
});
|
|
338
|
+
Router.#matchers.set(route, matcher);
|
|
339
|
+
return matcher;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Rank token kinds so more specific segments win before broader ones
|
|
343
|
+
*/
|
|
344
|
+
static #getTokenRank(token) {
|
|
345
|
+
if (!token)
|
|
346
|
+
return -1;
|
|
347
|
+
if (token.kind === 'static')
|
|
348
|
+
return 2;
|
|
349
|
+
if (token.kind === 'dynamic')
|
|
350
|
+
return 1;
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Compare two routes and prefer the one with the more specific segment pattern
|
|
355
|
+
*/
|
|
356
|
+
static #compare(a, b) {
|
|
357
|
+
const length = Math.max(a.tokens.length, b.tokens.length);
|
|
358
|
+
for (let index = 0; index < length; index += 1) {
|
|
359
|
+
// prefer static over dynamic and dynamic over wildcard at the
|
|
360
|
+
// first segment position where the two routes differ
|
|
361
|
+
const diff = Router.#getTokenRank(a.tokens[index]) - Router.#getTokenRank(b.tokens[index]);
|
|
362
|
+
if (diff !== 0)
|
|
363
|
+
return diff;
|
|
364
|
+
}
|
|
365
|
+
// if the token kinds line up, reuse the old coarse score
|
|
366
|
+
if (a.score !== b.score)
|
|
367
|
+
return a.score - b.score;
|
|
368
|
+
// final stable tie-break for routes with the same pattern shape
|
|
369
|
+
// sort alphabetically by path string
|
|
370
|
+
return a.path < b.path ? 1 : a.path > b.path ? -1 : 0;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Find the best matching route from a candidate list using explicit specificity rules
|
|
374
|
+
*/
|
|
375
|
+
static #pick(routes, segments, method) {
|
|
376
|
+
let best = null;
|
|
377
|
+
let bestParams = null;
|
|
378
|
+
for (const route of routes) {
|
|
379
|
+
// HEAD can reuse GET routes when HEAD is not registered explicitly
|
|
380
|
+
if (route.method !== method && !(method === 'HEAD' && route.method === 'GET')) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// skip routes that do not fit this path. Only compare specificity
|
|
384
|
+
// across matched routes
|
|
385
|
+
const params = Router.#fit(route, segments);
|
|
386
|
+
if (!params)
|
|
387
|
+
continue;
|
|
388
|
+
// replace the winner only when this route is strictly more specific
|
|
389
|
+
if (!best || Router.#compare(route, best) > 0) {
|
|
390
|
+
best = route;
|
|
391
|
+
bestParams = params;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!best)
|
|
395
|
+
return null;
|
|
396
|
+
return { route: best, params: bestParams ?? {} };
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Fit a route against path segments
|
|
400
|
+
*/
|
|
401
|
+
static #fit(route, segments) {
|
|
402
|
+
if (route.wildcard) {
|
|
403
|
+
// wildcard routes only require the fixed prefix before the catch-all segment
|
|
404
|
+
if (segments.length < route.length - 1)
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
else if (route.length !== segments.length) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
// defer the actual param extraction to the cached path-to-regexp matcher so
|
|
411
|
+
// dynamic and wildcard params stay consistent with registration
|
|
412
|
+
const matched = Router.#getMatcher(route)(segments.length ? `/${segments.join('/')}` : '/');
|
|
413
|
+
if (!matched)
|
|
414
|
+
return null;
|
|
415
|
+
return matched.params;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Cookies } from '../../utils/cookies';
|
|
2
|
+
import { RequestContext } from '../env/request-context';
|
|
3
|
+
import { dynamic } from './dynamic';
|
|
4
|
+
/**
|
|
5
|
+
* Get the request cookies as a Cookies instance
|
|
6
|
+
* @returns a read-only Cookies instance containing the request cookies
|
|
7
|
+
*/
|
|
8
|
+
export function cookies() {
|
|
9
|
+
dynamic();
|
|
10
|
+
const { req, cache } = RequestContext.use();
|
|
11
|
+
// use request cache if possible to avoid reparsing
|
|
12
|
+
if (cache.cookies)
|
|
13
|
+
return cache.cookies;
|
|
14
|
+
const parsed = Cookies.parse(req.headers.get('cookie'));
|
|
15
|
+
cache.cookies = parsed;
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declaratively mark render below this call as request-time only
|
|
3
|
+
* @description in prerender mode this suspends forever so the nearest Suspense
|
|
4
|
+
* boundary renders its fallback into the static shell. In request mode this
|
|
5
|
+
* resolves immediately
|
|
6
|
+
* @returns void during normal requests or prerender not in ppr mode
|
|
7
|
+
* @throws if called in prerender mode (the desired effect)
|
|
8
|
+
*/
|
|
9
|
+
export declare function dynamic(): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Logger } from '../../utils/logger';
|
|
2
|
+
import { RequestContext } from '../env/request-context';
|
|
3
|
+
const logger = new Logger();
|
|
4
|
+
const NEVER = new Promise(() => { });
|
|
5
|
+
/**
|
|
6
|
+
* Declaratively mark render below this call as request-time only
|
|
7
|
+
* @description in prerender mode this suspends forever so the nearest Suspense
|
|
8
|
+
* boundary renders its fallback into the static shell. In request mode this
|
|
9
|
+
* resolves immediately
|
|
10
|
+
* @returns void during normal requests or prerender not in ppr mode
|
|
11
|
+
* @throws if called in prerender mode (the desired effect)
|
|
12
|
+
*/
|
|
13
|
+
export function dynamic() {
|
|
14
|
+
const { prerender } = RequestContext.use();
|
|
15
|
+
if (!prerender)
|
|
16
|
+
return;
|
|
17
|
+
if (prerender !== 'ppr') {
|
|
18
|
+
logger.warn('[dynamic]', "dynamic() was called but prerender mode is not 'ppr'. This means the component will be rendered at build time, which may not be what you intended");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw NEVER;
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RequestContext } from '../env/request-context';
|
|
2
|
+
import { dynamic } from './dynamic';
|
|
3
|
+
/**
|
|
4
|
+
* Get the request headers as a read-only map
|
|
5
|
+
* @returns a read-only map of request headers
|
|
6
|
+
*/
|
|
7
|
+
export function headers() {
|
|
8
|
+
dynamic();
|
|
9
|
+
const { req, cache } = RequestContext.use();
|
|
10
|
+
// use request cache if possible to avoid reconstructing the map
|
|
11
|
+
if (cache.headers)
|
|
12
|
+
return cache.headers;
|
|
13
|
+
const map = new Map();
|
|
14
|
+
req.headers.forEach((value, key) => {
|
|
15
|
+
map.set(key, value);
|
|
16
|
+
});
|
|
17
|
+
cache.headers = map;
|
|
18
|
+
return map;
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { RequestContext } from '../env/request-context';
|
|
2
|
+
import { dynamic } from './dynamic';
|
|
3
|
+
/**
|
|
4
|
+
* Get the request url as a URL instance
|
|
5
|
+
* @returns a URL instance containing the request url
|
|
6
|
+
*/
|
|
7
|
+
export function url() {
|
|
8
|
+
dynamic();
|
|
9
|
+
const { req, cache } = RequestContext.use();
|
|
10
|
+
// use request cache if possible
|
|
11
|
+
if (cache.url)
|
|
12
|
+
return cache.url;
|
|
13
|
+
const parsed = new URL(req.url);
|
|
14
|
+
cache.url = parsed;
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export default function Err({ error }) {
|
|
3
|
+
const title = 'status' in error ? `${error.status} - ${error.message}` : error.message;
|
|
4
|
+
return (_jsxs(_Fragment, { children: [
|
|
5
|
+
_jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx("title", { children: title }), _jsx("h1", { children: title }), _jsx("p", { children: error.message }), error?.stack && _jsx("pre", { children: error.stack })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Component } from 'react';
|
|
2
|
+
import type { BoundaryError } from '../../types';
|
|
3
|
+
export type Props = {
|
|
4
|
+
fallback: ((error: BoundaryError, reset: () => void) => React.ReactNode) | React.ReactNode;
|
|
5
|
+
onError?: (error: BoundaryError) => void;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* A component that catches synchronous errors in its child component tree and displays a fallback UI
|
|
10
|
+
* @param props - the props for the component
|
|
11
|
+
* @param props.fallback - the fallback UI to display when an error occurs, can be a function, React node or component
|
|
12
|
+
* @param props.onReset - a callback function to call when the error is reset
|
|
13
|
+
* @param props.children - the child components to render
|
|
14
|
+
* @returns Component
|
|
15
|
+
*/
|
|
16
|
+
export declare class ErrorBoundary extends Component<Props, {
|
|
17
|
+
error: BoundaryError | null;
|
|
18
|
+
}> {
|
|
19
|
+
constructor(props: Props);
|
|
20
|
+
static getDerivedStateFromError(error: Error): {
|
|
21
|
+
error: Error;
|
|
22
|
+
};
|
|
23
|
+
componentDidCatch(error: Error): void;
|
|
24
|
+
reset(onReset?: () => void): void;
|
|
25
|
+
render(): import("react").ReactNode;
|
|
26
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Component } from 'react';
|
|
3
|
+
import { isKnownError } from '../env/utils';
|
|
4
|
+
/**
|
|
5
|
+
* A component that catches synchronous errors in its child component tree and displays a fallback UI
|
|
6
|
+
* @param props - the props for the component
|
|
7
|
+
* @param props.fallback - the fallback UI to display when an error occurs, can be a function, React node or component
|
|
8
|
+
* @param props.onReset - a callback function to call when the error is reset
|
|
9
|
+
* @param props.children - the child components to render
|
|
10
|
+
* @returns Component
|
|
11
|
+
*/
|
|
12
|
+
export class ErrorBoundary extends Component {
|
|
13
|
+
constructor(props) {
|
|
14
|
+
super(props);
|
|
15
|
+
this.state = { error: null };
|
|
16
|
+
this.reset = this.reset.bind(this);
|
|
17
|
+
}
|
|
18
|
+
static getDerivedStateFromError(error) {
|
|
19
|
+
return { error };
|
|
20
|
+
}
|
|
21
|
+
componentDidCatch(error) {
|
|
22
|
+
this.props.onError?.(error);
|
|
23
|
+
}
|
|
24
|
+
reset(onReset) {
|
|
25
|
+
if (this.state.error)
|
|
26
|
+
this.setState({ error: null });
|
|
27
|
+
onReset?.();
|
|
28
|
+
}
|
|
29
|
+
render() {
|
|
30
|
+
const { error } = this.state;
|
|
31
|
+
if (!error)
|
|
32
|
+
return this.props.children;
|
|
33
|
+
// possible framework control-flow error, re-throw to be caught
|
|
34
|
+
// by appropriate HttpException or Redirect boundaries
|
|
35
|
+
if (isKnownError(error))
|
|
36
|
+
throw error;
|
|
37
|
+
return typeof this.props.fallback === 'function'
|
|
38
|
+
? this.props.fallback(error, this.reset)
|
|
39
|
+
: this.props.fallback;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HttpException, abort, isHttpException, } from './internal/navigation/http-exception';
|
|
2
|
+
export { HttpExceptionBoundary } from './internal/navigation/http-exception-boundary';
|
|
3
|
+
export { Link } from './internal/navigation/link';
|
|
4
|
+
export { Redirect, isRedirect, redirect } from './internal/navigation/redirect';
|
|
5
|
+
export { RedirectBoundary } from './internal/navigation/redirect-boundary';
|
|
6
|
+
export { useSearchParams } from './internal/navigation/use-search-params';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HttpException, abort, isHttpException, } from './internal/navigation/http-exception';
|
|
2
|
+
export { HttpExceptionBoundary } from './internal/navigation/http-exception-boundary';
|
|
3
|
+
export { Link } from './internal/navigation/link';
|
|
4
|
+
export { Redirect, isRedirect, redirect } from './internal/navigation/redirect';
|
|
5
|
+
export { RedirectBoundary } from './internal/navigation/redirect-boundary';
|
|
6
|
+
export { useSearchParams } from './internal/navigation/use-search-params';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Prerender } from './internal/prerender';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Prerender } from './internal/prerender';
|
package/dist/router.d.ts
ADDED
package/dist/router.js
ADDED
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED