@reckona/mreact-router 0.0.175 → 0.0.177

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 (64) hide show
  1. package/dist/adapters/cloudflare.d.ts.map +1 -1
  2. package/dist/adapters/cloudflare.js +5 -49
  3. package/dist/adapters/cloudflare.js.map +1 -1
  4. package/dist/build.d.ts +1 -0
  5. package/dist/build.d.ts.map +1 -1
  6. package/dist/build.js +90 -0
  7. package/dist/build.js.map +1 -1
  8. package/dist/built-assets.d.ts +11 -0
  9. package/dist/built-assets.d.ts.map +1 -0
  10. package/dist/built-assets.js +130 -0
  11. package/dist/built-assets.js.map +1 -0
  12. package/dist/built-runtime.d.ts +53 -0
  13. package/dist/built-runtime.d.ts.map +1 -0
  14. package/dist/built-runtime.js +178 -0
  15. package/dist/built-runtime.js.map +1 -0
  16. package/dist/built-server-module-artifacts.d.ts +19 -0
  17. package/dist/built-server-module-artifacts.d.ts.map +1 -0
  18. package/dist/built-server-module-artifacts.js +217 -0
  19. package/dist/built-server-module-artifacts.js.map +1 -0
  20. package/dist/client-manifest-assets.d.ts +16 -0
  21. package/dist/client-manifest-assets.d.ts.map +1 -0
  22. package/dist/client-manifest-assets.js +60 -0
  23. package/dist/client-manifest-assets.js.map +1 -0
  24. package/dist/client.d.ts.map +1 -1
  25. package/dist/client.js +79 -13
  26. package/dist/client.js.map +1 -1
  27. package/dist/node-server.d.ts +29 -0
  28. package/dist/node-server.d.ts.map +1 -0
  29. package/dist/node-server.js +81 -0
  30. package/dist/node-server.js.map +1 -0
  31. package/dist/render.d.ts +9 -0
  32. package/dist/render.d.ts.map +1 -1
  33. package/dist/render.js +20 -41
  34. package/dist/render.js.map +1 -1
  35. package/dist/route-dispatch.d.ts +2 -0
  36. package/dist/route-dispatch.d.ts.map +1 -0
  37. package/dist/route-dispatch.js +16 -0
  38. package/dist/route-dispatch.js.map +1 -0
  39. package/dist/route-loader-runtime.d.ts +22 -0
  40. package/dist/route-loader-runtime.d.ts.map +1 -0
  41. package/dist/route-loader-runtime.js +30 -0
  42. package/dist/route-loader-runtime.js.map +1 -0
  43. package/dist/routes.d.ts +2 -0
  44. package/dist/routes.d.ts.map +1 -1
  45. package/dist/routes.js +48 -1
  46. package/dist/routes.js.map +1 -1
  47. package/dist/serve.d.ts +4 -3
  48. package/dist/serve.d.ts.map +1 -1
  49. package/dist/serve.js +84 -582
  50. package/dist/serve.js.map +1 -1
  51. package/package.json +11 -11
  52. package/src/adapters/cloudflare.ts +5 -60
  53. package/src/build.ts +143 -0
  54. package/src/built-assets.ts +164 -0
  55. package/src/built-runtime.ts +312 -0
  56. package/src/built-server-module-artifacts.ts +340 -0
  57. package/src/client-manifest-assets.ts +92 -0
  58. package/src/client.ts +79 -13
  59. package/src/node-server.ts +129 -0
  60. package/src/render.ts +38 -60
  61. package/src/route-dispatch.ts +17 -0
  62. package/src/route-loader-runtime.ts +54 -0
  63. package/src/routes.ts +70 -1
  64. package/src/serve.ts +105 -863
