@reckona/mreact-router 0.0.138 → 0.0.139

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 (76) hide show
  1. package/README.md +1 -1
  2. package/dist/actions.d.ts +1 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +32 -12
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/static.js +10 -4
  7. package/dist/adapters/static.js.map +1 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +28 -21
  10. package/dist/build.js.map +1 -1
  11. package/dist/cache.d.ts +2 -0
  12. package/dist/cache.d.ts.map +1 -1
  13. package/dist/cache.js +23 -1
  14. package/dist/cache.js.map +1 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +49 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/dev-server.d.ts +1 -0
  19. package/dist/dev-server.d.ts.map +1 -1
  20. package/dist/dev-server.js +6 -4
  21. package/dist/dev-server.js.map +1 -1
  22. package/dist/link.js +17 -3
  23. package/dist/link.js.map +1 -1
  24. package/dist/middleware.d.ts.map +1 -1
  25. package/dist/middleware.js +10 -7
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/multipart.d.ts +5 -0
  28. package/dist/multipart.d.ts.map +1 -1
  29. package/dist/multipart.js +24 -1
  30. package/dist/multipart.js.map +1 -1
  31. package/dist/native-route-matcher.d.ts +9 -0
  32. package/dist/native-route-matcher.d.ts.map +1 -1
  33. package/dist/native-route-matcher.js +23 -11
  34. package/dist/native-route-matcher.js.map +1 -1
  35. package/dist/navigation.d.ts.map +1 -1
  36. package/dist/navigation.js +8 -1
  37. package/dist/navigation.js.map +1 -1
  38. package/dist/render.d.ts +5 -1
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +239 -95
  41. package/dist/render.js.map +1 -1
  42. package/dist/route-source.d.ts +3 -1
  43. package/dist/route-source.d.ts.map +1 -1
  44. package/dist/route-source.js +13 -11
  45. package/dist/route-source.js.map +1 -1
  46. package/dist/route-styles.d.ts +4 -0
  47. package/dist/route-styles.d.ts.map +1 -1
  48. package/dist/route-styles.js +15 -3
  49. package/dist/route-styles.js.map +1 -1
  50. package/dist/serve.d.ts.map +1 -1
  51. package/dist/serve.js +39 -9
  52. package/dist/serve.js.map +1 -1
  53. package/dist/server-action-inference.d.ts.map +1 -1
  54. package/dist/server-action-inference.js +57 -0
  55. package/dist/server-action-inference.js.map +1 -1
  56. package/dist/vite.d.ts.map +1 -1
  57. package/dist/vite.js +27 -11
  58. package/dist/vite.js.map +1 -1
  59. package/package.json +11 -11
  60. package/src/actions.ts +44 -10
  61. package/src/adapters/static.ts +11 -4
  62. package/src/build.ts +28 -17
  63. package/src/cache.ts +33 -1
  64. package/src/client.ts +49 -12
  65. package/src/dev-server.ts +11 -4
  66. package/src/link.ts +24 -3
  67. package/src/middleware.ts +12 -7
  68. package/src/multipart.ts +32 -1
  69. package/src/native-route-matcher.ts +34 -13
  70. package/src/navigation.ts +11 -2
  71. package/src/render.ts +321 -113
  72. package/src/route-source.ts +14 -10
  73. package/src/route-styles.ts +28 -3
  74. package/src/serve.ts +68 -15
  75. package/src/server-action-inference.ts +86 -0
  76. package/src/vite.ts +39 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reckona/mreact-router",
3
- "version": "0.0.138",
3
+ "version": "0.0.139",
4
4
  "description": "File-system app router, SSR, actions, and deployment adapters for mreact.",
5
5
  "keywords": [
6
6
  "jsx",
@@ -105,20 +105,20 @@
105
105
  },
