@reckona/mreact-router 0.0.188 → 0.0.190

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 (69) hide show
  1. package/README.md +16 -3
  2. package/bin/mreact-router.js +23 -0
  3. package/dist/adapters/aws-lambda.d.ts +33 -13
  4. package/dist/adapters/aws-lambda.d.ts.map +1 -1
  5. package/dist/adapters/aws-lambda.js +128 -20
  6. package/dist/adapters/aws-lambda.js.map +1 -1
  7. package/dist/adapters/cloudflare.d.ts +18 -0
  8. package/dist/adapters/cloudflare.d.ts.map +1 -1
  9. package/dist/adapters/cloudflare.js.map +1 -1
  10. package/dist/adapters/edge.d.ts +2 -0
  11. package/dist/adapters/edge.d.ts.map +1 -1
  12. package/dist/adapters/edge.js.map +1 -1
  13. package/dist/adapters/node.d.ts +16 -2
  14. package/dist/adapters/node.d.ts.map +1 -1
  15. package/dist/adapters/node.js.map +1 -1
  16. package/dist/cache.d.ts +8 -0
  17. package/dist/cache.d.ts.map +1 -1
  18. package/dist/cache.js +34 -13
  19. package/dist/cache.js.map +1 -1
  20. package/dist/cli-options.d.ts +1 -1
  21. package/dist/cli-options.d.ts.map +1 -1
  22. package/dist/cli-options.js +21 -3
  23. package/dist/cli-options.js.map +1 -1
  24. package/dist/cli.js +0 -0
  25. package/dist/client.d.ts +1 -1
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +1 -0
  28. package/dist/client.js.map +1 -1
  29. package/dist/config.d.ts +4 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +6 -2
  32. package/dist/config.js.map +1 -1
  33. package/dist/index.d.ts +19 -16
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +15 -5
  36. package/dist/index.js.map +1 -1
  37. package/dist/link.d.ts +22 -1
  38. package/dist/link.d.ts.map +1 -1
  39. package/dist/link.js +130 -5
  40. package/dist/link.js.map +1 -1
  41. package/dist/render.d.ts.map +1 -1
  42. package/dist/render.js +514 -517
  43. package/dist/render.js.map +1 -1
  44. package/dist/request.d.ts +10 -0
  45. package/dist/request.d.ts.map +1 -0
  46. package/dist/request.js +6 -0
  47. package/dist/request.js.map +1 -0
  48. package/dist/typed-routes.d.ts +4 -5
  49. package/dist/typed-routes.d.ts.map +1 -1
  50. package/dist/typed-routes.js.map +1 -1
  51. package/dist/vite.d.ts +6 -0
  52. package/dist/vite.d.ts.map +1 -1
  53. package/dist/vite.js +4 -1
  54. package/dist/vite.js.map +1 -1
  55. package/package.json +19 -13
  56. package/src/adapters/aws-lambda.ts +227 -56
  57. package/src/adapters/cloudflare.ts +59 -0
  58. package/src/adapters/edge.ts +14 -0
  59. package/src/adapters/node.ts +44 -2
  60. package/src/cache.ts +39 -17
  61. package/src/cli-options.ts +28 -6
  62. package/src/client.ts +2 -1
  63. package/src/config.ts +10 -2
  64. package/src/index.ts +56 -10
  65. package/src/link.ts +205 -10
  66. package/src/render.ts +5 -7
  67. package/src/request.ts +35 -0
  68. package/src/typed-routes.ts +4 -4
  69. package/src/vite.ts +30 -1
@@ -1,7 +1,8 @@
1
1
  import type { AppRouterLogger, AppRouterLogEvent } from "./logger.js";