@@ -0,0 +1,129 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { nodeRequestToWebRequest, sendResponse } from "./http.js";
3
+ import {
4
+ emitRouterLog,
5
+ logDurationMs,
6
+ logError,
7
+ logNow,
8
+ nodeRequestPath,
9
+ requestLogFields,
10
+ type AppRouterLogger,
11
+ } from "./logger.js";
12
+ import type { HttpUpgradeHandler } from "./upgrade.js";
13
+
14
+ export interface StartNodeRequestServerOptions {
15
+ allowedHosts?: readonly string[] | undefined;
16
+ errorHandler?: ((error: unknown) => {
17
+ body: string;
18
+ status: number;
19
+ headers?: Record<string, string>;
20
+ }) | undefined;
21
+ hostname?: string | undefined;
22
+ hostPolicy?: "strict" | "trusted-proxy" | undefined;
23
+ logger?: AppRouterLogger | undefined;
24
+ onUpgrade?: HttpUpgradeHandler | undefined;
25
+ port: number;
26
+ render(request: Request): Promise<Response>;
27
+ resolveHost?: ((options: {
28
+ allowedHosts?: readonly string[] | undefined;
29
+ fallbackHost: string;
30
+ hostPolicy?: "strict" | "trusted-proxy" | undefined;
31
+ rawHost: string | undefined;
32
+ }) => string) | undefined;
33
+ }
34
+
35
+ export async function startNodeRequestServer(
36
+ options: StartNodeRequestServerOptions,
37
+ ): Promise<{ close(): Promise<void>; server: Server; url: string }> {
38
+ const server = createServer(async (incoming, outgoing) => {
39
+ const startedAt = logNow();
40
+ const fallbackRequestFields = {
41
+ method: incoming.method ?? "GET",
42
+ path: nodeRequestPath(incoming.url),
43
+ runtime: "node" as const,
44
+ };
45
+
46
+ try {
47
+ const fallbackHost = `${options.hostname ?? "127.0.0.1"}:${options.port}`;
48
+ const host = (options.resolveHost ?? defaultResolveRequestHost)({
49
+ allowedHosts: options.allowedHosts,
50
+ fallbackHost,
51
+ hostPolicy: options.hostPolicy,
52
+ rawHost: incoming.headers.host,
53
+ });
54
+ const request = nodeRequestToWebRequest(incoming, `http://${host}`);
55
+ const logFields = requestLogFields(request, "node");
56
+ emitRouterLog(options.logger, "info", {
57
+ ...logFields,
58
+ type: "router:request:start",
59
+ });
60
+ const response = await options.render(request);
61
+ emitRouterLog(options.logger, "info", {
62
+ ...logFields,
63
+ durationMs: logDurationMs(startedAt),
64
+ status: response.status,
65
+ type: "router:request:end",
66
+ });
67
+
68
+ await sendResponse(outgoing, response);
69
+ } catch (error) {
70
+ // Log the full stack to stderr for operator visibility; never put
71
+ // it in the response body where attackers can scrape it.
72
+ emitRouterLog(options.logger, "error", {
73
+ ...fallbackRequestFields,
74
+ durationMs: logDurationMs(startedAt),
75
+ error: logError(error),
76
+ type: "router:request:error",
77
+ });
78
+ if (options.logger === undefined) {
79
+ console.error("[mreact] startServer request failed:", error);
80
+ }
81
+ const payload = options.errorHandler
82
+ ? options.errorHandler(error)
83
+ : { body: "Internal Server Error", status: 500 };
84
+ outgoing.statusCode = payload.status;
85
+ outgoing.setHeader(
86
+ "content-type",
87
+ payload.headers?.["content-type"] ?? "text/plain; charset=utf-8",
88
+ );
89
+ for (const [name, value] of Object.entries(payload.headers ?? {})) {
90
+ if (name.toLowerCase() === "content-type") continue;
91
+ outgoing.setHeader(name, value);
92
+ }
93
+ outgoing.end(payload.body);
94
+ }
95
+ });
96
+
97
+ if (options.onUpgrade !== undefined) {
98
+ server.on("upgrade", options.onUpgrade);
99
+ }
100
+
101
+ await new Promise<void>((resolve) =>
102
+ server.listen(options.port, options.hostname ?? "127.0.0.1", resolve),
103
+ );
104
+ const address = server.address();
105
+ const port = typeof address === "object" && address !== null ? address.port : options.port;
106
+
107
+ return {
108
+ server,
109
+ url: `http://${options.hostname ?? "127.0.0.1"}:${port}`,
110
+ close: () =>
111
+ new Promise<void>((resolve, reject) =>
112
+ server.close((error) => (error ? reject(error) : resolve())),
113
+ ),
114
+ };
115
+ }
116
+
117
+ function defaultResolveRequestHost(options: {
118
+ allowedHosts?: readonly string[] | undefined;
119
+ fallbackHost: string;
120
+ hostPolicy?: "strict" | "trusted-proxy" | undefined;
121
+ rawHost: string | undefined;
122
+ }): string {
123
+ const raw = options.rawHost;
124
+ if (raw === undefined || raw === "") return options.fallbackHost;
125
+ if (options.allowedHosts === undefined) {
126
+ return options.hostPolicy === "strict" ? options.fallbackHost : raw;
127
+ }
128
+ return options.allowedHosts.includes(raw) ? raw : options.fallbackHost;
129
+ }
package/src/render.ts CHANGED
@@ -144,6 +144,12 @@ import {
144
144
  prebuiltServerModuleOutputOptionsMatch,
145
145
  type BuiltServerModuleOutputLike,
146
146
  } from "./route-module-loader.js";
