@reckona/mreact-router 0.0.137 → 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 +47 -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 +59 -12
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;
@@ -52,22 +52,43 @@ export function createNativeRouteMatcher(
52
52
 
53
53
  return {
54
54
  match(pathname): MatchedRoute | undefined {
55
- const output = matcher.matchRoute(pathname);
55
+ return matchNativeRoute(matcher, sortedRoutes, pathname);
56
+ },
57
+ };
58
+ }
56
59
 
57
- if (output == null) {
58
- return undefined;
59
- }
60
+ export function __matchNativeRouteForTesting(
61
+ matcher: NativeRouteMatcherInstance,
62
+ sortedRoutes: readonly AppRoute[],
63
+ pathname: string,
64
+ ): MatchedRoute | undefined {
65
+ return matchNativeRoute(matcher, sortedRoutes, pathname);
66
+ }
60
67
 
61
- const route = sortedRoutes[output.index];
68
+ function matchNativeRoute(
69
+ matcher: NativeRouteMatcherInstance,
70
+ sortedRoutes: readonly AppRoute[],
71
+ pathname: string,
72
+ ): MatchedRoute | undefined {
73
+ let output: NativeMatchOutput | null | undefined;
74
+ try {
75
+ output = matcher.matchRoute(pathname);
76
+ } catch {
77
+ return undefined;
78
+ }
62
79
 
63
- return route === undefined
64
- ? undefined
65
- : {
66
- route,
67
- params: normalizeNativeParams(route, output.params),
68
- };
69
- },
70
- };
80
+ if (output == null) {
81
+ return undefined;
82
+ }
83
+
84
+ const route = sortedRoutes[output.index];
85
+
86
+ return route === undefined
87
+ ? undefined
88
+ : {
89
+ route,
90
+ params: normalizeNativeParams(route, output.params),
91
+ };
71
92
  }
72
93
 
73
94
  export function normalizeNativeParams(
package/src/navigation.ts CHANGED
@@ -57,6 +57,10 @@ function throwUnsafeRedirect(location: string): never {
57
57
  );
58
58
  }
59
59
 
60
+ function throwUnsafeRewrite(location: string): never {
61
+ throw new TypeError(`unsafe rewrite target: ${JSON.stringify(location)}`);
62
+ }
63
+
60
64
  export function redirect(location: string, options: RedirectOptions = {}): never {
61
65
  if (!isSafeInternalRedirect(location)) {
62
66
  throwUnsafeRedirect(location);
@@ -134,6 +138,10 @@ export function next(): MiddlewareNext {
134
138
  }
135
139
 
136
140
  export function rewrite(location: string, init: ResponseInit = {}): Response {
141
+ if (!isSafeInternalRedirect(location)) {
142
+ throwUnsafeRewrite(location);
143
+ }
144
+
137
145
  const response = new Response(null, {
138
146
  ...init,
139
147
  status: init.status ?? 200,
@@ -198,10 +206,11 @@ export function cookies(request: Request): RequestCookies {
198
206
 
199
207
  export function rewriteLocation(response: Response): string | undefined {
200
208
  const marked = (response as { [rewriteLocationSymbol]?: unknown })[rewriteLocationSymbol];
201
-
202
- return typeof marked === "string"
209
+ const candidate = typeof marked === "string"
203
210
  ? marked
204
211
  : response.headers.get(rewriteHeaderName) ?? undefined;
212
+
213
+ return candidate !== undefined && isSafeInternalRedirect(candidate) ? candidate : undefined;
205
214
  }
206
215
 
207
216
  export function isRedirectError(error: unknown): error is Error & { location: string; status: number } {