@reckona/mreact-router 0.0.65 → 0.0.67

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.
Files changed (59) hide show
  1. package/package.json +13 -12
  2. package/src/actions.ts +1130 -0
  3. package/src/adapters/aws-lambda.ts +993 -0
  4. package/src/adapters/cloudflare.ts +1286 -0
  5. package/src/adapters/devtools.ts +5 -0
  6. package/src/adapters/edge.ts +70 -0
  7. package/src/adapters/node.ts +126 -0
  8. package/src/adapters/static.ts +61 -0
  9. package/src/app-router-globals.ts +19 -0
  10. package/src/assets.ts +113 -0
  11. package/src/build.ts +2948 -0
  12. package/src/bundle-pipeline.ts +496 -0
  13. package/src/cache-config.ts +35 -0
  14. package/src/cache-stats.ts +54 -0
  15. package/src/cache.ts +418 -0
  16. package/src/cli-options.ts +296 -0
  17. package/src/cli.ts +94 -0
  18. package/src/client.ts +3398 -0
  19. package/src/config.ts +146 -0
  20. package/src/cookies.ts +113 -0
  21. package/src/csp.ts +103 -0
  22. package/src/csrf.ts +132 -0
  23. package/src/deferred.ts +52 -0
  24. package/src/dev-server.ts +262 -0
  25. package/src/file-conventions.ts +88 -0
  26. package/src/http.ts +128 -0
  27. package/src/i18n.ts +98 -0
  28. package/src/import-policy.ts +261 -0
  29. package/src/index.ts +221 -0
  30. package/src/link.ts +47 -0
  31. package/src/logger.ts +149 -0
  32. package/src/module-runner.ts +554 -0
  33. package/src/multipart.ts +577 -0
  34. package/src/native-escape.ts +53 -0
  35. package/src/native-route-matcher.ts +173 -0
  36. package/src/navigation-state.ts +98 -0
  37. package/src/navigation.ts +215 -0
  38. package/src/prerender-store.ts +233 -0
  39. package/src/render.ts +5187 -0
  40. package/src/route-path.ts +5 -0
  41. package/src/route-shells.ts +47 -0
  42. package/src/route-source.ts +224 -0
  43. package/src/route-styles.ts +91 -0
  44. package/src/routes.ts +362 -0
  45. package/src/runtime-cache.ts +8 -0
  46. package/src/runtime-state.ts +37 -0
  47. package/src/security-headers.ts +134 -0
  48. package/src/serve.ts +1265 -0
  49. package/src/session.ts +238 -0
  50. package/src/source-jsx.ts +30 -0
  51. package/src/source-modules.ts +69 -0
  52. package/src/stream-list.ts +45 -0
  53. package/src/trace.ts +114 -0
  54. package/src/types.ts +155 -0
  55. package/src/upgrade.ts +8 -0
  56. package/src/vite-config.ts +63 -0
  57. package/src/vite-plugin-cache-key.ts +53 -0
  58. package/src/vite.ts +690 -0
  59. package/src/workspace-packages.ts +67 -0