106
106
  "dependencies": {
107
107
  "typescript": "^6.0.3",
108
- "@reckona/mreact": "0.0.138",
109
- "@reckona/mreact-compat": "0.0.138",
110
- "@reckona/mreact-compiler": "0.0.138",
111
- "@reckona/mreact-devtools": "0.0.138",
112
- "@reckona/mreact-reactive-core": "0.0.138",
113
- "@reckona/mreact-query": "0.0.138",
114
- "@reckona/mreact-server": "0.0.138",
115
- "@reckona/mreact-reactive-dom": "0.0.138",
116
- "@reckona/mreact-shared": "0.0.138"
108
+ "@reckona/mreact": "0.0.139",
109
+ "@reckona/mreact-compiler": "0.0.139",
110
+ "@reckona/mreact-query": "0.0.139",
111
+ "@reckona/mreact-devtools": "0.0.139",
112
+ "@reckona/mreact-reactive-dom": "0.0.139",
113
+ "@reckona/mreact-reactive-core": "0.0.139",
114
+ "@reckona/mreact-compat": "0.0.139",
115
+ "@reckona/mreact-server": "0.0.139",
116
+ "@reckona/mreact-shared": "0.0.139"
117
117
  },
118
118
  "peerDependencies": {
119
119
  "vite": ">=8 <9"
120
120
  },
121
121
  "optionalDependencies": {
122
- "@reckona/mreact-router-native": "0.0.138"
122
+ "@reckona/mreact-router-native": "0.0.139"
123
123
  }
124
124
  }
package/src/actions.ts CHANGED
@@ -64,6 +64,7 @@ const actionTokenSecret = process.env.MREACT_SERVER_ACTION_SECRET ?? defaultActi
64
64
  const DEFAULT_REPLAY_TTL_MS = 10 * 60 * 1000;
65
65
  const DEFAULT_REPLAY_MAX_ENTRIES = 50_000;
66
66
  const DEFAULT_ACTION_BODY_MAX_BYTES = 10 * 1024 * 1024;
67
+ const DEFAULT_ACTION_FORM_MAX_FIELDS = 1_000;
67
68
  let warnedUnrestrictedServerActions = false;
68
69
 
