@reckona/mreact-router 0.0.97 → 0.0.99

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 (92) hide show
  1. package/README.md +4 -0
  2. package/dist/actions.d.ts +7 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +26 -2
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/aws-lambda.d.ts.map +1 -1
  7. package/dist/adapters/aws-lambda.js +10 -7
  8. package/dist/adapters/aws-lambda.js.map +1 -1
  9. package/dist/build.d.ts +38 -1
  10. package/dist/build.d.ts.map +1 -1
  11. package/dist/build.js +1087 -304
  12. package/dist/build.js.map +1 -1
  13. package/dist/bundle-pipeline.d.ts +7 -0
  14. package/dist/bundle-pipeline.d.ts.map +1 -1
  15. package/dist/bundle-pipeline.js +121 -87
  16. package/dist/bundle-pipeline.js.map +1 -1
  17. package/dist/cache.d.ts.map +1 -1
  18. package/dist/cache.js +10 -8
  19. package/dist/cache.js.map +1 -1
  20. package/dist/cli-options.d.ts +1 -0
  21. package/dist/cli-options.d.ts.map +1 -1
  22. package/dist/cli-options.js +16 -0
  23. package/dist/cli-options.js.map +1 -1
  24. package/dist/cli.js +1 -0
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client.d.ts +6 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +113 -8
  29. package/dist/client.js.map +1 -1
  30. package/dist/csrf.d.ts.map +1 -1
  31. package/dist/csrf.js +7 -3
  32. package/dist/csrf.js.map +1 -1
  33. package/dist/http.d.ts.map +1 -1
  34. package/dist/http.js +18 -1
  35. package/dist/http.js.map +1 -1
  36. package/dist/import-policy.d.ts.map +1 -1
  37. package/dist/import-policy.js +7 -3
  38. package/dist/import-policy.js.map +1 -1
  39. package/dist/index.d.ts +4 -3
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/link.d.ts +5 -2
  43. package/dist/link.d.ts.map +1 -1
  44. package/dist/link.js +92 -3
  45. package/dist/link.js.map +1 -1
  46. package/dist/logger.d.ts +7 -1
  47. package/dist/logger.d.ts.map +1 -1
  48. package/dist/logger.js.map +1 -1
  49. package/dist/module-runner.d.ts +2 -0
  50. package/dist/module-runner.d.ts.map +1 -1
  51. package/dist/module-runner.js +1 -0
  52. package/dist/module-runner.js.map +1 -1
  53. package/dist/native-route-matcher.d.ts +2 -1
  54. package/dist/native-route-matcher.d.ts.map +1 -1
  55. package/dist/native-route-matcher.js +13 -2
  56. package/dist/native-route-matcher.js.map +1 -1
  57. package/dist/render.d.ts.map +1 -1
  58. package/dist/render.js +117 -2
  59. package/dist/render.js.map +1 -1
  60. package/dist/route-source.d.ts +4 -4
  61. package/dist/route-source.d.ts.map +1 -1
  62. package/dist/route-source.js +8 -8
  63. package/dist/route-source.js.map +1 -1
  64. package/dist/route-styles.d.ts +6 -0
  65. package/dist/route-styles.d.ts.map +1 -1
  66. package/dist/route-styles.js +10 -1
  67. package/dist/route-styles.js.map +1 -1
  68. package/dist/serve.d.ts +5 -0
  69. package/dist/serve.d.ts.map +1 -1
  70. package/dist/serve.js +7 -4
  71. package/dist/serve.js.map +1 -1
  72. package/package.json +11 -11
  73. package/src/actions.ts +42 -2
  74. package/src/adapters/aws-lambda.ts +33 -16
  75. package/src/build.ts +1578 -397
  76. package/src/bundle-pipeline.ts +136 -88
  77. package/src/cache.ts +13 -8
  78. package/src/cli-options.ts +20 -0
  79. package/src/cli.ts +1 -0
  80. package/src/client.ts +145 -14
  81. package/src/csrf.ts +8 -3
  82. package/src/http.ts +21 -1
  83. package/src/import-policy.ts +8 -3
  84. package/src/index.ts +5 -0
  85. package/src/link.ts +129 -8
  86. package/src/logger.ts +9 -1
  87. package/src/module-runner.ts +4 -0
  88. package/src/native-route-matcher.ts +13 -2
  89. package/src/render.ts +177 -2
  90. package/src/route-source.ts +8 -8
  91. package/src/route-styles.ts +17 -1
  92. package/src/serve.ts +11 -6