@@ -0,0 +1,262 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import type { ServerResponse } from "node:http";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { createServer as createViteServer, type UserConfig, type ViteDevServer } from "vite";
6
+ import type { AppRouterServerActionOptions } from "./actions.js";
7
+ import { createMemoryRouteCache, type AppRouterCache } from "./cache.js";
8
+ import { resolveAppRouterProjectOptions, type AppRouterProjectOptions } from "./config.js";
9
+ import type { AppRouterImportPolicy } from "./import-policy.js";
10
+ import { createAppRouterVitePlugin } from "./vite.js";
11
+ import {
12
+ loadMreactRouterViteConfigDetails,
13
+ type LoadedMreactRouterViteConfig,
14
+ } from "./vite-config.js";
15
+ import {
16
+ emitRouterLog,
17
+ logDurationMs,
18
+ logNow,
19
+ nodeRequestPath,
20
+ type AppRouterLogger,
21
+ } from "./logger.js";
22
+ import type { HttpUpgradeHandler } from "./upgrade.js";
23
+
24
+ export interface StartDevServerOptions extends AppRouterProjectOptions {
25
+ port?: number | undefined;
26
+ hostname?: string;
27
+ importPolicy?: AppRouterImportPolicy | undefined;
28
+ logger?: AppRouterLogger | undefined;
29
+ routeCache?: AppRouterCache | undefined;
30
+ serverActions?: AppRouterServerActionOptions | undefined;
31
+ viteConfig?: UserConfig | undefined;
32
+ onUpgrade?: HttpUpgradeHandler | undefined;
33
+ }
34
+
35
+ export async function startDevServer(
36
+ options: StartDevServerOptions,
37
+ ): Promise<{ close(): Promise<void>; server: Server; url: string }> {
38
+ const hostname = options.hostname ?? "127.0.0.1";
39
+ const resolved = await resolveStartDevServerProject(options);
40
+ const project = resolved.project;
41
+ const userViteConfig = options.viteConfig ?? resolved.viteConfig ?? {};
42
+ const userViteServerConfig =
43
+ typeof userViteConfig.server === "object" ? userViteConfig.server : {};
44
+ const userHmrConfig =
45
+ typeof userViteServerConfig.hmr === "object" ? userViteServerConfig.hmr : {};
46
+ const port = options.port ?? resolved.serverPort ?? 3001;
47
+ const routeCache = options.routeCache ?? createMemoryRouteCache();
48
+ const declaredPackages = await readDeclaredProjectPackages(project.projectRoot);
49
+ const importPolicy: AppRouterImportPolicy = {
50
+ ...options.importPolicy,
51
+ allowedPackages: [
52
+ ...new Set([...declaredPackages, ...(options.importPolicy?.allowedPackages ?? [])]),
53
+ ],
54
+ };
55
+ let vite: ViteDevServer | undefined;
56
+ const server = createServer((incoming, outgoing) => {
57
+ const logger = options.logger;
58
+ const startedAt = logger === undefined ? undefined : logNow();
59
+ const logFields =
60
+ logger === undefined
61
+ ? undefined
62
+ : {
63
+ method: incoming.method ?? "GET",
64
+ path: nodeRequestPath(incoming.url),
65
+ runtime: "node" as const,
66
+ };
67
+
68
+ if (logger !== undefined && logFields !== undefined && startedAt !== undefined) {
69
+ emitRouterLog(logger, "info", {
70
+ ...logFields,
71
+ type: "router:request:start",
72
+ });
73
+ outgoing.once("finish", () => {
74
+ emitRouterLog(logger, "info", {
75
+ ...logFields,
76
+ durationMs: logDurationMs(startedAt),
77
+ status: outgoing.statusCode,
78
+ type: "router:request:end",
79
+ });
80
+ });
81
+ }
82
+
83
+ if (vite === undefined) {
84
+ sendDevServerError(outgoing, new Error("Vite dev server is not initialized."));
85
+ return;
86
+ }
87
+
88
+ vite.middlewares(incoming, outgoing, (error?: unknown) => {
89
+ if (error !== undefined) {
90
+ if (error instanceof Error) {
91
+ vite?.ssrFixStacktrace(error);
92
+ }
93
+ sendDevServerError(outgoing, error);
94
+ return;
95
+ }
96
+
97
+ outgoing.statusCode = 404;
98
+ outgoing.setHeader("content-type", "text/plain; charset=utf-8");
99
+ outgoing.end("Not Found");
100
+ });
101
+ });
102
+
103
+ if (options.onUpgrade !== undefined) {
104
+ server.on("upgrade", options.onUpgrade);
105
+ }
106
+
107
+ vite = await createViteServer({
108
+ ...userViteConfig,
109
+ appType: "custom",
110
+ configFile: false,
111
+ root: project.projectRoot,
112
+ server: {
113
+ ...userViteServerConfig,
114
+ hmr: { ...userHmrConfig, server },
115
+ middlewareMode: true,
116
+ },
117
+ plugins: [
118
+ ...(userViteConfig.plugins ?? []),
119
+ createAppRouterVitePlugin({
120
+ allowedSourceDirs: project.allowedSourceDirs,
121
+ projectRoot: project.projectRoot,
122
+ publicDir: project.publicDir,
123
+ routesDir: project.routesDir,
124
+ importPolicy,
125
+ routeCache,
126
+ serverActions: options.serverActions,
127
+ }),
128
+ ],
129
+ });
130
+
131
+ try {
132
+ await listenDevHttpServer(server, port, hostname);
133
+ } catch (error) {
134
+ await vite.close();
135
+ throw error;
136
+ }
137
+ const address = server.address();
138
+ const actualPort = typeof address === "object" && address !== null ? address.port : port;
139
+
140
+ return {
141
+ server,
142
+ url: `http://${hostname}:${actualPort}`,
143
+ close: async () => {
144
+ await vite?.close();
145
+ await new Promise<void>((resolve, reject) => {
146
+ server.close((error) => (error ? reject(error) : resolve()));
147
+ });
148
+ },
149
+ };
150
+ }
151
+
152
+ function listenDevHttpServer(server: Server, port: number, hostname: string): Promise<void> {
153
+ return new Promise((resolve, reject) => {
154
+ const onError = (error: Error) => {
155
+ server.off("listening", onListening);
156
+ reject(formatDevListenError(error, hostname, port));
157
+ };
158
+ const onListening = () => {
159
+ server.off("error", onError);
160
+ resolve();
161
+ };
162
+
163
+ server.once("error", onError);
164
+ server.once("listening", onListening);
165
+ server.listen(port, hostname);
166
+ });
167
+ }
168
+
169
+ function formatDevListenError(error: Error, hostname: string, port: number): Error {
170
+ if (isNodeErrorCode(error, "EADDRINUSE")) {
171
+ return new Error(
172
+ `mreact dev server could not start because ${hostname}:${port} is already in use. Stop the process using that port or run with PORT=<free-port>.`,
173
+ );
174
+ }
175
+
176
+ return error;
177
+ }
178
+
179
+ function isNodeErrorCode(error: unknown, code: string): boolean {
180
+ return (
181
+ typeof error === "object" &&
182
+ error !== null &&
183
+ "code" in error &&
184
+ (error as { code?: unknown }).code === code
185
+ );
186
+ }
187
+
188
+ async function resolveStartDevServerProject(options: StartDevServerOptions): Promise<{
189
+ project: ReturnType<typeof resolveAppRouterProjectOptions>;
190
+ serverPort?: number | undefined;
191
+ viteConfig?: LoadedMreactRouterViteConfig["viteConfig"];
192
+ }> {
193
+ if (options.appDir !== undefined || options.routesDir !== undefined) {
194
+ return { project: resolveAppRouterProjectOptions(options) };
195
+ }
196
+
197
+ const config = await loadOptionalMreactRouterViteConfig(
198
+ resolve(options.projectRoot ?? process.cwd()),
199
+ );
200
+
201
+ return {
202
+ project: resolveAppRouterProjectOptions({
203
+ ...config?.project,
204
+ ...definedProjectOptions(options),
205
+ }),
206
+ serverPort: config?.serverPort,
207
+ viteConfig: config?.viteConfig,
208
+ };
209
+ }
210
+
211
+ async function loadOptionalMreactRouterViteConfig(cwd: string) {
212
+ try {
213
+ return await loadMreactRouterViteConfigDetails({
214
+ command: "serve",
215
+ cwd,
216
+ });
217
+ } catch (error) {
218
+ if (error instanceof Error && error.message.includes("vite.config.ts is required")) {
219
+ return undefined;
220
+ }
221
+
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ async function readDeclaredProjectPackages(projectRoot: string): Promise<string[]> {
227
+ try {
228
+ const json = JSON.parse(await readFile(resolve(projectRoot, "package.json"), "utf8")) as {
229
+ dependencies?: Record<string, unknown> | undefined;
230
+ devDependencies?: Record<string, unknown> | undefined;
231
+ optionalDependencies?: Record<string, unknown> | undefined;
232
+ peerDependencies?: Record<string, unknown> | undefined;
233
+ };
234
+
235
+ return [
236
+ ...Object.keys(json.dependencies ?? {}),
237
+ ...Object.keys(json.devDependencies ?? {}),
238
+ ...Object.keys(json.optionalDependencies ?? {}),
239
+ ...Object.keys(json.peerDependencies ?? {}),
240
+ ];
241
+ } catch {
242
+ return [];
243
+ }
244
+ }
245
+
246
+ function definedProjectOptions(options: StartDevServerOptions): AppRouterProjectOptions {
247
+ return {
248
+ ...(options.allowedSourceDirs === undefined
249
+ ? {}
250
+ : { allowedSourceDirs: options.allowedSourceDirs }),
251
+ ...(options.appDir === undefined ? {} : { appDir: options.appDir }),
252
+ ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }),
253
+ ...(options.publicDir === undefined ? {} : { publicDir: options.publicDir }),
254
+ ...(options.routesDir === undefined ? {} : { routesDir: options.routesDir }),
255
+ };
256
+ }
257
+
258
+ function sendDevServerError(outgoing: ServerResponse, error: unknown): void {
259
+ outgoing.statusCode = 500;
260
+ outgoing.setHeader("content-type", "text/plain; charset=utf-8");
261
+ outgoing.end(error instanceof Error ? error.stack : String(error));
262
+ }
@@ -0,0 +1,88 @@
1
+ export type AppFileConvention =
2
+ | "apple-icon"
3
+ | "icon"
4
+ | "manifest"
5
+ | "opengraph-image"
6
+ | "robots"
7
+ | "sitemap";
8
+
9
+ export type AppFileConventionRouteKind = "asset" | "metadata";
10
+
11
+ export interface AppFileConventionRouteInfo {
12
+ convention: AppFileConvention;
13
+ kind: AppFileConventionRouteKind;
14
+ path: string;
15
+ }
16
+
17
+ const conventionByFilename = new Map<string, AppFileConventionRouteInfo>([
18
+ ["robots.ts", { convention: "robots", kind: "metadata", path: "/robots.txt" }],
19
+ ["robots.txt", { convention: "robots", kind: "asset", path: "/robots.txt" }],
20
+ ["sitemap.ts", { convention: "sitemap", kind: "metadata", path: "/sitemap.xml" }],
21
+ ["sitemap.xml", { convention: "sitemap", kind: "asset", path: "/sitemap.xml" }],
22
+ ["manifest.ts", { convention: "manifest", kind: "metadata", path: "/manifest.webmanifest" }],
23
+ [
24
+ "manifest.webmanifest",
25
+ { convention: "manifest", kind: "asset", path: "/manifest.webmanifest" },
26
+ ],
27
+ ["favicon.ico", { convention: "icon", kind: "asset", path: "/favicon.ico" }],
28
+ ["icon.ico", { convention: "icon", kind: "asset", path: "/icon" }],
29
+ ["icon.png", { convention: "icon", kind: "asset", path: "/icon" }],
30
+ ["icon.jpg", { convention: "icon", kind: "asset", path: "/icon" }],
31
+ ["icon.jpeg", { convention: "icon", kind: "asset", path: "/icon" }],
32
+ ["icon.svg", { convention: "icon", kind: "asset", path: "/icon" }],
33
+ ["apple-icon.png", { convention: "apple-icon", kind: "asset", path: "/apple-icon" }],
34
+ ["apple-icon.jpg", { convention: "apple-icon", kind: "asset", path: "/apple-icon" }],
35
+ ["apple-icon.jpeg", { convention: "apple-icon", kind: "asset", path: "/apple-icon" }],
36
+ ["apple-icon.svg", { convention: "apple-icon", kind: "asset", path: "/apple-icon" }],
37
+ [
38
+ "opengraph-image.png",
39
+ { convention: "opengraph-image", kind: "asset", path: "/opengraph-image" },
40
+ ],
41
+ [
42
+ "opengraph-image.jpg",
43
+ { convention: "opengraph-image", kind: "asset", path: "/opengraph-image" },
44
+ ],
45
+ [
46
+ "opengraph-image.jpeg",
47
+ { convention: "opengraph-image", kind: "asset", path: "/opengraph-image" },
48
+ ],
49
+ [
50
+ "opengraph-image.svg",
51
+ { convention: "opengraph-image", kind: "asset", path: "/opengraph-image" },
52
+ ],
53
+ [
54
+ "opengraph-image.ts",
55
+ { convention: "opengraph-image", kind: "metadata", path: "/opengraph-image" },
56
+ ],
57
+ [
58
+ "opengraph-image.tsx",
59
+ { convention: "opengraph-image", kind: "metadata", path: "/opengraph-image" },
60
+ ],
61
+ [
62
+ "opengraph-image.js",
63
+ { convention: "opengraph-image", kind: "metadata", path: "/opengraph-image" },
64
+ ],
65
+ [
66
+ "opengraph-image.jsx",
67
+ { convention: "opengraph-image", kind: "metadata", path: "/opengraph-image" },
68
+ ],
69
+ ]);
70
+
71
+ export function appFileConventionForRootFilename(
72
+ filename: string,
73
+ ): AppFileConventionRouteInfo | undefined {
74
+ return conventionByFilename.get(filename);
75
+ }
76
+
77
+ export function appFileConventionContentType(filename: string): string {
78
+ if (filename.endsWith(".txt")) return "text/plain; charset=utf-8";
79
+ if (filename.endsWith(".xml")) return "application/xml; charset=utf-8";
80
+ if (filename.endsWith(".webmanifest")) return "application/manifest+json; charset=utf-8";
81
+ if (filename.endsWith(".json")) return "application/json; charset=utf-8";
82
+ if (filename.endsWith(".svg")) return "image/svg+xml";
83
+ if (filename.endsWith(".png")) return "image/png";
84
+ if (filename.endsWith(".jpg") || filename.endsWith(".jpeg")) return "image/jpeg";
85
+ if (filename.endsWith(".ico")) return "image/x-icon";
86
+
87
+ return "application/octet-stream";
88
+ }
package/src/http.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import type { IncomingMessage } from "node:http";
3
+ import { Readable } from "node:stream";
4
+
5
+ export function nodeRequestToWebRequest(
6
+ incoming: IncomingMessage,
7
+ origin: string,
8
+ ): Request {
9
+ const method = incoming.method ?? "GET";
10
+ const headers = new Headers();
11
+
12
+ for (const [name, value] of Object.entries(incoming.headers)) {
13
+ if (Array.isArray(value)) {
14
+ for (const item of value) {
15
+ headers.append(name, item);
16
+ }
17
+ continue;
18
+ }
19
+
20
+ if (value !== undefined) {
21
+ headers.set(name, value);
22
+ }
23
+ }
24
+
25
+ const init: RequestInit & { duplex?: "half" } = {
26
+ headers,
27
+ method,
28
+ };
29
+
30
+ if (method !== "GET" && method !== "HEAD") {
31
+ init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;
32
+ init.duplex = "half";
33
+ }
34
+
35
+ return new Request(new URL(incoming.url ?? "/", origin), init);
36
+ }
37
+
38
+ /**
39
+ * WeakMap-backed marker holding the underlying body for Response objects
40
+ * that mreact built from a known-shape body (string / Buffer /
41
+ * Uint8Array). When `sendResponse` sees this marker, it bypasses the
42
+ * `ReadableStream → reader.read()` pump and writes the body directly to
43
+ * the Node HTTP socket.
44
+ *
45
+ * A WeakMap is used instead of a Symbol property to avoid forcing a
46
+ * hidden-class transition on the Response — Response is a global host
47
+ * object whose IC chain is shared, and adding own properties slows down
48
+ * subsequent `response.headers` / `response.body` accesses.
49
+ *
50
+ * Measured savings (Node 24 / Linux x64, minimal page bench): ~10-15μs/req
51
+ * vs the stream path. See docs/issues/054.
52
+ */
53
+ const rawBodyByResponse = new WeakMap<Response, string | Uint8Array>();
54
+
55
+ /**
56
+ * Constructs a Response whose body is a single string (the common HTML
57
+ * render result). Equivalent to `new Response(html, init)` but tagged so
58
+ * `sendResponse` can take its string fast path.
59
+ */
60
+ export function htmlResponse(html: string, init?: ResponseInit): Response {
61
+ const response = new Response(html, init);
62
+ rawBodyByResponse.set(response, html);
63
+ return response;
64
+ }
65
+
66
+ /**
67
+ * Constructs a Response whose body is raw bytes (e.g., pre-rendered HTML
68
+ * already encoded, or static assets). Tagged so `sendResponse` writes the
69
+ * bytes directly without going through the stream reader.
70
+ */
71
+ export function bytesResponse(bytes: Uint8Array, init?: ResponseInit): Response {
72
+ const response = new Response(bytes as BodyInit, init);
73
+ rawBodyByResponse.set(response, bytes);
74
+ return response;
75
+ }
76
+
77
+ /**
78
+ * Test-only probe: returns true when the Response was constructed via
79
+ * `htmlResponse` / `bytesResponse` and is therefore eligible for the
80
+ * `sendResponse` raw-body fast path. Not part of the public runtime API.
81
+ */
82
+ export function hasFastPathBody(response: Response): boolean {
83
+ return rawBodyByResponse.has(response);
84
+ }
85
+
86
+ export async function sendResponse(
87
+ outgoing: ServerResponse,
88
+ response: Response,
89
+ ): Promise<void> {
90
+ outgoing.statusCode = response.status;
91
+ response.headers.forEach((value, key) => outgoing.setHeader(key, value));
92
+
93
+ if (response.body === null) {
94
+ outgoing.end();
95
+ return;
96
+ }
97
+
98
+ // Fast path: when the Response was constructed via `htmlResponse` /
99
+ // `bytesResponse` (i.e., mreact knows the underlying body shape), skip
100
+ // the Response→ReadableStream→reader pump and write directly.
101
+ const rawBody = rawBodyByResponse.get(response);
102
+ if (rawBody !== undefined) {
103
+ outgoing.end(rawBody);
104
+ return;
105
+ }
106
+
107
+ outgoing.flushHeaders();
108
+ const reader = response.body.getReader();
109
+
110
+ try {
111
+ while (true) {
112
+ const result = await reader.read();
113
+
114
+ if (result.done) {
115
+ outgoing.end();
116
+ return;
117
+ }
118
+
119
+ if (!outgoing.write(result.value)) {
120
+ await new Promise<void>((resolve) => outgoing.once("drain", resolve));
121
+ }
122
+ }
123
+ } catch (error) {
124
+ outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
125
+ } finally {
126
+ reader.releaseLock();
127
+ }
128
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,98 @@
1
+ export interface LocaleRoutingOptions<Locale extends string = string> {
2
+ defaultLocale: Locale;
3
+ locales: readonly Locale[];
4
+ }
5
+
6
+ export interface DetectedLocale<Locale extends string = string> {
7
+ locale: Locale;
8
+ pathname: string;
9
+ source: "accept-language" | "default" | "path";
10
+ }
11
+
12
+ export type MessageTree = {
13
+ readonly [key: string]: MessageTree | string;
14
+ };
15
+
16
+ export function detectLocale<Locale extends string>(
17
+ request: Request,
18
+ options: LocaleRoutingOptions<Locale>,
19
+ ): DetectedLocale<Locale> {
20
+ const url = new URL(request.url);
21
+ const pathMatch = localeFromPath(url.pathname, options.locales);
22
+
23
+ if (pathMatch !== undefined) {
24
+ return {
25
+ locale: pathMatch.locale,
26
+ pathname: pathMatch.pathname,
27
+ source: "path",
28
+ };
29
+ }
30
+
31
+ const accepted = localeFromAcceptLanguage(
32
+ request.headers.get("accept-language"),
33
+ options.locales,
34
+ );
35
+
36
+ if (accepted !== undefined) {
37
+ return {
38
+ locale: accepted,
39
+ pathname: url.pathname,
40
+ source: "accept-language",
41
+ };
42
+ }
43
+
44
+ return {
45
+ locale: options.defaultLocale,
46
+ pathname: url.pathname,
47
+ source: "default",
48
+ };
49
+ }
50
+
51
+ export function defineMessages<const Messages extends MessageTree>(messages: Messages): Messages {
52
+ return messages;
53
+ }
54
+
55
+ function localeFromPath<Locale extends string>(
56
+ pathname: string,
57
+ locales: readonly Locale[],
58
+ ): { locale: Locale; pathname: string } | undefined {
59
+ const [, firstSegment = "", ...rest] = pathname.split("/");
60
+ const locale = locales.find((candidate) => candidate === firstSegment);
61
+
62
+ if (locale === undefined) {
63
+ return undefined;
64
+ }
65
+
66
+ return {
67
+ locale,
68
+ pathname: rest.length === 0 ? "/" : `/${rest.join("/")}`,
69
+ };
70
+ }
71
+
72
+ function localeFromAcceptLanguage<Locale extends string>(
73
+ header: string | null,
74
+ locales: readonly Locale[],
75
+ ): Locale | undefined {
76
+ if (header === null) {
77
+ return undefined;
78
+ }
79
+
80
+ return header
81
+ .split(",")
82
+ .map((part) => {
83
+ const [tag = "", ...params] = part.trim().split(";");
84
+ const q = params
85
+ .map((param) => /^q=(?<value>\d(?:\.\d+)?)$/.exec(param.trim())?.groups?.value)
86
+ .find((value): value is string => value !== undefined);
87
+
88
+ return {
89
+ q: q === undefined ? 1 : Number(q),
90
+ tag: tag.toLowerCase(),
91
+ };
92
+ })
93
+ .filter((entry) => entry.tag !== "" && Number.isFinite(entry.q) && entry.q > 0)
94
+ .sort((left, right) => right.q - left.q)
95
+ .flatMap((entry) => [entry.tag, entry.tag.split("-")[0] ?? ""])
96
+ .map((tag) => locales.find((locale) => locale.toLowerCase() === tag))
97
+ .find((locale): locale is Locale => locale !== undefined);
98
+ }