2
- import type {
3
- AppRouterBuildTarget,
4
- AppRouterClientSourceMapMode,
2
+ import {
3
+ appRouterBuildTargetMetadata,
4
+ type AppRouterBuildTarget,
5
+ type AppRouterClientSourceMapMode,
5
6
  } from "./config.js";
6
7
  import type { RequestHostPolicy } from "./serve.js";
7
8
 
@@ -178,6 +179,11 @@ export function parseCliArguments(argv: readonly string[]): ParsedCliArguments {
178
179
  }
179
180
 
180
181
  export function formatCliHelp(command?: string | undefined): string {
182
+ const defaultTargetLabel = formatBuildTargetDisplayList(
183
+ appRouterBuildTargetMetadata.defaultTargets,
184
+ );
185
+ const allTargetValues = formatBuildTargetValueList(appRouterBuildTargetMetadata.allTargets);
186
+
181
187
  if (command === "build") {
182
188
  return [
183
189
  "Usage: mreact-router build [appDir] [options]",
@@ -186,7 +192,7 @@ export function formatCliHelp(command?: string | undefined): string {
186
192
  "",
187
193
  "Options:",
188
194
  " --target=node|cloudflare|aws-lambda|all",
189
- " Select build artifacts. Defaults to node. aws-lambda writes .mreact/aws-lambda/mreact-handler.mjs and .mreact/server/import-policy.json.",
195
+ ` Select build artifacts. Defaults to ${defaultTargetLabel.toLowerCase()}. all selects ${allTargetValues}. aws-lambda writes .mreact/aws-lambda/mreact-handler.mjs and .mreact/server/import-policy.json.`,
190
196
  " --client-source-maps=none|hidden",
191
197
  " Control production client source map output.",
192
198
  " -h, --help",
@@ -196,6 +202,7 @@ export function formatCliHelp(command?: string | undefined): string {
196
202
  " mreact-router build --target=node",
197
203
  " mreact-router build --target=cloudflare",
198
204
  " mreact-router build --target=aws-lambda",
205
+ " mreact-router build --target=all",
199
206
  ].join("\n");
200
207
  }
201
208
 
@@ -272,7 +279,7 @@ export function formatCliHelp(command?: string | undefined): string {
272
279
  "",
273
280
  "Commands:",
274
281
  " dev [appDir] Start the development server.",
275
- " build [appDir] Build Node and Cloudflare artifacts by default.",
282
+ ` build [appDir] Build ${defaultTargetLabel} artifacts by default.`,
276
283
  " build --target=aws-lambda Build Lambda artifacts including generated handler and import policy.",
277
284
  " start [outDir] Serve built Node output.",
278
285
  " package aws-lambda --from .mreact --out .lambda",
@@ -290,6 +297,7 @@ export function formatCliHelp(command?: string | undefined): string {
290
297
  "Examples:",
291
298
  " mreact-router build --target=cloudflare",
292
299
  " mreact-router build --target=aws-lambda",
300
+ " mreact-router build --target=all",
293
301
  " mreact-router package aws-lambda --from .mreact --out .lambda",
294
302
  " mreact-router build --help",
295
303
  ].join("\n");
@@ -302,7 +310,21 @@ export function buildTargetsFromCliTarget(
302
310
  return undefined;
303
311
  }
304
312
 
305
- return target === "all" ? ["node", "cloudflare", "aws-lambda"] : [target];
313
+ return target === "all" ? appRouterBuildTargetMetadata.allTargets : [target];
314
+ }
315
+
316
+ function formatBuildTargetDisplayList(targets: readonly AppRouterBuildTarget[]): string {
317
+ return targets.map(formatBuildTargetDisplayName).join(", ");
318
+ }
319
+
320
+ function formatBuildTargetDisplayName(target: AppRouterBuildTarget): string {
321
+ if (target === "aws-lambda") return "AWS Lambda";
322
+ return target[0]?.toUpperCase() + target.slice(1);
323
+ }
324
+
325
+ function formatBuildTargetValueList(targets: readonly AppRouterBuildTarget[]): string {
326
+ if (targets.length < 2) return targets[0] ?? "";
327
+ return `${targets.slice(0, -1).join(", ")}, and ${targets.at(-1)}`;
306
328
  }
307
329
 
308
330
  export function resolveCliRequestLogMode(
package/src/client.ts CHANGED
@@ -103,7 +103,7 @@ export interface ClientRouteInferenceCache {
103
103
  transformedSourceByFile: Map<string, Promise<CachedClientRouteSource>>;
104
104
  }
105
105
 
106
- interface CachedClientRouteSource {
106
+ export interface CachedClientRouteSource {
107
107
  signature: string;
108
108
  source: string;
109
109
  }
@@ -3535,6 +3535,7 @@ function __mreactSyncRouteDataScripts(root, currentRouteId, nextRouteId) {
3535
3535
 
3536
3536
  function __mreactRouteDataScriptIds(...routeIds) {
3537
3537
  const ids = new Set();
3538
+ ids.add("__mreact_auth_session");
3538
3539
 
3539
3540
  for (const routeId of routeIds) {
3540
3541
  if (typeof routeId !== "string" || routeId === "") {
package/src/config.ts CHANGED
@@ -4,6 +4,14 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  * Selects the deployment runtime artifacts emitted by an app-router build.
5
5
  */
6
6
  export type AppRouterBuildTarget = "node" | "cloudflare" | "aws-lambda";
7
+
8
+ export const appRouterBuildTargetMetadata = {
9
+ allTargets: ["node", "cloudflare", "aws-lambda"],
10
+ defaultTargets: ["node"],
11
+ } as const satisfies {
12
+ allTargets: readonly AppRouterBuildTarget[];
13
+ defaultTargets: readonly AppRouterBuildTarget[];
14
+ };
7
15
  /**
8
16
  * Names a browser console method that can be stripped from client bundles.
9
17
  */
@@ -180,7 +188,7 @@ export function resolveBuildTargets(
180
188
  targets: readonly AppRouterBuildTarget[] | undefined,
181
189
  ): readonly AppRouterBuildTarget[] {
182
190
  if (targets === undefined) {
183
- return ["node"];
191
+ return appRouterBuildTargetMetadata.defaultTargets;
184
192
  }
185
193
 
186
194
  const uniqueTargets = [...new Set(targets)];
@@ -190,7 +198,7 @@ export function resolveBuildTargets(
190
198
  }
191
199
 
192
200
  for (const target of uniqueTargets) {
193
- if (target !== "node" && target !== "cloudflare" && target !== "aws-lambda") {
201
+ if (!appRouterBuildTargetMetadata.allTargets.some((supported) => supported === target)) {
194
202
  throw new Error(
195
203
  `Unsupported mreactRouter build target ${JSON.stringify(target)}. Expected "node", "cloudflare", or "aws-lambda".`,
196
204
  );
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export { defer, isDeferredLoaderData } from "./deferred.js";
7
7
  export type { DeferredLoaderData } from "./deferred.js";
8
8
  export { definePage } from "./types.js";
9
9
  export { Link, linkProps } from "./link.js";
10
+ export type { LinkSerializableAttribute, LinkSinkChild, LinkSinkProps } from "./link.js";
10
11
  export { href } from "./typed-routes.js";
11
12
  export { parseMultipartStream } from "./multipart.js";
12
13
  export type {
@@ -35,7 +36,7 @@ export {
35
36
  textError,
36
37
  throwNotFound,
37
38
  } from "./navigation.js";
38
- export type { ParseSchema } from "./navigation.js";
39
+ export type { MiddlewareNext, ParseSchema, RedirectOptions, RequestCookies } from "./navigation.js";
39
40
  export { createMemoryPrerenderStore } from "./prerender-store.js";
40
41
  export { getServerRuntimeState } from "./runtime-state.js";
41
42
  import {
@@ -57,31 +58,62 @@ import type {
57
58
  *
58
59
  * @deprecated Import session helpers from `@reckona/mreact-auth` instead.
59
60
  */
60
- export const createMemorySessionStore = createMemorySessionStoreInternal;
61
+ export function createMemorySessionStore<TData = unknown>(
62
+ options: MemorySessionStoreOptionsInternal = {},
63
+ ): SessionStoreInternal<TData> {
64
+ return createMemorySessionStoreInternal<TData>(options);
65
+ }
61
66
  /**
62
67
  * Creates a deprecated session record and cookie alias.
63
68
  *
64
69
  * @deprecated Import session helpers from `@reckona/mreact-auth` instead.
65
70
  */
66
- export const createSession = createSessionInternal;
71
+ export async function createSession<TData>(
72
+ response: Response,
73
+ store: SessionStoreInternal<TData>,
74
+ data: TData,
75
+ options: SessionCookieOptionsInternal = {},
76
+ ): Promise<SessionRecordInternal<TData>> {
77
+ return createSessionInternal(response, store, data, options);
78
+ }
67
79
  /**
68
80
  * Destroys a session through a deprecated router session alias.
69
81
  *
70
82
  * @deprecated Import session helpers from `@reckona/mreact-auth` instead.
71
83
  */
72
- export const destroySession = destroySessionInternal;
84
+ export async function destroySession<TData>(
85
+ request: Request,
86
+ response: Response,
87
+ store: SessionStoreInternal<TData>,
88
+ options: SessionCookieOptionsInternal = {},
89
+ ): Promise<void> {
90
+ return destroySessionInternal(request, response, store, options);
91
+ }
73
92
  /**
74
93
  * Reads a session through a deprecated router session alias.
75
94
  *
76
95
  * @deprecated Import session helpers from `@reckona/mreact-auth` instead.
77
96
  */
78
- export const getSession = getSessionInternal;
97
+ export async function getSession<TData>(
98
+ request: Request,
99
+ store: SessionStoreInternal<TData>,
100
+ options: SessionCookieOptionsInternal = {},
101
+ ): Promise<SessionRecordInternal<TData> | undefined> {
102
+ return getSessionInternal(request, store, options);
103
+ }
79
104
  /**
80
105
  * Rotates a session through a deprecated router session alias.
81
106
  *
82
107
  * @deprecated Import session helpers from `@reckona/mreact-auth` instead.
83
108
  */
84
- export const rotateSession = rotateSessionInternal;
109
+ export async function rotateSession<TData>(
110
+ request: Request,
111
+ response: Response,
112
+ store: SessionStoreInternal<TData>,
113
+ options: SessionCookieOptionsInternal = {},
114
+ ): Promise<SessionRecordInternal<TData> | undefined> {
115
+ return rotateSessionInternal(request, response, store, options);
116
+ }
85
117
  export type {
86
118
  AwsLambdaArtifactManifest,
87
119
  BuildAppPhase,
@@ -90,10 +122,15 @@ export type {
90
122
  BuildAppProgressEvent,
91
123
  BuildAppResult,
92
124
  BuiltImportPolicyArtifact,
125
+ BuiltPrerenderedRoute,
126
+ BuiltRouteSourceAnalysisSummary,
127
+ BuiltServerModuleArtifact,
128
+ BuiltServerModuleOutput,
93
129
  CloudflarePagesArtifactManifest,
94
130
  PackageAwsLambdaArtifactOptions,
95
131
  PackageCloudflarePagesArtifactOptions,
96
132
  } from "./build.js";
133
+ export type { AppRouterProjectOptions } from "./config.js";
97
134
  export type { ServerActionContext } from "./actions.js";
98
135
  export type {
99
136
  AppRouteHref,
@@ -106,7 +143,11 @@ export type {
106
143
  RouteParamsFor,
107
144
  RouteSearchParams,
108
145
  RouteSearchValue,
146
+ SegmentRouteParam,
109
147
  StaticHrefOptions,
148
+ ExtractRouteParams,
149
+ HasRouteParams,
150
+ Simplify,
110
151
  } from "./typed-routes.js";
111
152
  export type {
112
153
  InferLoaderData,
@@ -180,7 +221,7 @@ export type {
180
221
  } from "./navigation-state.js";
181
222
  export type { RouterRuntimeCacheStat } from "./runtime-cache.js";
182
223
  /** Configures the deprecated router memory session store alias. */
183
- export type { MemorySessionStoreOptionsInternal as MemorySessionStoreOptions };
224
+ export type { MemorySessionStoreOptions } from "./session.js";
184
225
  export type {
185
226
  AppRouterCspInlineNonceWarningLogEvent,
186
227
  AppRouterLogError,
@@ -211,23 +252,24 @@ export type {
211
252
  *
212
253
  * @deprecated Import session helpers and types from `@reckona/mreact-auth` instead.
213
254
  */
214
- export type SessionCookieOptions = SessionCookieOptionsInternal;
255
+ export type { SessionCookieOptions } from "./session.js";
215
256
  /**
216
257
  * Stores session data through the deprecated router session record alias.
217
258
  *
218
259
  * @deprecated Import session helpers and types from `@reckona/mreact-auth` instead.
219
260
  */
220
- export type SessionRecord<TData = unknown> = SessionRecordInternal<TData>;
261
+ export type { SessionRecord } from "./session.js";
221
262
  /**
222
263
  * Defines session persistence through the deprecated router session store alias.
223
264
  *
224
265
  * @deprecated Import session helpers and types from `@reckona/mreact-auth` instead.
225
266
  */
226
- export type SessionStore<TData = unknown> = SessionStoreInternal<TData>;
267
+ export type { SessionStore } from "./session.js";
227
268
  export { startDevServer } from "./dev-server.js";
228
269
  export type { StartDevServerOptions } from "./dev-server.js";
229
270
  export { renderAppRequest } from "./render.js";
230
271
  export type {
272
+ AppRouterRenderPreload,
231
273
  AppRouterResponseHook,
232
274
  AppRouterResponseHookContext,
233
275
  RenderAppRequestOptions,
@@ -250,6 +292,7 @@ export type {
250
292
  FileSystemPrerenderStoreOptions,
251
293
  KeyValuePrerenderStoreAdapter,
252
294
  KeyValuePrerenderStoreOptions,
295
+ MemoryPrerenderStoreEntry,
253
296
  MemoryPrerenderStoreOptions,
254
297
  } from "./prerender-store.js";
255
298
  export { createFileSystemPrerenderStore, createKeyValuePrerenderStore } from "./prerender-store.js";
@@ -260,9 +303,12 @@ export type {
260
303
  BuiltAppRuntimePreloadStrategy,
261
304
  RenderBuiltAppRequestOptions,
262
305
  RequestHostPolicy,
306
+ ResponseSinkStrategy,
263
307
  StartServerOptions,
264
308
  } from "./serve.js";
265
309
  export { matchRoute, scanAppRoutes } from "./routes.js";
310
+ export type { CachedClientRouteSource, ClientRouteInferenceCache } from "./client.js";
311
+ export type { RouteMatcher, ScanAppRoutesOptions } from "./routes.js";
266
312
  export type { AppFileConvention } from "./file-conventions.js";
267
313
  export type {
268
314
  AppAssetRoute,
package/src/link.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  } from "./typed-routes.js";
14
14
 
15
15
  const TRUSTED_LINK_HTML = Symbol.for("modular.react.router.trusted_link_html");
16
+ const VALID_LINK_ATTRIBUTE_NAME = /^[A-Za-z_][\w.\-:]*$/;
16
17
 
17
18
  /**
18
19
  * Selects when the app router should prefetch a linked route.
@@ -70,6 +71,37 @@ export interface LinkProps<Href extends string = LinkHref> extends LinkOptions<H
70
71
  [attribute: string]: unknown;
71
72
  }
72
73
 
74
+ /** Represents attributes that can be serialized when Link writes to an HtmlSink. */
75
+ export type LinkSerializableAttribute = boolean | number | string | null | undefined;
76
+
77
+ /** Represents child values that an HtmlSink Link can serialize without a browser DOM. */
78
+ export type LinkSinkChild =
79
+ | boolean
80
+ | number
81
+ | string
82
+ | null
83
+ | undefined
84
+ | TrustedLinkHtml
85
+ | readonly LinkSinkChild[];
86
+
87
+ /** Configures an HtmlSink Link without browser-only event handlers or refs. */
88
+ export interface LinkSinkProps extends LinkOptions<string> {
89
+ children?: LinkSinkChild;
90
+ class?: string | undefined;
91
+ className?: string | undefined;
92
+ download?: string | boolean | undefined;
93
+ id?: string | undefined;
94
+ name?: string | undefined;
95
+ rel?: string | undefined;
96
+ role?: string | undefined;
97
+ style?: Readonly<Record<string, boolean | number | string | null | undefined>> | undefined;
98
+ tabIndex?: number | undefined;
99
+ target?: string | undefined;
100
+ title?: string | undefined;
101
+ [ariaAttribute: `aria-${string}`]: LinkSerializableAttribute;
102
+ [dataAttribute: `data-${string}`]: LinkSerializableAttribute;
103
+ }
104
+
73
105
  /**
74
106
  * Converts router link options into anchor attributes consumed by the client navigation runtime.
75
107
  *
@@ -85,7 +117,9 @@ export function linkProps(options: LinkOptions<string>): Record<string, string>
85
117
  ...(options.scroll === undefined || options.scroll === "top"
86
118
  ? {}
87
119
  : { "data-mreact-scroll": options.scroll }),
88
- ...(options.transition === undefined || options.transition === false || options.transition === "none"
120
+ ...(options.transition === undefined ||
121
+ options.transition === false ||
122
+ options.transition === "none"
89
123
  ? {}
90
124
  : { "data-mreact-transition": options.transition }),
91
125
  };
@@ -108,19 +142,51 @@ export type ConcreteLinkHrefGuard<Href extends string> = [RegisteredAppRoutePath
108
142
  export function Link<const Href extends LinkHref>(
109
143
  props: LinkProps<Href> & ConcreteLinkHrefGuard<Href>,
110
144
  ): ReactCompatElement;
111
- export function Link(sink: HtmlSink, props: LinkProps<string>): void;
145
+ export function Link(sink: HtmlSink, props: LinkSinkProps): void;
112
146
  export function Link(
113
147
  sinkOrProps: HtmlSink | LinkProps<string>,
114
- maybeProps?: LinkProps<string>,
148
+ maybeProps?: LinkProps<string> | LinkSinkProps,
115
149
  ): ReactCompatElement | string | HTMLAnchorElement | void {
116
150
  if (maybeProps !== undefined) {
117
- (sinkOrProps as HtmlSink).append(renderLinkString(maybeProps));
151
+ reportUnsupportedSinkProps(maybeProps);
152
+ (sinkOrProps as HtmlSink).append(renderLinkString(maybeProps as LinkSinkProps));
118
153
  return;
119
154
  }
120
155
 
121
156
  return renderLink(sinkOrProps as LinkProps);
122
157
  }
123
158
 
159
+ function reportUnsupportedSinkProps(props: LinkProps<string> | LinkSinkProps): void {
160
+ const nodeEnv = (
161
+ globalThis as typeof globalThis & {
162
+ process?: { env?: { NODE_ENV?: string | undefined } | undefined } | undefined;
163
+ }
164
+ ).process?.env?.NODE_ENV;
165
+
166
+ if (nodeEnv === "production") {
167
+ return;
168
+ }
169
+
170
+ const unsupported = Object.entries(props)
171
+ .filter(
172
+ ([name, value]) =>
173
+ name === "ref" ||
174
+ typeof value === "function" ||
175
+ typeof value === "symbol" ||
176
+ isLinkEventAttributeName(name) ||
177
+ (name === "children" && !isSerializableSinkChild(value)) ||
178
+ (name !== "children" && name !== "style" && typeof value === "object" && value !== null) ||
179
+ (name !== "children" && !isSafeLinkAttributeName(attributeName(name))),
180
+ )
181
+ .map(([name]) => name);
182
+
183
+ if (unsupported.length > 0) {
184
+ console.warn(
185
+ `[MR_LINK_SINK_UNSUPPORTED_PROP] HtmlSink Link cannot serialize browser-only props: ${unsupported.join(", ")}. Render Link through JSX for event handlers and refs.`,
186
+ );
187
+ }
188
+ }
189
+
124
190
  (Link as typeof Link & { trustedHtml(html: string): TrustedLinkHtml }).trustedHtml = (
125
191
  html: string,
126
192
  ): TrustedLinkHtml => ({
@@ -141,13 +207,14 @@ function renderLink(props: LinkProps<string>): string | HTMLAnchorElement {
141
207
  return renderAnchorString(propsWithLinkAttrs);
142
208
  }
143
209
 
144
- function renderLinkString(props: LinkProps<string>): string {
210
+ function renderLinkString(props: LinkSinkProps): string {
145
211
  const { href, prefetch, reload, scroll, transition, ...rest } = props;
146
-
147
- return renderAnchorString({
212
+ const propsWithLinkAttrs = {
148
213
  ...rest,
149
214
  ...linkProps({ href, prefetch, reload, scroll, transition }),
150
- });
215
+ };
216
+
217
+ return `<a${renderAnchorAttributes(propsWithLinkAttrs)}>${renderSinkChildren(props.children)}</a>`;
151
218
  }
152
219
 
153
220
  function createAnchorElement(props: Record<string, unknown>): HTMLAnchorElement {
@@ -159,18 +226,53 @@ function createAnchorElement(props: Record<string, unknown>): HTMLAnchorElement
159
226
  continue;
160
227
  }
161
228
 
229
+ if (name === "ref") {
230
+ if (typeof value === "function") {
231
+ (value as (element: HTMLAnchorElement) => void)(anchor);
232
+ } else if (typeof value === "object" && value !== null && "current" in value) {
233
+ (value as { current: HTMLAnchorElement | null }).current = anchor;
234
+ }
235
+ continue;
236
+ }
237
+
238
+ if (/^on[A-Z]/.test(name) && typeof value === "function") {
239
+ const capture = name.endsWith("Capture");
240
+ const eventName = name.slice(2, capture ? -"Capture".length : undefined).toLowerCase();
241
+ anchor.addEventListener(eventName, value as EventListener, capture);
242
+ continue;
243
+ }
244
+
245
+ if (isLinkEventAttributeName(name)) {
246
+ reportRejectedBrowserEventProp(name);
247
+ continue;
248
+ }
249
+
250
+ if (name === "style" && typeof value === "object" && value !== null) {
251
+ for (const [property, styleValue] of Object.entries(value as Record<string, unknown>)) {
252
+ const cssProperty = linkStylePropertyName(property);
253
+ const cssValue = linkStyleValue(styleValue);
254
+ if (cssProperty !== undefined && cssValue !== undefined) {
255
+ anchor.style.setProperty(cssProperty, cssValue);
256
+ }
257
+ }
258
+ continue;
259
+ }
260
+
162
261
  if (!shouldSetAttribute(name, value)) {
163
262
  continue;
164
263
  }
165
264
 
166
265
  const attrName = attributeName(name);
266
+ if (!isSafeLinkAttributeName(attrName)) {
267
+ continue;
268
+ }
167
269
  const safeValue = safeUrlAttributeValue(attrName, String(value));
168
270
 
169
271
  if (safeValue === undefined) {
170
272
  continue;
171
273
  }
172
274
 
173
- anchor.setAttribute(attrName, safeValue);
275
+ anchor.setAttribute(attrName, value === true ? "" : safeValue);
174
276
  }
175
277
 
176
278
  return anchor;
@@ -204,11 +306,33 @@ function renderAnchorAttributes(props: Record<string, unknown>): string {
204
306
  const attrs: string[] = [];
205
307
 
206
308
  for (const [name, value] of Object.entries(props)) {
207
- if (name === "children" || !shouldSetAttribute(name, value)) {
309
+ if (name === "children" || isLinkEventAttributeName(name) || !shouldSetAttribute(name, value)) {
310
+ continue;
311
+ }
312
+
313
+ if (name !== "style" && typeof value === "object" && value !== null) {
208
314
  continue;
209
315
  }
210
316
 
211
317
  const attrName = attributeName(name);
318
+ if (!isSafeLinkAttributeName(attrName)) {
319
+ continue;
320
+ }
321
+ if (name === "style" && typeof value === "object" && value !== null) {
322
+ const style = Object.entries(value as Record<string, unknown>)
323
+ .flatMap(([property, styleValue]) => {
324
+ const cssProperty = linkStylePropertyName(property);
325
+ const cssValue = linkStyleValue(styleValue);
326
+ return cssProperty === undefined || cssValue === undefined
327
+ ? []
328
+ : [`${cssProperty}:${cssValue}`];
329
+ })
330
+ .join(";");
331
+ if (style !== "") {
332
+ attrs.push(`${escapeHtmlAttribute(attrName)}="${escapeHtmlAttribute(style)}"`);
333
+ }
334
+ continue;
335
+ }
212
336
  const attrValue = String(value);
213
337
  const safeValue = safeUrlAttributeValue(attrName, attrValue);
214
338
 
@@ -238,6 +362,42 @@ function attributeName(name: string): string {
238
362
  return name === "className" ? "class" : name;
239
363
  }
240
364
 
365
+ function isSafeLinkAttributeName(name: string): boolean {
366
+ return VALID_LINK_ATTRIBUTE_NAME.test(name);
367
+ }
368
+
369
+ function isLinkEventAttributeName(name: string): boolean {
370
+ return /^on/i.test(name);
371
+ }
372
+
373
+ function reportRejectedBrowserEventProp(name: string): void {
374
+ const nodeEnv = (
375
+ globalThis as typeof globalThis & {
376
+ process?: { env?: { NODE_ENV?: string | undefined } | undefined } | undefined;
377
+ }
378
+ ).process?.env?.NODE_ENV;
379
+
380
+ if (nodeEnv !== "production") {
381
+ console.warn(
382
+ `[MR_LINK_UNSAFE_EVENT_PROP] Link rejected event-like prop ${JSON.stringify(name)}. Pass a supported camel-cased function handler such as onClick instead.`,
383
+ );
384
+ }
385
+ }
386
+
387
+ function linkStylePropertyName(property: string): string | undefined {
388
+ const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
389
+ return /^(?:--[a-zA-Z0-9_-]+|[a-zA-Z][a-zA-Z0-9-]*)$/.test(cssProperty) ? cssProperty : undefined;
390
+ }
391
+
392
+ function linkStyleValue(value: unknown): string | undefined {
393
+ if (value === null || value === undefined || value === false) {
394
+ return undefined;
395
+ }
396
+
397
+ const cssValue = String(value);
398
+ return /[;{}]/.test(cssValue) ? undefined : cssValue;
399
+ }
400
+
241
401
  function renderChildren(child: LinkChild): string {
242
402
  if (child === null || child === undefined || typeof child === "boolean") {
243
403
  return "";
@@ -254,6 +414,41 @@ function renderChildren(child: LinkChild): string {
254
414
  return escapeHtmlText(child);
255
415
  }
256
416
 
417
+ function renderSinkChildren(child: LinkSinkChild): string {
418
+ if (child === null || child === undefined || typeof child === "boolean") {
419
+ return "";
420
+ }
421
+
422
+ if (Array.isArray(child)) {
423
+ return child.map(renderSinkChildren).join("");
424
+ }
425
+
426
+ if (isTrustedLinkHtml(child)) {
427
+ return child[TRUSTED_LINK_HTML];
428
+ }
429
+
430
+ if (typeof child === "object") {
431
+ return "";
432
+ }
433
+
434
+ return escapeHtmlText(String(child));
435
+ }
436
+
437
+ function isSerializableSinkChild(value: unknown): value is LinkSinkChild {
438
+ if (
439
+ value === null ||
440
+ value === undefined ||
441
+ typeof value === "boolean" ||
442
+ typeof value === "number" ||
443
+ typeof value === "string" ||
444
+ isTrustedLinkHtml(value)
445
+ ) {
446
+ return true;
447
+ }
448
+
449
+ return Array.isArray(value) && value.every(isSerializableSinkChild);
450
+ }
451
+
257
452
  function isTrustedLinkHtml(value: unknown): value is TrustedLinkHtml {
258
453
  return (
259
454
  typeof value === "object" &&
package/src/render.ts CHANGED
@@ -53,7 +53,8 @@ import {
53
53
  import { serverActionCookie } from "./csrf.js";
54
54
  import {
55
55
  type AppRouterCache,
56
- beginRouteCacheContext,
56
+ activeRouteCacheContext,
57
+ withRouteCacheContext,
57
58
  cachedRouteResponse,
58
59
  cacheRouteResponse,
59
60
  routeCacheKey,
@@ -981,8 +982,7 @@ async function renderAppRequestInternal(
981
982
  script: string | undefined;
982
983
  }
983
984
  | undefined;
984
- let routeCacheContext: ReturnType<typeof beginRouteCacheContext> | undefined;
985
-
985
+ return (await withRouteCacheContext(options.routeCache, async () => {
986
986
  try {
987
987
  if (matched.route.kind === "asset") {
988
988
  return await dispatchConventionAssetRoute({
@@ -1031,7 +1031,6 @@ async function renderAppRequestInternal(
1031
1031
  return methodResponse;
1032
1032
  }
1033
1033
 
1034
- routeCacheContext = beginRouteCacheContext(options.routeCache);
1035
1034
  const clientScript = options.clientScripts?.get(matched.route.path);
1036
1035
  const clientStyleSheets = options.clientStyles?.get(matched.route.path);
1037
1036
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
@@ -1607,7 +1606,7 @@ async function renderAppRequestInternal(
1607
1606
  preparedActions.csrfTokenIsNew === true,
1608
1607
  );
1609
1608
 
1610
- const effectiveCachePolicy = cachePolicy ?? routeCacheContext.cachePolicy;
1609
+ const effectiveCachePolicy = cachePolicy ?? activeRouteCacheContext()?.cachePolicy;
1611
1610
 
1612
1611
  const finalResponse = preparedActions.hasFormActions
1613
1612
  ? withRouteCacheHeader(response, effectiveCachePolicy)
@@ -1686,9 +1685,8 @@ async function renderAppRequestInternal(
1686
1685
  });
1687
1686
  emitRenderTiming(options, timing, response.status);
1688
1687
  return response;
1689
- } finally {
1690
- await routeCacheContext?.dispose();
1691
1688
  }
1689
+ })).value;
1692
1690
  }
1693
1691
 
1694
1692
  async function applyAppRouterResponseHook(
package/src/request.ts ADDED
@@ -0,0 +1,35 @@
1
+ /** Lightweight request/control-plane helpers that do not import router build or Vite modules. */
2
+ export { cacheControl, createMemoryRouteCache, revalidatePath } from "./cache.js";
3
+ export type {
4
+ AppRouterCache,
5
+ AppRouterCacheEntry,
6
+ CacheControlOptions,
7
+ MemoryRouteCacheOptions,
8
+ } from "./cache.js";
9
+ export { deleteCookie, parseCookieHeader, serializeCookie, setCookie } from "./cookies.js";
10
+ export type { CookieOptions } from "./cookies.js";
11
+ export { defer, isDeferredLoaderData } from "./deferred.js";
12
+ export type { DeferredLoaderData } from "./deferred.js";
13
+ export {
14
+ cookies,
15
+ headers,
16
+ html,
17
+ isNotFoundError,
18
+ isRedirectError,
19
+ json,
20
+ next,
21
+ notFound,
22
+ parseForm,
23
+ redirect,
24
+ redirect303,
25
+ redirectExternal,
26
+ rewrite,
27
+ textError,
28
+ throwNotFound,
29
+ } from "./navigation.js";
30
+ export type {
31
+ MiddlewareNext,
32
+ ParseSchema,
33
+ RedirectOptions,
34
+ RequestCookies,
35
+ } from "./navigation.js";