package/src/client.ts CHANGED
@@ -67,6 +67,7 @@ export interface ClientRouteManifestEntry {
67
67
  }
68
68
 
69
69
  export interface BuildClientRouteOutputOptions {
70
+ cacheDir?: string | undefined;
70
71
  code: string;
71
72
  clientBoundaryImports?: readonly string[] | undefined;
72
73
  clientReferenceImports?: readonly ClientReferenceImport[] | undefined;
@@ -145,16 +146,20 @@ export interface ClientRouteReferenceResult extends ClientRouteInferenceResult {
145
146
  export interface ClientRouteInferenceDiagnostic {
146
147
  code:
147
148
  | typeof clientBoundaryInferenceServerOnlyReferenceCode
149
+ | typeof clientBoundaryInferenceFunctionCallInteractiveCode
148
150
  | typeof clientBoundaryInferenceUnsupportedReferenceCode;
149
151
  filename: string;
150
152
  level: "warn";
151
153
  localNames: string[];
152
154
  message: string;
155
+ routePath?: string | undefined;
153
156
  source: string;
154
157
  }
155
158
 
156
159
  const clientBoundaryInferenceServerOnlyReferenceCode =
157
160
  "MR_CLIENT_BOUNDARY_INFERENCE_SERVER_ONLY_REFERENCE";
161
+ const clientBoundaryInferenceFunctionCallInteractiveCode =
162
+ "MR_CLIENT_BOUNDARY_INFERENCE_FUNCTION_CALL_INTERACTIVE";
158
163
  const clientBoundaryInferenceUnsupportedReferenceCode =
159
164
  "MR_CLIENT_BOUNDARY_INFERENCE_UNSUPPORTED_REFERENCE";
160
165
 
@@ -244,7 +249,7 @@ export async function inferClientRouteModule(options: {
244
249
  : routeInference;
245
250
 
246
251
  if (options.appDir === undefined) {
247
- return mergedRouteInference;
252
+ return withClientRouteDiagnosticPath(mergedRouteInference, options.routePath);
248
253
  }
249
254
 
250
255
  const shellInferences = await inferClientRouteShellModules({
@@ -254,14 +259,18 @@ export async function inferClientRouteModule(options: {
254
259
  sourceTransform,
255
260
  });
256
261
 
257
- return {
258
- client: mergedRouteInference.client || shellInferences.some((inference) => inference.client),
259
- clientBoundaryImports: mergedRouteInference.clientBoundaryImports,
260
- diagnostics: [
261
- ...mergedRouteInference.diagnostics,
262
- ...shellInferences.flatMap((inference) => inference.diagnostics),
263
- ],
264
- };
262
+ return withClientRouteDiagnosticPath(
263
+ {
264
+ client:
265
+ mergedRouteInference.client || shellInferences.some((inference) => inference.client),
266
+ clientBoundaryImports: mergedRouteInference.clientBoundaryImports,
267
+ diagnostics: [
268
+ ...mergedRouteInference.diagnostics,
269
+ ...shellInferences.flatMap((inference) => inference.diagnostics),
270
+ ],
271
+ },
272
+ options.routePath,
273
+ );
265
274
  } catch (error) {
266
275
  throw new Error(
267
276
  `Failed to infer client route for ${options.routePath ?? "<unknown>"} (${options.filename}).\n${errorMessage(error)}`,
@@ -337,6 +346,7 @@ export async function collectClientRouteReferences(options: {
337
346
  cache?: ClientRouteInferenceCache | undefined;
338
347
  code: string;
339
348
  filename: string;
349
+ routePath?: string | undefined;
340
350
  vitePlugins?: readonly PluginOption[] | undefined;
341
351
  }): Promise<ClientRouteReferenceResult> {
342
352
  const cache = options.cache ?? createClientRouteInferenceCache();
@@ -502,7 +512,9 @@ export async function collectClientRouteReferences(options: {
502
512
  clientBoundaryImports: routeInference.clientBoundaryImports,
503
513
  clientReferenceImports,
504
514
  clientReferenceManifest,
505
- diagnostics: sources.flatMap((source) => source.inference.diagnostics),
515
+ diagnostics: sources
516
+ .flatMap((source) => source.inference.diagnostics)
517
+ .map((diagnostic) => withClientRouteDiagnosticPath(diagnostic, options.routePath)),
506
518
  usesNavigationLink:
507
519
  routeInference.usesNavigationLink ||
508
520
  sources.some(
@@ -813,6 +825,12 @@ async function inferClientRouteModuleSource(options: {
813
825
  }
814
826
 
815
827
  if (renderedByCall && !renderedByJsx) {
828
+ diagnostics.push(
829
+ functionCallInteractiveImportDiagnostic({
830
+ filename: options.filename,
831
+ reference,
832
+ }),
833
+ );
816
834
  continue;
817
835
  }
818
836
 
@@ -1145,6 +1163,28 @@ function serverOnlyClientImportReferenceDiagnostic(options: {
1145
1163
  };
1146
1164
  }
1147
1165
 
1166
+ function functionCallInteractiveImportDiagnostic(options: {
1167
+ filename: string;
1168
+ reference: StaticImportReference;
1169
+ }): ClientRouteInferenceDiagnostic {
1170
+ const localNames = options.reference.localNames.filter(startsUppercase);
1171
+ const component = localNames[0] ?? options.reference.localNames[0] ?? options.reference.source;
1172
+ const componentUsage = startsUppercase(component) ? `<${component} />` : "JSX";
1173
+
1174
+ return {
1175
+ code: clientBoundaryInferenceFunctionCallInteractiveCode,
1176
+ filename: options.filename,
1177
+ level: "warn",
1178
+ localNames: localNames.length === 0 ? options.reference.localNames : localNames,
1179
+ message:
1180
+ `${options.filename}: interactive component import ${JSON.stringify(options.reference.source)} ` +
1181
+ `is rendered through a function call as ${component}(), so its event handlers or cell() state ` +
1182
+ "will not hydrate as a client boundary. Render it through JSX such as " +
1183
+ `${componentUsage}, or move the call behind an explicit client boundary.`,
1184
+ source: options.reference.source,
1185
+ };
1186
+ }
1187
+
1148
1188
  function unsupportedClientImportReferenceDiagnostic(options: {
1149
1189
  filename: string;
1150
1190
  identifierReferences: ReadonlySet<string>;
@@ -1186,7 +1226,31 @@ function startsUppercase(value: string): boolean {
1186
1226
  export function formatClientRouteInferenceDiagnostic(
1187
1227
  diagnostic: ClientRouteInferenceDiagnostic,
1188
1228
  ): string {
1189
- return `${diagnostic.code}: ${diagnostic.message}`;
1229
+ const route = diagnostic.routePath === undefined ? "" : ` on route ${JSON.stringify(diagnostic.routePath)}`;
1230
+ return `${diagnostic.code}: ${diagnostic.message}${route}`;
1231
+ }
1232
+
1233
+ function withClientRouteDiagnosticPath<T extends ClientRouteInferenceDiagnostic | ClientRouteInferenceResult>(
1234
+ value: T,
1235
+ routePath: string | undefined,
1236
+ ): T {
1237
+ if (routePath === undefined) {
1238
+ return value;
1239
+ }
1240
+
1241
+ if ("diagnostics" in value) {
1242
+ return {
1243
+ ...value,
1244
+ diagnostics: value.diagnostics.map((diagnostic) =>
1245
+ withClientRouteDiagnosticPath(diagnostic, routePath),
1246
+ ),
1247
+ };
1248
+ }
1249
+
1250
+ return {
1251
+ ...value,
1252
+ routePath: value.routePath ?? routePath,
1253
+ };
1190
1254
  }
1191
1255
 
1192
1256
  async function resolveAppLocalModule(options: {
@@ -1579,6 +1643,7 @@ export async function buildClientRouteOutput(
1579
1643
  options.dropConsoleFunctions ?? resolveClientConsolePureFunctions(options.dropClientConsole);
1580
1644
  const bundled = await bundleRouterModule({
1581
1645
  code: entry.code,
1646
+ cacheDir: options.cacheDir,
1582
1647
  define: {
1583
1648
  __MREACT_CLIENT_DEVTOOLS__: "false",
1584
1649
  },
@@ -1600,6 +1665,7 @@ export async function buildClientRouteOutput(
1600
1665
 
1601
1666
  export async function buildClientRouteBatchOutput(options: {
1602
1667
  assetBaseUrl?: string | undefined;
1668
+ cacheDir?: string | undefined;
1603
1669
  dropConsoleFunctions?: readonly string[] | undefined;
1604
1670
  minify?: boolean;
1605
1671
  projectRoot?: string | undefined;
@@ -1622,6 +1688,7 @@ export async function buildClientRouteBatchOutput(options: {
1622
1688
  );
1623
1689
  const bundled = await bundleRouterModules({
1624
1690
  base: options.assetBaseUrl ?? "/_mreact/client/",
1691
+ cacheDir: options.cacheDir,
1625
1692
  define: {
1626
1693
  __MREACT_CLIENT_DEVTOOLS__: "false",
1627
1694
  },
@@ -2091,6 +2158,10 @@ export async function __mreactPrefetch(url) {
2091
2158
  return false;
2092
2159
  }
2093
2160
 
2161
+ if (__mreactIsCurrentLocationNavigationHref(href)) {
2162
+ return false;
2163
+ }
2164
+
2094
2165
  const script = __mreactRouteScriptForNavigationUrl(href);
2095
2166
 
2096
2167
  if (script === undefined) {
@@ -2110,11 +2181,43 @@ function __mreactPrefetchNavigationHtml(href) {
2110
2181
  return false;
2111
2182
  }
2112
2183
 
2113
- __mreactNavigationState.cache.set(href, html);
2184
+ __mreactRememberNavigationHtml(href, html);
2114
2185
  return true;
2115
2186
  }).catch(() => false);
2116
2187
  }
2117
2188
 
2189
+ const __mreactNavigationHtmlCacheMaxEntries = 64;
2190
+
2191
+ function __mreactCachedNavigationHtml(href) {
2192
+ const html = __mreactNavigationState.cache.get(href);
2193
+
2194
+ if (html === undefined) {
2195
+ return undefined;
2196
+ }
2197
+
2198
+ __mreactNavigationState.cache.delete(href);
2199
+ __mreactNavigationState.cache.set(href, html);
2200
+ return html;
2201
+ }
2202
+
2203
+ function __mreactRememberNavigationHtml(href, html) {
2204
+ if (__mreactNavigationState.cache.has(href)) {
2205
+ __mreactNavigationState.cache.delete(href);
2206
+ }
2207
+
2208
+ __mreactNavigationState.cache.set(href, html);
2209
+
2210
+ while (__mreactNavigationState.cache.size > __mreactNavigationHtmlCacheMaxEntries) {
2211
+ const oldestHref = __mreactNavigationState.cache.keys().next().value;
2212
+
2213
+ if (oldestHref === undefined) {
2214
+ return;
2215
+ }
2216
+
2217
+ __mreactNavigationState.cache.delete(oldestHref);
2218
+ }
2219
+ }
2220
+
2118
2221
  function __mreactPrefetchRouteScript(script) {
2119
2222
  if (typeof document === "undefined") {
2120
2223
  return false;
@@ -2155,14 +2258,14 @@ export async function __mreactNavigate(url, options = {}) {
2155
2258
  __mreactSetNavigationState(__mreactPendingNavigationState(href, options.type ?? "push"));
2156
2259
 
2157
2260
  try {
2158
- const cachedHtml = __mreactNavigationState.cache.get(href);
2261
+ const cachedHtml = __mreactCachedNavigationHtml(href);
2159
2262
  const html = cachedHtml ?? await __mreactFetchNavigationHtml(href);
2160
2263
 
2161
2264
  if (typeof html !== "string") {
2162
2265
  return false;
2163
2266
  }
2164
2267
 
2165
- __mreactNavigationState.cache.set(href, html);
2268
+ __mreactRememberNavigationHtml(href, html);
2166
2269
  return await __mreactApplyNavigationHtmlWithOptionalTransition(html, href, options);
2167
2270
  } finally {
2168
2271
  __mreactSetNavigationState(__mreactIdleNavigationState());
@@ -2828,6 +2931,24 @@ function __mreactIsHashOnlyNavigation(nextUrl) {
2828
2931
  nextUrl.hash !== location.hash;
2829
2932
  }
2830
2933
 
2934
+ function __mreactIsCurrentLocationNavigationHref(href) {
2935
+ if (typeof location === "undefined") {
2936
+ return false;
2937
+ }
2938
+
2939
+ return __mreactIsCurrentLocationNavigation(new URL(href, location.href));
2940
+ }
2941
+
2942
+ function __mreactIsCurrentLocationNavigation(nextUrl) {
2943
+ if (typeof location === "undefined") {
2944
+ return false;
2945
+ }
2946
+
2947
+ return nextUrl.origin === location.origin &&
2948
+ nextUrl.pathname === location.pathname &&
2949
+ nextUrl.search === location.search;
2950
+ }
2951
+
2831
2952
  function __mreactInstallNavigation() {
2832
2953
  if (__mreactNavigationState.installed || typeof document === "undefined") {
2833
2954
  return;
@@ -2890,6 +3011,16 @@ function __mreactInstallNavigation() {
2890
3011
  return;
2891
3012
  }
2892
3013
 
3014
+ if (__mreactIsCurrentLocationNavigation(nextUrl)) {
3015
+ event.preventDefault();
3016
+
3017
+ if (__mreactAnchorScrollMode(anchor) !== false && nextUrl.hash === "") {
3018
+ __mreactScrollTo(0, 0);
3019
+ }
3020
+
3021
+ return;
3022
+ }
3023
+
2893
3024
  event.preventDefault();
2894
3025
  void __mreactNavigate(nextUrl.href, {
2895
3026
  scroll: __mreactAnchorScrollMode(anchor),
package/src/csrf.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  const csrfCookieNameProduction = "__Host-mreact.csrf";
2
2
  const csrfCookieNameDevelopment = "mreact.csrf";
3
- const csrfCookieNamesRead = [csrfCookieNameProduction, csrfCookieNameDevelopment];
4
3
  const formFieldCsrf = "__mreact_csrf";
5
4
 
6
5
  export const formCsrfFieldName = formFieldCsrf;
@@ -32,7 +31,7 @@ export function formCsrfCookie(csrfToken: string): string {
32
31
  export function validateFormCsrf(request: Request, formData: FormData): Response | undefined {
33
32
  const formToken = stringFormValue(formData.get(formFieldCsrf));
34
33
  const cookieHeader = request.headers.get("cookie");
35
- const cookieToken = csrfCookieNamesRead
34
+ const cookieToken = csrfCookieNamesRead()
36
35
  .map((name) => readCookie(cookieHeader, name))
37
36
  .find((token) => token !== undefined);
38
37
 
@@ -48,7 +47,7 @@ export function validateFormCsrf(request: Request, formData: FormData): Response
48
47
  export function readExistingFormCsrfToken(request: Request | undefined): string | undefined {
49
48
  const cookieHeader = request?.headers.get("cookie") ?? null;
50
49
 
51
- for (const name of csrfCookieNamesRead) {
50
+ for (const name of csrfCookieNamesRead()) {
52
51
  const token = readCookie(cookieHeader, name);
53
52
 
54
53
  if (token !== undefined && isCsrfTokenShape(token)) {
@@ -90,6 +89,12 @@ function currentCsrfCookieName(): string {
90
89
  return isProductionEnvironment() ? csrfCookieNameProduction : csrfCookieNameDevelopment;
91
90
  }
92
91
 
92
+ function csrfCookieNamesRead(): readonly string[] {
93
+ return isProductionEnvironment()
94
+ ? [csrfCookieNameProduction]
95
+ : [csrfCookieNameProduction, csrfCookieNameDevelopment];
96
+ }
97
+
93
98
  function timingSafeStringEqual(left: string, right: string): boolean {
94
99
  let diff = left.length ^ right.length;
95
100
  const length = Math.max(left.length, right.length);
package/src/http.ts CHANGED
@@ -88,7 +88,16 @@ export async function sendResponse(
88
88
  response: Response,
89
89
  ): Promise<void> {
90
90
  outgoing.statusCode = response.status;
91
- response.headers.forEach((value, key) => outgoing.setHeader(key, value));
91
+ const setCookieHeaders = responseSetCookieHeaders(response.headers);
92
+ response.headers.forEach((value, key) => {
93
+ if (key.toLowerCase() === "set-cookie" && setCookieHeaders.length > 0) {
94
+ return;
95
+ }
96
+ outgoing.setHeader(key, value);
97
+ });
98
+ if (setCookieHeaders.length > 0) {
99
+ outgoing.setHeader("set-cookie", setCookieHeaders);
100
+ }
92
101
 
93
102
  if (response.body === null) {
94
103
  outgoing.end();
@@ -126,3 +135,14 @@ export async function sendResponse(
126
135
  reader.releaseLock();
127
136
  }
128
137
  }
138
+
139
+ function responseSetCookieHeaders(headers: Headers): string[] {
140
+ const getSetCookie = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
141
+
142
+ if (typeof getSetCookie === "function") {
143
+ return getSetCookie.call(headers);
144
+ }
145
+
146
+ const value = headers.get("set-cookie");
147
+ return value === null ? [] : [value];
148
+ }
@@ -106,9 +106,14 @@ export function createAppRouterImportPolicyPlugin(options: AppRouterImportPolicy
106
106
 
107
107
  const resolvedPackage = resolvePackageSpecifier(args.path, args.resolveDir, options.appDir);
108
108
 
109
- return resolvedPackage === undefined
110
- ? undefined
111
- : { external: true, path: pathToFileURL(resolvedPackage).href };
109
+ if (resolvedPackage === undefined) {
110
+ return undefined;
111
+ }
112
+
113
+ return {
114
+ external: true,
115
+ path: pathToFileURL(resolvedPackage).href,
116
+ };
112
117
  });
113
118
  },
114
119
  };
package/src/index.ts CHANGED
@@ -71,6 +71,8 @@ export const getSession = getSessionInternal;
71
71
  export const rotateSession = rotateSessionInternal;
72
72
  export type {
73
73
  AwsLambdaArtifactManifest,
74
+ BuildAppPhase,
75
+ BuildAppPhaseTiming,
74
76
  BuildAppOptions,
75
77
  BuildAppResult,
76
78
  BuiltImportPolicyArtifact,
@@ -78,6 +80,7 @@ export type {
78
80
  PackageAwsLambdaArtifactOptions,
79
81
  PackageCloudflarePagesArtifactOptions,
80
82
  } from "./build.js";
83
+ export type { ServerActionContext } from "./actions.js";
81
84
  export type {
82
85
  InferLoaderData,
83
86
  LayoutProps,
@@ -126,6 +129,7 @@ export type {
126
129
  export type { CookieOptions } from "./cookies.js";
127
130
  export type { AppRouterImportPolicy } from "./import-policy.js";
128
131
  export type {
132
+ LinkChild,
129
133
  LinkOptions,
130
134
  LinkPrefetch,
131
135
  LinkProps,
@@ -140,6 +144,7 @@ export type {
140
144
  export type { RouterRuntimeCacheStat } from "./runtime-cache.js";
141
145
  export type { MemorySessionStoreOptionsInternal as MemorySessionStoreOptions };
142
146
  export type {
147
+ AppRouterCspInlineNonceWarningLogEvent,
143
148
  AppRouterLogError,
144
149
  AppRouterLogEvent,
145
150
  AppRouterLogger,
package/src/link.ts CHANGED
@@ -1,12 +1,12 @@
1
- import {
2
- createElement,
3
- type ReactCompatElement,
4
- type ReactCompatNode,
5
- } from "@reckona/mreact-compat";
1
+ import { escapeHtmlAttribute } from "@reckona/mreact-shared/html-escape";
2
+ import type { HtmlSink } from "@reckona/mreact-shared/compiler-contract";
3
+ import type { ReactCompatElement, ReactCompatNode } from "@reckona/mreact-compat";
4
+ import { safeUrlAttributeValue } from "@reckona/mreact-shared/url-safety";
6
5
 
7
6
  export type LinkPrefetch = "intent" | "viewport" | "none" | false;
8
7
  export type LinkScroll = "top" | "preserve";
9
8
  export type LinkTransition = "auto" | "none" | false;
9
+ export type LinkChild = ReactCompatNode | Node | readonly LinkChild[];
10
10
 
11
11
  export interface LinkOptions {
12
12
  href: string;
@@ -17,7 +17,7 @@ export interface LinkOptions {
17
17
  }
18
18
 
19
19
  export interface LinkProps extends LinkOptions {
20
- children?: ReactCompatNode;
20
+ children?: LinkChild;
21
21
  [attribute: string]: unknown;
22
22
  }
23
23
 
@@ -37,11 +37,132 @@ export function linkProps(options: LinkOptions): Record<string, string> {
37
37
  };
38
38
  }
39
39
 
40
- export function Link(props: LinkProps): ReactCompatElement {
40
+ export function Link(props: LinkProps): ReactCompatElement;
41
+ export function Link(sink: HtmlSink, props: LinkProps): void;
42
+ export function Link(
43
+ sinkOrProps: HtmlSink | LinkProps,
44
+ maybeProps?: LinkProps,
45
+ ): ReactCompatElement | string | HTMLAnchorElement | void {
46
+ if (maybeProps !== undefined) {
47
+ (sinkOrProps as HtmlSink).append(renderLinkString(maybeProps));
48
+ return;
49
+ }
50
+
51
+ return renderLink(sinkOrProps as LinkProps);
52
+ }
53
+
54
+ function renderLink(props: LinkProps): string | HTMLAnchorElement {
55
+ const { href, prefetch, reload, scroll, transition, ...rest } = props;
56
+ const propsWithLinkAttrs = {
57
+ ...rest,
58
+ ...linkProps({ href, prefetch, reload, scroll, transition }),
59
+ };
60
+
61
+ if (typeof document !== "undefined" && typeof document.createElement === "function") {
62
+ return createAnchorElement(propsWithLinkAttrs);
63
+ }
64
+
65
+ return renderAnchorString(propsWithLinkAttrs);
66
+ }
67
+
68
+ function renderLinkString(props: LinkProps): string {
41
69
  const { href, prefetch, reload, scroll, transition, ...rest } = props;
42
70
 
43
- return createElement("a", {
71
+ return renderAnchorString({
44
72
  ...rest,
45
73
  ...linkProps({ href, prefetch, reload, scroll, transition }),
46
74
  });
47
75
  }
76
+
77
+ function createAnchorElement(props: Record<string, unknown>): HTMLAnchorElement {
78
+ const anchor = document.createElement("a");
79
+
80
+ for (const [name, value] of Object.entries(props)) {
81
+ if (name === "children") {
82
+ appendLinkChild(anchor, value as LinkChild);
83
+ continue;
84
+ }
85
+
86
+ if (!shouldSetAttribute(name, value)) {
87
+ continue;
88
+ }
89
+
90
+ anchor.setAttribute(attributeName(name), String(value));
91
+ }
92
+
93
+ return anchor;
94
+ }
95
+
96
+ function appendLinkChild(parent: Node, child: LinkChild): void {
97
+ if (child === null || child === undefined || typeof child === "boolean") {
98
+ return;
99
+ }
100
+
101
+ if (Array.isArray(child)) {
102
+ for (const item of child) {
103
+ appendLinkChild(parent, item);
104
+ }
105
+ return;
106
+ }
107
+
108
+ if (child instanceof Node) {
109
+ parent.appendChild(child);
110
+ return;
111
+ }
112
+
113
+ parent.appendChild(document.createTextNode(String(child)));
114
+ }
115
+
116
+ function renderAnchorString(props: Record<string, unknown>): string {
117
+ return `<a${renderAnchorAttributes(props)}>${renderChildren(props.children as LinkChild)}</a>`;
118
+ }
119
+
120
+ function renderAnchorAttributes(props: Record<string, unknown>): string {
121
+ const attrs: string[] = [];
122
+
123
+ for (const [name, value] of Object.entries(props)) {
124
+ if (name === "children" || !shouldSetAttribute(name, value)) {
125
+ continue;
126
+ }
127
+
128
+ const attrName = attributeName(name);
129
+ const attrValue = String(value);
130
+ const safeValue = safeUrlAttributeValue(attrName, attrValue);
131
+
132
+ if (safeValue === undefined) {
133
+ continue;
134
+ }
135
+
136
+ attrs.push(`${escapeHtmlAttribute(attrName)}="${escapeHtmlAttribute(safeValue)}"`);
137
+ }
138
+
139
+ return attrs.length === 0 ? "" : ` ${attrs.join(" ")}`;
140
+ }
141
+
142
+ function shouldSetAttribute(name: string, value: unknown): boolean {
143
+ return (
144
+ name !== "key" &&
145
+ name !== "ref" &&
146
+ value !== null &&
147
+ value !== undefined &&
148
+ value !== false &&
149
+ typeof value !== "function" &&
150
+ typeof value !== "symbol"
151
+ );
152
+ }
153
+
154
+ function attributeName(name: string): string {
155
+ return name === "className" ? "class" : name;
156
+ }
157
+
158
+ function renderChildren(child: LinkChild): string {
159
+ if (child === null || child === undefined || typeof child === "boolean") {
160
+ return "";
161
+ }
162
+
163
+ if (Array.isArray(child)) {
164
+ return child.map(renderChildren).join("");
165
+ }
166
+
167
+ return String(child);
168
+ }
package/src/logger.ts CHANGED
@@ -14,7 +14,8 @@ export type AppRouterLogEvent =
14
14
  | AppRouterRequestEndLogEvent
15
15
  | AppRouterRequestErrorLogEvent
16
16
  | AppRouterRequestTimingLogEvent
17
- | AppRouterRenderTimingLogEvent;
17
+ | AppRouterRenderTimingLogEvent
18
+ | AppRouterCspInlineNonceWarningLogEvent;
18
19
 
19
20
  export interface AppRouterRequestStartLogEvent {
20
21
  method: string;
@@ -59,6 +60,13 @@ export interface AppRouterRenderTimingLogEvent {
59
60
  type: "router:render:timing";
60
61
  }
61
62
 
63
+ export interface AppRouterCspInlineNonceWarningLogEvent {
64
+ directive: "script-src" | "style-src";
65
+ path: string;
66
+ tag: "script" | "style";
67
+ type: "router:csp:inline-nonce-warning";
68
+ }
69
+
62
70
  export interface AppRouterLogError {
63
71
  message: string;
64
72
  name: string;
@@ -85,6 +85,7 @@ export async function importAppRouterSourceModule<T>(options: {
85
85
  label: string;
86
86
  plugins?: readonly RouterCompatPlugin[] | undefined;
87
87
  resolveDir?: string | undefined;
88
+ root?: string | undefined;
88
89
  serverSourceTransform?: ServerSourceTransformOptions | undefined;
89
90
  sourcefile?: string | undefined;
90
91
  vitePlugins?: readonly PluginOption[] | undefined;
@@ -125,6 +126,7 @@ async function importAppRouterSourceModuleWithoutCache<T>(options: {
125
126
  label: string;
126
127
  plugins?: readonly RouterCompatPlugin[] | undefined;
127
128
  resolveDir?: string | undefined;
129
+ root?: string | undefined;
128
130
  serverSourceTransform?: ServerSourceTransformOptions | undefined;
129
131
  sourcefile?: string | undefined;
130
132
  vitePlugins?: readonly PluginOption[] | undefined;
@@ -275,6 +277,7 @@ export async function bundleAppRouterSourceModule(options: {
275
277
  label: string;
276
278
  plugins?: readonly RouterCompatPlugin[] | undefined;
277
279
  resolveDir?: string | undefined;
280
+ root?: string | undefined;
278
281
  serverSourceTransform?: ServerSourceTransformOptions | undefined;
279
282
  sourcefile?: string | undefined;
280
283
  vitePlugins?: readonly PluginOption[] | undefined;
@@ -284,6 +287,7 @@ export async function bundleAppRouterSourceModule(options: {
284
287
  externalizeAppSourceModuleDirs: options.externalizeAppSourceModuleDirs,
285
288
  filename: options.sourcefile ?? join(options.resolveDir ?? process.cwd(), "module.js"),
286
289
  platform: "node",
290
+ root: options.root,
287
291
  vitePlugins: options.vitePlugins,
288
292
  plugins: [
289
293
  workspacePackageResolutionPlugin(),
@@ -70,7 +70,7 @@ export function createNativeRouteMatcher(
70
70
  };
71
71
  }
72
72
 
73
- function normalizeNativeParams(
73
+ export function normalizeNativeParams(
74
74
  route: AppRoute,
75
75
  params: Record<string, string>,
76
76
  ): MatchedRoute["params"] {
@@ -82,13 +82,24 @@ function normalizeNativeParams(
82
82
  }
83
83
  const value = normalized[segment.name];
84
84
  if (typeof value === "string") {
85
- normalized[segment.name] = value.split("/").filter((part: string) => part !== "");
85
+ normalized[segment.name] = value
86
+ .split("/")
87
+ .filter((part: string) => part !== "")
88
+ .map((part: string) => safeDecodeURIComponent(part) ?? part);
86
89
  }
87
90
  }
88
91
 
89
92
  return normalized;
90
93
  }
91
94
 
95
+ function safeDecodeURIComponent(value: string): string | undefined {
96
+ try {
97
+ return decodeURIComponent(value);
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
92
103
  export function shouldUseNativeRouteMatcher(
93
104
  routeCount: number,
94
105
  mode: string | undefined,