69
70
  export interface ServerActionContext {
@@ -103,19 +104,20 @@ class BoundedReplayStore {
103
104
 
104
105
  add(value: string): void {
105
106
  const now = Date.now();
106
- // Cheap opportunistic sweep: drop the oldest expired entries first,
107
- // then enforce the hard cap with FIFO eviction.
108
107
  if (this.entries.size >= this.maxEntries) {
109
- for (const [key, expiresAt] of this.entries) {
108
+ const oldest = this.entries.entries().next().value;
109
+ if (oldest !== undefined) {
110
+ const [key, expiresAt] = oldest;
111
+ this.entries.delete(key);
110
112
  if (expiresAt < now) {
111
- this.entries.delete(key);
113
+ while (this.entries.size >= this.maxEntries) {
114
+ const nextOldest = this.entries.entries().next().value;
115
+ if (nextOldest === undefined || nextOldest[1] >= now) {
116
+ break;
117
+ }
118
+ this.entries.delete(nextOldest[0]);
119
+ }
112
120
  }
113
- if (this.entries.size < this.maxEntries) break;
114
- }
115
- while (this.entries.size >= this.maxEntries) {
116
- const oldest = this.entries.keys().next().value;
117
- if (oldest === undefined) break;
118
- this.entries.delete(oldest);
119
121
  }
120
122
  }
121
123
  this.entries.set(value, now + this.ttlMs);
@@ -147,6 +149,7 @@ export interface AppRouterServerActionOptions {
147
149
  allowedActions?: readonly AppRouterAllowedServerAction[] | undefined;
148
150
  authorize?: ServerActionHandlerOptions["authorize"] | undefined;
149
151
  maxBodyBytes?: number | undefined;
152
+ maxFormFields?: number | undefined;
150
153
  replayStore?: ServerActionReplayStore | undefined;
151
154
  }
152
155
 
@@ -370,6 +373,15 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
370
373
  }
371
374
 
372
375
  const formData = await options.request.formData();
376
+ const fieldCountResponse = validateServerActionFormFieldCount(
377
+ formData,
378
+ options.serverActions?.maxFormFields ?? DEFAULT_ACTION_FORM_MAX_FIELDS,
379
+ );
380
+
381
+ if (fieldCountResponse !== undefined) {
382
+ return fieldCountResponse;
383
+ }
384
+
373
385
  const csrfResponse = validateFormCsrf(options.request, formData);
374
386
 
375
387
  if (csrfResponse !== undefined) {
@@ -515,6 +527,28 @@ function validateServerActionBodySize(
515
527
  return undefined;
516
528
  }
517
529
 
530
+ function validateServerActionFormFieldCount(
531
+ formData: FormData,
532
+ maxFormFields: number,
533
+ ): Response | undefined {
534
+ if (!Number.isFinite(maxFormFields) || maxFormFields < 0) {
535
+ return undefined;
536
+ }
537
+
538
+ let count = 0;
539
+ for (const _ of formData.keys()) {
540
+ count += 1;
541
+ if (count > maxFormFields) {
542
+ return jsonResponse({
543
+ ok: false,
544
+ error: "Server action form field count is too large.",
545
+ }, 413);
546
+ }
547
+ }
548
+
549
+ return undefined;
550
+ }
551
+
518
552
  function redirectToFormReferer(request: Request): Response {
519
553
  return new Response(null, {
520
554
  status: 303,
@@ -1,5 +1,5 @@
1
1
  import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, join, resolve, sep } from "node:path";
3
3
  import type { BuiltPrerenderedRoute, BuiltServerManifest } from "../build.js";
4
4
 
5
5
  export interface StaticExportOptions {
@@ -79,14 +79,21 @@ async function writePrerenderedRoute(
79
79
  throw new Error(`Cannot export route ${route} with status ${entry.status}.`);
80
80
  }
81
81
 
82
- const file = join(exportDir, routeToHtmlFile(route));
82
+ const file = routeToHtmlFile(exportDir, route);
83
83
  await mkdir(dirname(file), { recursive: true });
84
84
  await writeFile(file, entry.html);
85
85
  }
86
86
 
87
- function routeToHtmlFile(route: string): string {
87
+ function routeToHtmlFile(exportDir: string, route: string): string {
88
88
  const pathname = route.split("?")[0] ?? "/";
89
89
  const normalized = pathname.startsWith("/") ? pathname.slice(1) : pathname;
90
+ const relativeFile = normalized === "" ? "index.html" : join(normalized, "index.html");
91
+ const root = resolve(exportDir);
92
+ const file = resolve(root, relativeFile);
90
93
 
91
- return normalized === "" ? "index.html" : join(normalized, "index.html");
94
+ if (file !== root && !file.startsWith(`${root}${sep}`)) {
95
+ throw new Error(`unsafe static export route: ${route}`);
96
+ }
97
+
98
+ return file;
92
99
  }
package/src/build.ts CHANGED
@@ -1387,6 +1387,7 @@ async function collectBuildServerActionManifest(options: {
1387
1387
  .filter((route) => route.kind === "page")
1388
1388
  .map((route) => relative(options.projectRoot, route.file).split(sep).join("/")),
1389
1389
  );
1390
+ const inferredRouteReferences: Array<{ code: string; file: string }> = [];
1390
1391
 
1391
1392
  for (const [file, code] of Object.entries(options.files)) {
1392
1393
  if (!isAppRelativeFile(file, relativeRoutesDir) || !isSourceModuleFile(file)) {
@@ -1402,29 +1403,39 @@ async function collectBuildServerActionManifest(options: {
1402
1403
  }
1403
1404
 
1404
1405
  if (routeSourceFiles.has(file)) {
1405
- const inference = await collectBuildInferredServerActionReferences({
1406
+ inferredRouteReferences.push({ code, file });
1407
+ }
1408
+ }
1409
+
1410
+ const inferredRouteResults = await mapWithBuildConcurrency(
1411
+ inferredRouteReferences,
1412
+ async ({ code, file }) => ({
1413
+ file,
1414
+ inference: await collectBuildInferredServerActionReferences({
1406
1415
  file,
1407
1416
  files: options.files,
1408
1417
  relativeRoutesDir,
1409
1418
  source: code,
1410
- });
1419
+ }),
1420
+ }),
1421
+ );
1411
1422
 
1412
- for (const diagnostic of inference.diagnostics) {
1413
- console.warn(formatServerActionInferenceDiagnostic(diagnostic));
1414
- }
1423
+ for (const { file, inference } of inferredRouteResults) {
1424
+ for (const diagnostic of inference.diagnostics) {
1425
+ console.warn(formatServerActionInferenceDiagnostic(diagnostic));
1426
+ }
1415
1427
 
1416
- routeReferences.set(file, inference.references);
1428
+ routeReferences.set(file, inference.references);
1417
1429
 
1418
- for (const reference of inference.references) {
1419
- const key = `${reference.moduleId}#${reference.exportName}`;
1430
+ for (const reference of inference.references) {
1431
+ const key = `${reference.moduleId}#${reference.exportName}`;
1420
1432
 
1421
- if (!entries.has(key)) {
1422
- entries.set(key, {
1423
- exportName: reference.exportName,
1424
- inferred: reference.inferred,
1425
- moduleId: reference.moduleId,
1426
- });
1427
- }
1433
+ if (!entries.has(key)) {
1434
+ entries.set(key, {
1435
+ exportName: reference.exportName,
1436
+ inferred: reference.inferred,
1437
+ moduleId: reference.moduleId,
1438
+ });
1428
1439
  }
1429
1440
  }
1430
1441
  }
@@ -4383,8 +4394,8 @@ function cloudflareWorkspaceRuntimePlugin(): RouterCompatPlugin {
4383
4394
  { filter: /^native-escape$/, namespace: "mreact-cloudflare-native-escape" },
4384
4395
  () => ({
4385
4396
  contents: `function escapeHtml(value) {
4386
- return String(value ?? "").replace(/[&<>"']/g, (char) =>
4387
- char === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : char === '"' ? "&quot;" : "&#39;"
4397
+ return String(value ?? "").replace(/[&<>"]/g, (char) =>
4398
+ char === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : "&quot;"
4388
4399
  );
4389
4400
  }
4390
4401
  export function escapeHtmlBatch(values) {
package/src/cache.ts CHANGED
@@ -233,8 +233,13 @@ export function cachedRouteResponse(options: {
233
233
  cache?: AppRouterCache | undefined;
234
234
  key: string;
235
235
  now?: number;
236
+ request?: Request | undefined;
236
237
  }): Promise<Response | undefined> {
237
238
  return Promise.resolve().then(async () => {
239
+ if (requestCarriesCredentials(options.request)) {
240
+ return undefined;
241
+ }
242
+
238
243
  const cache = options.cache ?? cacheState.memoryCache;
239
244
 
240
245
  await consumeInvalidations(cache);
@@ -262,6 +267,7 @@ export async function cacheRouteResponse(options: {
262
267
  now?: number;
263
268
  path: string;
264
269
  policy: RouteCachePolicy | undefined;
270
+ request?: Request | undefined;
265
271
  response: Response;
266
272
  }): Promise<Response> {
267
273
  if (options.policy === undefined) {
@@ -276,6 +282,24 @@ export async function cacheRouteResponse(options: {
276
282
  const body = await options.response.text();
277
283
  const cacheControl = options.policy.cacheControl;
278
284
  const status = options.response.status;
285
+ const contentType = options.response.headers.get("content-type") ?? "text/html; charset=utf-8";
286
+
287
+ if (
288
+ requestCarriesCredentials(options.request) ||
289
+ options.response.headers.has("set-cookie")
290
+ ) {
291
+ const headers = new Headers(options.response.headers);
292
+ headers.set("cache-control", "private, no-store");
293
+ if (!headers.has("content-type")) {
294
+ headers.set("content-type", contentType);
295
+ }
296
+
297
+ return new Response(body, {
298
+ headers,
299
+ status,
300
+ });
301
+ }
302
+
279
303
  await (options.cache ?? cacheState.memoryCache).set(options.key, {
280
304
  body,
281
305
  cacheControl,
@@ -287,13 +311,21 @@ export async function cacheRouteResponse(options: {
287
311
  return new Response(body, {
288
312
  headers: {
289
313
  "cache-control": cacheControl,
290
- "content-type": options.response.headers.get("content-type") ?? "text/html; charset=utf-8",
314
+ "content-type": contentType,
291
315
  "x-mreact-cache": "MISS",
292
316
  },
293
317
  status,
294
318
  });
295
319
  }
296
320
 
321
+ function requestCarriesCredentials(request: Request | undefined): boolean {
322
+ if (request === undefined) {
323
+ return false;
324
+ }
325
+
326
+ return request.headers.has("authorization") || request.headers.has("cookie");
327
+ }
328
+
297
329
  export function revalidatePath(path: string): void {
298
330
  const normalizedPath = normalizeRevalidationPath(path);
299
331
  const activeContext = activeRouteCacheContext();
package/src/client.ts CHANGED
@@ -2240,6 +2240,7 @@ export async function buildClientRouteEntrySource(
2240
2240
  },
2241
2241
  fetchRevalidationInstalled: false,
2242
2242
  installed: false,
2243
+ prefetchedUrls: new Set(),
2243
2244
  prefetchedScripts: new Set(),
2244
2245
  reloadNextNavigationFetch: false,
2245
2246
  routePrefetchManifest: undefined,
@@ -2593,6 +2594,12 @@ export async function __mreactPrefetch(url) {
2593
2594
  return false;
2594
2595
  }
2595
2596
 
2597
+ if (__mreactNavigationState.prefetchedUrls.has(href)) {
2598
+ return true;
2599
+ }
2600
+
2601
+ __mreactNavigationState.prefetchedUrls.add(href);
2602
+
2596
2603
  const script = __mreactRouteScriptForNavigationUrl(href);
2597
2604
 
2598
2605
  if (script === undefined) {
@@ -2690,6 +2697,10 @@ export async function __mreactNavigate(url, options = {}) {
2690
2697
 
2691
2698
  try {
2692
2699
  const cachedHtml = __mreactCachedNavigationHtml(href);
2700
+ const script = __mreactRouteScriptForNavigationUrl(href);
2701
+ if (script !== undefined) {
2702
+ void __mreactPrefetchRouteScript(script);
2703
+ }
2693
2704
  const html = cachedHtml ?? await __mreactFetchNavigationHtml(href);
2694
2705
 
2695
2706
  if (typeof html !== "string") {
@@ -2842,12 +2853,14 @@ export function __mreactInvalidateNavigationCache(path) {
2842
2853
  for (const href of Array.from(__mreactNavigationState.cache.keys())) {
2843
2854
  if (__mreactNormalizeNavigationPath(href) === normalizedPath) {
2844
2855
  __mreactNavigationState.cache.delete(href);
2856
+ __mreactNavigationState.prefetchedUrls.delete(href);
2845
2857
  }
2846
2858
  }
2847
2859
  }
2848
2860
 
2849
2861
  function __mreactInvalidateAllNavigationCache() {
2850
2862
  __mreactNavigationState.cache.clear();
2863
+ __mreactNavigationState.prefetchedUrls.clear();
2851
2864
  }
2852
2865
 
2853
2866
  function __mreactFetchNavigationHtml(href) {
@@ -2903,11 +2916,19 @@ function __mreactNormalizeNavigationPath(path) {
2903
2916
  }
2904
2917
 
2905
2918
  export function __mreactRestoreHistoryState(state) {
2906
- if (state === null || state === undefined || state.__mreact !== true || typeof state.html !== "string") {
2919
+ if (state === null || state === undefined || state.__mreact !== true) {
2907
2920
  return false;
2908
2921
  }
2909
2922
 
2910
- const applied = __mreactApplyNavigationHtml(state.html, state.url);
2923
+ const html = typeof state.html === "string"
2924
+ ? state.html
2925
+ : typeof state.url === "string" ? __mreactNavigationState.cache.get(state.url) : undefined;
2926
+
2927
+ if (typeof html !== "string") {
2928
+ return false;
2929
+ }
2930
+
2931
+ const applied = __mreactApplyNavigationHtml(html, state.url);
2911
2932
 
2912
2933
  if (!applied) {
2913
2934
  return false;
@@ -3120,15 +3141,14 @@ function __mreactSyncRouteDataScripts(root, currentRouteId, nextRouteId) {
3120
3141
  return;
3121
3142
  }
3122
3143
 
3123
- for (const element of Array.from(document.querySelectorAll(__mreactRouteDataScriptSelector()))) {
3124
- if (managedIds.has(element.id)) {
3125
- element.remove();
3126
- }
3127
- }
3144
+ for (const id of managedIds) {
3145
+ document.getElementById(id)?.remove();
3146
+ const next = typeof root.getElementById === "function"
3147
+ ? root.getElementById(id)
3148
+ : root.querySelector(\`#\${id}\`);
3128
3149
 
3129
- for (const element of Array.from(root.querySelectorAll(__mreactRouteDataScriptSelector()))) {
3130
- if (managedIds.has(element.id)) {
3131
- document.body.appendChild(element);
3150
+ if (next !== null && next !== undefined) {
3151
+ document.body.appendChild(next);
3132
3152
  }
3133
3153
  }
3134
3154
  }
@@ -3155,13 +3175,29 @@ function __mreactRouteDataScriptSelector() {
3155
3175
  function __mreactCurrentHistoryState(url) {
3156
3176
  return {
3157
3177
  __mreact: true,
3158
- html: document.body.innerHTML,
3178
+ html: __mreactCurrentDocumentRouteHtml(),
3159
3179
  scrollX: Number(globalThis.scrollX ?? 0),
3160
3180
  scrollY: Number(globalThis.scrollY ?? 0),
3161
3181
  url,
3162
3182
  };
3163
3183
  }
3164
3184
 
3185
+ function __mreactCurrentDocumentRouteHtml() {
3186
+ if (typeof document === "undefined") {
3187
+ return undefined;
3188
+ }
3189
+
3190
+ const marker = document.querySelector("[" + __mreactRouteMarkerAttribute + "]");
3191
+ if (marker === null) {
3192
+ return undefined;
3193
+ }
3194
+
3195
+ const routeDataScripts = Array.from(document.querySelectorAll(__mreactRouteDataScriptSelector()))
3196
+ .map((script) => script.outerHTML)
3197
+ .join("");
3198
+ return marker.outerHTML + routeDataScripts;
3199
+ }
3200
+
3165
3201
  function __mreactPushHistoryState(url) {
3166
3202
  if (typeof history === "undefined" || url === undefined) {
3167
3203
  return;
@@ -4245,7 +4281,8 @@ export function cell(initial) {
4245
4281
  buildApi.onLoad({ filter: /^devtools$/, namespace: "mreact-devtools-stub" }, () => ({
4246
4282
  contents: `export function emitReactiveDevtoolsEvent() {}
4247
4283
  export function hasReactiveDevtoolsEmitter() { return false; }
4248
- export function currentDevtoolsEmitter() { return undefined; }`,
4284
+ export function currentDevtoolsEmitter() { return undefined; }
4285
+ export function currentReactiveDevtools() { return undefined; }`,
4249
4286
  loader: "ts",
4250
4287
  }));
4251
4288
  buildApi.onLoad({ filter: /\.(?:mreact\.)?[cm]?[jt]sx$/ }, async (args) => {
package/src/dev-server.ts CHANGED
@@ -28,6 +28,7 @@ export interface StartDevServerOptions extends AppRouterProjectOptions {
28
28
  logger?: AppRouterLogger | undefined;
29
29
  routeCache?: AppRouterCache | undefined;
30
30
  serverActions?: AppRouterServerActionOptions | undefined;
31
+ verboseErrors?: boolean | undefined;
31
32
  viteConfig?: UserConfig | undefined;
32
33
  onUpgrade?: HttpUpgradeHandler | undefined;
33
34
  }
@@ -87,7 +88,7 @@ export async function startDevServer(
87
88
  }
88
89
 
89
90
  if (vite === undefined) {
90
- sendDevServerError(outgoing, new Error("Vite dev server is not initialized."));
91
+ sendDevServerError(outgoing, new Error("Vite dev server is not initialized."), options);
91
92
  return;
92
93
  }
93
94
 
@@ -96,7 +97,7 @@ export async function startDevServer(
96
97
  if (error instanceof Error) {
97
98
  vite?.ssrFixStacktrace(error);
98
99
  }
99
- sendDevServerError(outgoing, error);
100
+ sendDevServerError(outgoing, error, options);
100
101
  return;
101
102
  }
102
103
 
@@ -263,8 +264,14 @@ function definedProjectOptions(options: StartDevServerOptions): AppRouterProject
263
264
  };
264
265
  }
265
266
 
266
- function sendDevServerError(outgoing: ServerResponse, error: unknown): void {
267
+ function sendDevServerError(
268
+ outgoing: ServerResponse,
269
+ error: unknown,
270
+ options: Pick<StartDevServerOptions, "verboseErrors">,
271
+ ): void {
267
272
  outgoing.statusCode = 500;
268
273
  outgoing.setHeader("content-type", "text/plain; charset=utf-8");
269
- outgoing.end(error instanceof Error ? error.stack : String(error));
274
+ outgoing.end(options.verboseErrors === true
275
+ ? error instanceof Error ? error.stack : String(error)
276
+ : "Internal Server Error");
270
277
  }
package/src/link.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { escapeHtmlAttribute } from "@reckona/mreact-shared/html-escape";
1
+ import { escapeHtmlAttribute, escapeHtmlText } from "@reckona/mreact-shared/html-escape";
2
2
  import type { HtmlSink } from "@reckona/mreact-shared/compiler-contract";
3
3
  import type { ReactCompatElement, ReactCompatNode } from "@reckona/mreact-compat";
4
4
  import { safeUrlAttributeValue } from "@reckona/mreact-shared/url-safety";
@@ -87,7 +87,14 @@ function createAnchorElement(props: Record<string, unknown>): HTMLAnchorElement
87
87
  continue;
88
88
  }
89
89
 
90
- anchor.setAttribute(attributeName(name), String(value));
90
+ const attrName = attributeName(name);
91
+ const safeValue = safeUrlAttributeValue(attrName, String(value));
92
+
93
+ if (safeValue === undefined) {
94
+ continue;
95
+ }
96
+
97
+ anchor.setAttribute(attrName, safeValue);
91
98
  }
92
99
 
93
100
  return anchor;
@@ -164,5 +171,19 @@ function renderChildren(child: LinkChild): string {
164
171
  return child.map(renderChildren).join("");
165
172
  }
166
173
 
167
- return String(child);
174
+ if (typeof child === "string" && isPreRenderedSafeHtmlChild(child)) {
175
+ return child;
176
+ }
177
+
178
+ return escapeHtmlText(child);
179
+ }
180
+
181
+ function isPreRenderedSafeHtmlChild(value: string): boolean {
182
+ if (!/[<&]/.test(value)) {
183
+ return false;
184
+ }
185
+
186
+ return !/(?:<\s*\/?\s*(?:script|style|iframe|object|embed|link|meta|base)\b|\son[a-z]+\s*=|javascript\s*:)/iu.test(
187
+ value,
188
+ );
168
189
  }
package/src/middleware.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { normalizeRoutePath } from "./route-path.js";
2
+
1
3
  export interface RouteMiddlewareControl {
2
4
  skip?: boolean | readonly string[];
3
5
  }
@@ -133,20 +135,23 @@ export function mergeRouteMiddlewareControls(
133
135
  }
134
136
 
135
137
  function middlewarePatternMatches(pattern: string, pathname: string): boolean {
136
- if (pattern === pathname) {
138
+ const normalizedPattern = normalizeRoutePath(pattern);
139
+ const normalizedPathname = normalizeRoutePath(pathname);
140
+
141
+ if (normalizedPattern === normalizedPathname) {
137
142
  return true;
138
143
  }
139
144
 
140
- if (pattern.endsWith("/:path*")) {
141
- const prefix = pattern.slice(0, -"/:path*".length);
145
+ if (normalizedPattern.endsWith("/:path*")) {
146
+ const prefix = normalizedPattern.slice(0, -"/:path*".length);
142
147
 
143
- return pathname === prefix || pathname.startsWith(`${prefix}/`);
148
+ return normalizedPathname === prefix || normalizedPathname.startsWith(`${prefix}/`);
144
149
  }
145
150
 
146
- if (pattern.endsWith("*")) {
147
- const prefix = pattern.slice(0, -1);
151
+ if (normalizedPattern.endsWith("*")) {
152
+ const prefix = normalizedPattern.slice(0, -1);
148
153
 
149
- return pathname.startsWith(prefix);
154
+ return normalizedPathname.startsWith(prefix);
150
155
  }
151
156
 
152
157
  return false;
package/src/multipart.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export interface MultipartStreamParseOptions {
2
2
  fields?: Readonly<Record<string, MultipartStreamFieldOptions>>;
3
+ maxParts?: number;
3
4
  maxBytes?: number;
4
5
  }
5
6
 
@@ -11,6 +12,7 @@ export interface MultipartStreamFieldOptions {
11
12
  export interface MultipartStreamPart {
12
13
  name: string;
13
14
  filename?: string;
15
+ safeFilename?: string;
14
16
  contentType?: string;
15
17
  headers: Headers;
16
18
  body: ReadableStream<Uint8Array<ArrayBufferLike>>;
@@ -34,6 +36,8 @@ interface PartWriter {
34
36
  const crlf = "\r\n";
35
37
  const headerSeparator = "\r\n\r\n";
36
38
  const maxHeaderBytes = 64 * 1024;
39
+ export const defaultMultipartMaxBytes = 10 * 1024 * 1024;
40
+ export const defaultMultipartMaxParts = 1_000;
37
41
 
38
42
  export function parseMultipartStream(
39
43
  request: Request,
@@ -73,6 +77,7 @@ async function parseMultipartRequest(
73
77
  const keepTailBytes = delimiter.length + 4;
74
78
  let buffer: Uint8Array<ArrayBufferLike> = new Uint8Array();
75
79
  let totalBytes = 0;
80
+ let totalParts = 0;
76
81
  let started = false;
77
82
  let currentPart: PartWriter | undefined;
78
83
 
@@ -87,7 +92,7 @@ async function parseMultipartRequest(
87
92
 
88
93
  if (!next.done) {
89
94
  totalBytes += next.value.byteLength;
90
- enforceByteLimit("multipart request", totalBytes, options.maxBytes);
95
+ enforceByteLimit("multipart request", totalBytes, options.maxBytes ?? defaultMultipartMaxBytes);
91
96
  buffer = concatBytes(buffer, next.value);
92
97
  }
93
98
 
@@ -136,6 +141,8 @@ async function parseMultipartRequest(
136
141
  break;
137
142
  }
138
143
 
144
+ totalParts += 1;
145
+ enforcePartLimit(totalParts, options.maxParts ?? defaultMultipartMaxParts);
139
146
  currentPart = createPartWriter(
140
147
  decodeAscii(buffer.slice(0, headerEnd)),
141
148
  options,
@@ -266,6 +273,7 @@ function createPart(options: {
266
273
 
267
274
  if (options.filename !== undefined) {
268
275
  part.filename = options.filename;
276
+ part.safeFilename = sanitizeMultipartFilename(options.filename);
269
277
  }
270
278
 
271
279
  const contentType = options.headers.get("content-type");
@@ -330,6 +338,29 @@ function enforceByteLimit(label: string, bytes: number, maxBytes: number | undef
330
338
  }
331
339
  }
332
340
 
341
+ function enforcePartLimit(parts: number, maxParts: number | undefined): void {
342
+ if (maxParts !== undefined && parts > maxParts) {
343
+ throw new Error(`multipart request exceeded ${maxParts} parts.`);
344
+ }
345
+ }
346
+
347
+ export function sanitizeMultipartFilename(filename: string): string {
348
+ const leaf = filename.split(/[\\/]/u).at(-1) ?? "";
349
+ let sanitized = "";
350
+
351
+ for (const char of leaf) {
352
+ sanitized += isControlCharacter(char) ? "_" : char;
353
+ }
354
+
355
+ return sanitized === "" || sanitized === "." || sanitized === ".." ? "upload.bin" : sanitized;
356
+ }
357
+
358
+ function isControlCharacter(char: string): boolean {
359
+ const code = char.charCodeAt(0);
360
+
361
+ return code < 0x20 || code === 0x7f;
362
+ }
363
+
333
364
  function parseMultipartBoundary(contentType: string | null): string | undefined {
334
365
  if (contentType === null) {
335
366
  return undefined;