147
+ import {
148
+ loadRouteDataFromModule,
149
+ type RouteDataContext,
150
+ type RouteLoaderModule,
151
+ } from "./route-loader-runtime.js";
152
+ import { pageRouteMethodResponse } from "./route-dispatch.js";
147
153
 
148
154
  const nativeEscapeTransform = {
149
155
  batchImportName: "escapeHtmlBatch",
@@ -166,6 +172,13 @@ interface RenderTiming {
166
172
  phases: Record<string, number>;
167
173
  }
168
174
 
175
+ /**
176
+ * Loads prebuilt server render artifacts immediately before a route render needs them.
177
+ */
178
+ export interface AppRouterServerRenderArtifactLoader {
179
+ load(routeFile: string): Promise<void>;
180
+ }
181
+
169
182
  /**
170
183
  * Configures server rendering for a source app-router request.
171
184
  */
@@ -205,6 +218,10 @@ export interface RenderAppRequestOptions {
205
218
  vitePlugins?: readonly PluginOption[] | undefined;
206
219
  }
207
220
 
221
+ export interface RenderAppRequestRuntimeOptions extends RenderAppRequestOptions {
222
+ serverRenderArtifactLoader?: AppRouterServerRenderArtifactLoader | undefined;
223
+ }
224
+
208
225
  export interface AppRouterRenderPreload {
209
226
  promise: Promise<void>;
210
227
  wait: "before-render";
@@ -723,15 +740,11 @@ async function waitForRenderPreload(
723
740
  }
724
741
 
725
742
  async function loadServerRenderArtifacts(
726
- options: RenderAppRequestOptions,
743
+ options: RenderAppRequestRuntimeOptions,
727
744
  routeFile: string,
728
745
  timing: RenderTiming | undefined,
729
746
  ): Promise<void> {
730
- const loader = (
731
- options as RenderAppRequestOptions & {
732
- __mreactLoadServerRenderArtifacts?: ((routeFile: string) => Promise<void>) | undefined;
733
- }
734
- ).__mreactLoadServerRenderArtifacts;
747
+ const loader = options.serverRenderArtifactLoader;
735
748
 
736
749
  if (loader === undefined) {
737
750
  return;
@@ -739,14 +752,14 @@ async function loadServerRenderArtifacts(
739
752
 
740
753
  const phaseStartedAt = renderTimingPhaseStartedAt(timing);
741
754
  try {
742
- await loader(routeFile);
755
+ await loader.load(routeFile);
743
756
  } finally {
744
757
  finishRenderTimingPhase(timing, phaseStartedAt, "renderArtifactLoadMs");
745
758
  }
746
759
  }
747
760
 
748
761
  function emitRenderTiming(
749
- options: RenderAppRequestOptions,
762
+ options: RenderAppRequestRuntimeOptions,
750
763
  timing: RenderTiming | undefined,
751
764
  status: number,
752
765
  ): void {
@@ -798,7 +811,7 @@ export async function resolveAppRouterMiddleware(options: {
798
811
  return { response: middlewareResponse, type: "response" };
799
812
  }
800
813
 
801
- async function renderAppRequestInternal(options: RenderAppRequestOptions): Promise<Response> {
814
+ async function renderAppRequestInternal(options: RenderAppRequestRuntimeOptions): Promise<Response> {
802
815
  warnProductionRenderWithoutPrebuiltModules(options);
803
816
  const timing = createRenderTiming(options.logger);
804
817
  const clientRouteInferenceCache =
@@ -967,18 +980,9 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
967
980
  // methods (PUT, PATCH, DELETE, PROPFIND, ...) get 405 with an
968
981
  // Allow header so the response shape complies with RFC 9110 §9
969
982
  // and so caching intermediaries do not cross-cache method results.
970
- const method = options.request.method;
971
- if (method === "OPTIONS") {
972
- return new Response(null, {
973
- status: 204,
974
- headers: { allow: "GET, HEAD, OPTIONS" },
975
- });
976
- }
977
- if (method !== "GET" && method !== "HEAD") {
978
- return new Response("Method Not Allowed", {
979
- status: 405,
980
- headers: { allow: "GET, HEAD, OPTIONS" },
981
- });
983
+ const methodResponse = pageRouteMethodResponse(options.request.method);
984
+ if (methodResponse !== undefined) {
985
+ return methodResponse;
982
986
  }
983
987
 
984
988
  routeCacheContext = beginRouteCacheContext(options.routeCache);
@@ -4389,17 +4393,6 @@ async function loadRouteMiddlewareControlFile(options: {
4389
4393
  return control;
4390
4394
  }
4391
4395
 
4392
- interface RouteDataContext {
4393
- env?: unknown;
4394
- params: RouteParams;
4395
- queryClient: QueryClient;
4396
- request: Request;
4397
- }
4398
-
4399
- interface RouteLoaderModule {
4400
- loader?: (context: RouteDataContext) => unknown;
4401
- }
4402
-
4403
4396
  interface RouteMetadataContext {
4404
4397
  data: unknown;
4405
4398
  params: RouteParams;
@@ -4427,34 +4420,19 @@ async function loadRouteData(options: {
4427
4420
  timing?: RenderTiming | undefined;
4428
4421
  vitePlugins?: readonly PluginOption[] | undefined;
4429
4422
  }): Promise<unknown> {
4430
- if (!hasLoaderExport(options.code)) {
4431
- return undefined;
4432
- }
4433
-
4434
- let module: RouteLoaderModule;
4435
- const moduleLoadStartedAt = renderTimingPhaseStartedAt(options.timing);
4436
- try {
4437
- module = await loadRouteLoaderModule(options);
4438
- } finally {
4439
- finishRenderTimingPhase(options.timing, moduleLoadStartedAt, "loaderModuleLoadMs");
4440
- }
4441
-
4442
- if (module.loader === undefined) {
4443
- return undefined;
4444
- }
4445
-
4446
- const executionStartedAt = renderTimingPhaseStartedAt(options.timing);
4447
- try {
4448
- return await module.loader(options.context);
4449
- } catch (error) {
4450
- if (error instanceof Response) {
4451
- return error;
4452
- }
4453
-
4454
- throw error;
4455
- } finally {
4456
- finishRenderTimingPhase(options.timing, executionStartedAt, "loaderExecutionMs");
4457
- }
4423
+ return await loadRouteDataFromModule({
4424
+ context: options.context,
4425
+ hasLoader: hasLoaderExport(options.code),
4426
+ loadModule: () => loadRouteLoaderModule(options),
4427
+ timing: {
4428
+ finish(startedAt, phase) {
4429
+ finishRenderTimingPhase(options.timing, startedAt, phase);
4430
+ },
4431
+ start() {
4432
+ return renderTimingPhaseStartedAt(options.timing);
4433
+ },
4434
+ },
4435
+ });
4458
4436
  }
4459
4437
 
4460
4438
  async function loadRouteDataWithInstrumentation(options: {
@@ -0,0 +1,17 @@
1
+ export function pageRouteMethodResponse(method: string): Response | undefined {
2
+ if (method === "GET" || method === "HEAD") {
3
+ return undefined;
4
+ }
5
+
6
+ if (method === "OPTIONS") {
7
+ return new Response(null, {
8
+ status: 204,
9
+ headers: { allow: "GET, HEAD, OPTIONS" },
10
+ });
11
+ }
12
+
13
+ return new Response("Method Not Allowed", {
14
+ status: 405,
15
+ headers: { allow: "GET, HEAD, OPTIONS" },
16
+ });
17
+ }
@@ -0,0 +1,54 @@
1
+ import type { QueryClient } from "@reckona/mreact-query";
2
+ import type { RouteParams } from "./types.js";
3
+
4
+ export interface RouteDataContext {
5
+ env?: unknown;
6
+ params: RouteParams;
7
+ queryClient: QueryClient;
8
+ request: Request;
9
+ }
10
+
11
+ export interface RouteLoaderModule {
12
+ loader?: (context: RouteDataContext) => unknown;
13
+ }
14
+
15
+ export interface RouteLoaderRuntimeTiming {
16
+ finish(startedAt: number | undefined, phase: "loaderExecutionMs" | "loaderModuleLoadMs"): void;
17
+ start(): number | undefined;
18
+ }
19
+
20
+ export async function loadRouteDataFromModule(options: {
21
+ context: RouteDataContext;
22
+ hasLoader: boolean;
23
+ loadModule(): Promise<RouteLoaderModule>;
24
+ timing?: RouteLoaderRuntimeTiming | undefined;
25
+ }): Promise<unknown> {
26
+ if (!options.hasLoader) {
27
+ return undefined;
28
+ }
29
+
30
+ let module: RouteLoaderModule;
31
+ const moduleLoadStartedAt = options.timing?.start();
32
+ try {
33
+ module = await options.loadModule();
34
+ } finally {
35
+ options.timing?.finish(moduleLoadStartedAt, "loaderModuleLoadMs");
36
+ }
37
+
38
+ if (module.loader === undefined) {
39
+ return undefined;
40
+ }
41
+
42
+ const executionStartedAt = options.timing?.start();
43
+ try {
44
+ return await module.loader(options.context);
45
+ } catch (error) {
46
+ if (error instanceof Response) {
47
+ return error;
48
+ }
49
+
50
+ throw error;
51
+ } finally {
52
+ options.timing?.finish(executionStartedAt, "loaderExecutionMs");
53
+ }
54
+ }
package/src/routes.ts CHANGED
@@ -80,6 +80,11 @@ export interface RouteMatcher {
80
80
  match(pathname: string): MatchedRoute | undefined;
81
81
  }
82
82
 
83
+ interface MatchRouteCacheEntry {
84
+ fingerprint: string;
85
+ matcher: RouteMatcher;
86
+ }
87
+
83
88
  export type CompiledRouteMatcherSegment =
84
89
  | { kind: "static"; value: string }
85
90
  | { kind: "dynamic"; name: string }
@@ -99,6 +104,11 @@ export interface CompiledRouteMatcherArtifact {
99
104
  version: 1;
100
105
  }
101
106
 
107
+ let matchRouteMatcherCache = new WeakMap<readonly AppRoute[], MatchRouteCacheEntry>();
108
+ let matchRouteMatcherBuildCountForTest = 0;
109
+ let matchRouteFingerprintIds = new WeakMap<AppRoute, number>();
110
+ let nextMatchRouteFingerprintId = 1;
111
+
102
112
  /**
103
113
  * Scans an app directory and returns sorted app-router route definitions.
104
114
  */
@@ -137,7 +147,28 @@ export function matchRoute(
137
147
  routes: readonly AppRoute[],
138
148
  pathname: string,
139
149
  ): MatchedRoute | undefined {
140
- return createRouteMatcher(routes).match(pathname);
150
+ const fingerprint = routeMatcherFingerprint(routes);
151
+ const cached = matchRouteMatcherCache.get(routes);
152
+
153
+ if (cached !== undefined && cached.fingerprint === fingerprint) {
154
+ return cached.matcher.match(pathname);
155
+ }
156
+
157
+ const matcher = createRouteMatcher(routes);
158
+ matchRouteMatcherBuildCountForTest += 1;
159
+ matchRouteMatcherCache.set(routes, { fingerprint, matcher });
160
+ return matcher.match(pathname);
161
+ }
162
+
163
+ export function __resetMatchRouteCacheForTest(): void {
164
+ matchRouteMatcherCache = new WeakMap();
165
+ matchRouteMatcherBuildCountForTest = 0;
166
+ matchRouteFingerprintIds = new WeakMap();
167
+ nextMatchRouteFingerprintId = 1;
168
+ }
169
+
170
+ export function __getMatchRouteMatcherBuildCountForTest(): number {
171
+ return matchRouteMatcherBuildCountForTest;
141
172
  }
142
173
 
143
174
  export function compileRouteMatcherArtifact(
@@ -204,6 +235,44 @@ function compileRouteMatcherEntry(
204
235
  };
205
236
  }
206
237
 
238
+ function routeMatcherFingerprint(routes: readonly AppRoute[]): string {
239
+ const parts: string[] = [String(routes.length)];
240
+
241
+ for (const route of routes) {
242
+ parts.push(
243
+ String(routeMatcherRouteFingerprintId(route)),
244
+ route.kind,
245
+ route.path,
246
+ String(route.segments.length),
247
+ );
248
+
249
+ for (const segment of route.segments) {
250
+ parts.push(segment.kind);
251
+
252
+ if (segment.kind === "static") {
253
+ parts.push(segment.value);
254
+ } else {
255
+ parts.push(segment.name);
256
+ }
257
+ }
258
+ }
259
+
260
+ return parts.map((part) => `${part.length}:${part}`).join("|");
261
+ }
262
+
263
+ function routeMatcherRouteFingerprintId(route: AppRoute): number {
264
+ const cached = matchRouteFingerprintIds.get(route);
265
+
266
+ if (cached !== undefined) {
267
+ return cached;
268
+ }
269
+
270
+ const id = nextMatchRouteFingerprintId;
271
+ nextMatchRouteFingerprintId += 1;
272
+ matchRouteFingerprintIds.set(route, id);
273
+ return id;
274
+ }
275
+
207
276
  function matchCompiledRoutes(
208
277
  routes: readonly AppRoute[],
209
278
  artifact: CompiledRouteMatcherArtifact,