@reckona/mreact-router 0.0.202 → 0.0.204
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.
- package/README.md +58 -11
- package/dist/actions.d.ts +8 -3
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +125 -45
- package/dist/actions.js.map +1 -1
- package/dist/adapters/aws-lambda.d.ts +15 -4
- package/dist/adapters/aws-lambda.d.ts.map +1 -1
- package/dist/adapters/aws-lambda.js +301 -63
- package/dist/adapters/aws-lambda.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +5 -2
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +35 -11
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/edge.d.ts +1 -1
- package/dist/adapters/edge.d.ts.map +1 -1
- package/dist/adapters/edge.js.map +1 -1
- package/dist/adapters/node.d.ts +5 -3
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +2 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/static.d.ts.map +1 -1
- package/dist/adapters/static.js +24 -2
- package/dist/adapters/static.js.map +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +185 -13
- package/dist/build.js.map +1 -1
- package/dist/built-runtime.d.ts +2 -0
- package/dist/built-runtime.d.ts.map +1 -1
- package/dist/built-runtime.js +16 -0
- package/dist/built-runtime.js.map +1 -1
- package/dist/cache.d.ts +4 -2
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +216 -17
- package/dist/cache.js.map +1 -1
- package/dist/cli-options.d.ts +3 -0
- package/dist/cli-options.d.ts.map +1 -1
- package/dist/cli-options.js +10 -2
- package/dist/cli-options.js.map +1 -1
- package/dist/cli.js +14 -3
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +43 -3
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -2
- package/dist/config.js.map +1 -1
- package/dist/csp.d.ts +1 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +9 -0
- package/dist/csp.js.map +1 -1
- package/dist/dehydrate-policy.d.ts +3 -0
- package/dist/dehydrate-policy.d.ts.map +1 -0
- package/dist/dehydrate-policy.js +16 -0
- package/dist/dehydrate-policy.js.map +1 -0
- package/dist/dev-server.d.ts +7 -2
- package/dist/dev-server.d.ts.map +1 -1
- package/dist/dev-server.js +120 -11
- package/dist/dev-server.js.map +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +147 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +22 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +22 -8
- package/dist/logger.js.map +1 -1
- package/dist/metadata.d.ts +8 -0
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +60 -22
- package/dist/metadata.js.map +1 -1
- package/dist/navigation-marker.d.ts +2 -0
- package/dist/navigation-marker.d.ts.map +1 -0
- package/dist/navigation-marker.js +357 -0
- package/dist/navigation-marker.js.map +1 -0
- package/dist/navigation-runtime.d.ts +1 -1
- package/dist/navigation-runtime.d.ts.map +1 -1
- package/dist/navigation-runtime.js +1 -1
- package/dist/navigation-runtime.js.map +1 -1
- package/dist/navigation.d.ts +1 -0
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +1 -1
- package/dist/navigation.js.map +1 -1
- package/dist/node-server.d.ts +5 -2
- package/dist/node-server.d.ts.map +1 -1
- package/dist/node-server.js +75 -6
- package/dist/node-server.js.map +1 -1
- package/dist/prerender-entry.d.ts +17 -1
- package/dist/prerender-entry.d.ts.map +1 -1
- package/dist/prerender-entry.js +98 -3
- package/dist/prerender-entry.js.map +1 -1
- package/dist/render.d.ts +16 -2
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +142 -135
- package/dist/render.js.map +1 -1
- package/dist/serve.d.ts +11 -2
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +67 -41
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +52 -19
- package/dist/session.js.map +1 -1
- package/dist/upgrade.d.ts +59 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +282 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/vite-config.d.ts +2 -0
- package/dist/vite-config.d.ts.map +1 -1
- package/dist/vite-config.js +2 -0
- package/dist/vite-config.js.map +1 -1
- package/dist/vite.d.ts +4 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +117 -6
- package/dist/vite.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +146 -60
- package/src/adapters/aws-lambda.ts +426 -115
- package/src/adapters/cloudflare.ts +58 -12
- package/src/adapters/edge.ts +2 -0
- package/src/adapters/node.ts +8 -10
- package/src/adapters/static.ts +31 -2
- package/src/build.ts +217 -13
- package/src/built-runtime.ts +28 -0
- package/src/cache.ts +272 -19
- package/src/cli-options.ts +15 -2
- package/src/cli.ts +16 -2
- package/src/client.ts +47 -3
- package/src/config.ts +43 -6
- package/src/csp.ts +11 -0
- package/src/dehydrate-policy.ts +23 -0
- package/src/dev-server.ts +188 -15
- package/src/http.ts +172 -6
- package/src/index.ts +14 -1
- package/src/logger.ts +47 -10
- package/src/metadata.ts +125 -60
- package/src/navigation-marker.ts +423 -0
- package/src/navigation-runtime.ts +1 -0
- package/src/navigation.ts +1 -1
- package/src/node-server.ts +128 -22
- package/src/prerender-entry.ts +127 -5
- package/src/render.ts +859 -749
- package/src/serve.ts +112 -82
- package/src/session.ts +56 -25
- package/src/upgrade.ts +402 -3
- package/src/vite-config.ts +6 -0
- package/src/vite.ts +157 -5
package/README.md
CHANGED
|
@@ -51,6 +51,8 @@ export default defineConfig({
|
|
|
51
51
|
|
|
52
52
|
`allowedSourceDirs` can be omitted for the common route roots: `routesDir: "src/app"` defaults to `["src"]`, and `routesDir: "app"` defaults to `["app"]`. Keep it explicit when shared server/client modules live in additional project-root-relative directories.
|
|
53
53
|
|
|
54
|
+
Server query data embedded during hydration is visible to the browser. Set `dehydratePolicyModule` to a project-relative server module inside `allowedSourceDirs` when Vite development, prerendering, built Node output, and generated Lambda handlers must share one filter. The module must export a named `dehydrateOptions` object; Mreact bundles it as executable server code and fails closed when the configured module or export is invalid. Programmatic Node and Lambda handlers may instead pass `dehydrateOptions` directly.
|
|
55
|
+
|
|
54
56
|
`mreact-router build` reads this config. Pass `--target=node` for plain Node/container output, `--target=aws-lambda` for Lambda artifacts with a generated handler and import policy, or `--target=cloudflare` for Workers artifacts with a generated Worker module. Configure `buildTargets: ["aws-lambda"]`, `["cloudflare"]`, or another explicit target list in `mreactRouter()` when one deployment target should be the project default. Without an explicit target, build output includes only the Node-compatible server/client artifacts; Cloudflare and Lambda artifacts are opt-in. The legacy `appDir` shortcut remains available for tests and older direct programmatic usage, but it is deprecated. Use `projectRoot` + `routesDir` for new code. The shortcut is planned for removal after `0.1.0`.
|
|
55
57
|
|
|
56
58
|
Run `mreact-router boundaries` to inspect every route and its statically traceable rendered server and client components without writing build artifacts. The command loads the same Vite project configuration as `build`; pass an explicit app directory for the legacy direct-directory mode, or add `--json` for a deterministic versioned report with project-relative paths.
|
|
@@ -81,6 +83,8 @@ local import:
|
|
|
81
83
|
|
|
82
84
|
Internal anchors are intercepted by the app-router client runtime and update the changed route payload instead of forcing a full document reload. The runtime keeps head metadata and route-data scripts synchronized, restores scroll on back/forward navigation, and prefetches client route scripts for likely navigations when the browser is not in reduced-data mode. Client route assets share app-local module instances across development and production route chunks, so app-local modules imported by multiple client routes use the browser's normal single ESM instance across SPA navigation.
|
|
83
85
|
|
|
86
|
+
Server-only document responses preserve the page's original DOM shape, while client-navigation responses use the `data-mreact-route-id` wrapper required for swapping route content. Route caches, prerender storage, and downstream caches keep those response variants separate. Serialized hydration payloads carry only the request pathname, so shared HTML does not capture visitor-specific origin or query data; routes that read request input restore `props.request.url` from the current document URL immediately before hydration. Read the full URL from the server-side `request` when server route logic needs it, which also makes request dependence visible to the cache tracker.
|
|
87
|
+
|
|
84
88
|
Use `Link` or `linkProps()` when a route needs explicit navigation behavior:
|
|
85
89
|
|
|
86
90
|
```tsx
|
|
@@ -120,6 +124,7 @@ client-only code. Navigation observers are available from
|
|
|
120
124
|
- Imported functions passed to `<form action={...}>` are inferred as server actions, including supported typed registry references such as `actions.save`. Inferred action implementations stay in the server graph and are stripped from production client route bundles. A top-level `"use server"` directive is still supported and marks every exported function in that module as a server action. Production dispatch is fail-closed without a generated or explicit action manifest; direct integrations that intentionally expose every registered action must pass `serverActions: { allowedActions: "any" }`.
|
|
121
125
|
- Browser-enhanced form actions that mutate data, call `revalidatePath()` for the current route, and return normally can use a single-flight mutation response: the action POST response carries fresh navigation HTML marked with `x-mreact-action-single-flight`, so the browser updates the visible route without a second GET. Unsupported action flows still fall back to the existing `x-mreact-revalidate` invalidation and redirect/navigation behavior. This is unrelated to duplicate-submission coalescing or application idempotency keys.
|
|
122
126
|
- Server actions reject `Content-Length` values over `10 MiB` by default. Pass `serverActions: { maxBodyBytes }` to configure the limit.
|
|
127
|
+
- Server action nonces are atomically claimed immediately before application action code runs. Custom `serverActions.replayStore` implementations must provide an atomic `claim()` operation; replay returns `409`, while capacity or store failures fail closed with `503`. An invoked action consumes its nonce even if it later throws because its side effects may already have committed.
|
|
123
128
|
- Route handlers may return or throw standard `Response` objects from method exports such as `GET`, `POST`, or `ALL`. Dynamic route handlers receive decoded params as the second argument: `GET(request, { params })`; catch-all `$...slug` params are arrays of decoded segments. The context also includes `request`, `route`, and adapter `env`; Cloudflare route handlers additionally receive the Worker execution `context`.
|
|
124
129
|
- `redirect(location)` throws router control flow that defaults to HTTP 303 so auth and form flows continue as GET requests after the redirect. Use `redirect(location, { status: 307 })` when preserving the original method and body is required.
|
|
125
130
|
|
|
@@ -131,7 +136,35 @@ Inferred form actions are bound to the rendered form with a hidden token derived
|
|
|
131
136
|
|
|
132
137
|
Plain `multipart/form-data` route handlers do not automatically run the server-action CSRF guard. For cookie-authenticated upload forms that post directly to `route.ts`, render a hidden field named `formCsrfFieldName`, set `formCsrfCookie(token)` for the same token, and either call `validateFormCsrf(request, formData)` for small buffered forms or read the CSRF field with `parseMultipartStream()` before consuming a large file part. `request.formData()` buffers multipart file parts in memory; `parseMultipartStream(request, { fields })` keeps file parts as `ReadableStream<Uint8Array>` values with per-part and total byte limits, enforces bounded defaults, and exposes both the raw `filename` and a storage-oriented `safeFilename`. Workers APIs such as R2 require known-length streams, so `part.fixedLengthStream(length).readable` is only valid when the multipart part carries a `Content-Length` header or the handler can pass the exact byte length; otherwise use a bounded `arrayBuffer()` fallback for R2. Put the CSRF field before the file field when you want early rejection without reading the file body.
|
|
133
138
|
|
|
134
|
-
Use `startDevServer()` and `startServer()` when an app needs to attach HTTP upgrade handling to the same Node server as mreact routes. Both APIs accept `onUpgrade(request, socket, head)`, and `startDevServer()` keeps Vite middleware/HMR wired to the underlying server.
|
|
139
|
+
Use `startDevServer()` and `startServer()` when an app needs to attach HTTP upgrade handling to the same Node server as mreact routes. Both APIs accept `onUpgrade(request, socket, head, context)`, and `startDevServer()` keeps Vite middleware/HMR wired to the underlying server. WebSocket handshakes are not protected by the browser same-origin policy or CORS, and browsers send matching cookies with the handshake. mreact therefore rejects missing, opaque, malformed, and cross-origin browser upgrades by default before invoking the application handler. Validate `Origin` before reading a session or other credentials, then explicitly accept or decline every upgrade:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { startServer, validateHttpUpgradeOrigin } from "@reckona/mreact-router";
|
|
143
|
+
import { WebSocketServer } from "ws";
|
|
144
|
+
|
|
145
|
+
const allowedOrigins = ["https://app.example.com"];
|
|
146
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
147
|
+
|
|
148
|
+
await startServer({
|
|
149
|
+
outDir: ".mreact",
|
|
150
|
+
port: 3000,
|
|
151
|
+
upgradeOriginPolicy: { allowedOrigins },
|
|
152
|
+
onUpgrade(request, socket, head, context) {
|
|
153
|
+
const origin = validateHttpUpgradeOrigin(request, { allowedOrigins });
|
|
154
|
+
if (!origin.ok || request.url !== "/ws") {
|
|
155
|
+
return context.decline();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
context.accept();
|
|
159
|
+
// Authenticate only after Origin validation, then transfer the socket.
|
|
160
|
+
wss.handleUpgrade(request, socket, head, (websocket) => {
|
|
161
|
+
wss.emit("connection", websocket, request);
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The first three handler arguments remain compatible with synchronous handlers that write a handshake immediately. A handler that delays verification, including `ws` with asynchronous `verifyClient`, must call `context.accept()` synchronously before starting that work; a return with no synchronous socket action is treated as a decline. Thrown errors, rejected handlers, and undecided asynchronous handlers close the socket. Framework-generated structured fields omit request headers and query data, but an application error message is preserved, so handlers must not copy secrets into thrown errors. The default policy compares `Origin` with the validated request authority, so wildcard binds and trusted reverse proxies work when their Host and forwarded-protocol policies are configured correctly. `close()` waits for accepted upgrade sockets up to `upgradeCloseTimeoutMs` and then destroys only those sockets, while ordinary HTTP responses keep their normal drain behavior. Non-browser clients that omit `Origin` require an explicit `{ allowedOrigins, allowMissingOrigin: true }` policy and an alternate authentication boundary; `"unchecked"` is available only as an explicit compatibility escape hatch. Production route artifacts are bundled independently, so mutable custom-server state should live in `getServerRuntimeState(key, create)` rather than in a module-scoped `let` singleton.
|
|
135
168
|
|
|
136
169
|
Client boundary markers have the same SSR behavior. A plain `Foo.tsx` component runs on the server and does not hydrate by itself. Marking a component boundary with either `Foo.client.tsx` or a top-level `"use client";` directive makes that boundary hydrate on the client; SSR emits a `<template data-mreact-client-boundary="...">` placeholder plus serialized props, and the component JSX appears after hydration. When a client boundary wrapper receives server-renderable JSX children, those children remain visible as SSR DOM between the boundary marker and its props payload so the initial response stays paintable and indexable before hydration. Inferred boundaries from plain imported components can also keep an SSR fallback when every browser-global read is guarded by a `typeof window !== "undefined"` style check: a guarded if/ternary branch, a short-circuited logical expression, or statements after a guarded early exit such as `if (typeof window === "undefined") return;`. A guard on one of `window`, `document`, or `localStorage` covers the others. Unguarded reads, and guards the analyzer cannot follow such as an aliased `const isBrowser = typeof window !== "undefined"` condition, still disable fallback eligibility and produce a placeholder-only boundary. Combining `.client.tsx` with `"use client";` is redundant and does not change the boundary behavior. Route-level `"use client";` is separate: when it appears in a page, layout, or template route module, the whole route is emitted as a hydrated client route. In development, app-local client boundary dependencies are transformed through the mreact client compiler before Vite serves them, and generated mreact runtime imports are resolved by the router plugin, so server-rendered layouts and app shells can import hydrated client controls without adding React or mreact compat runtime aliases.
|
|
137
170
|
|
|
@@ -168,6 +201,8 @@ Route metadata is composed from parent layouts before the matched page. CSP dire
|
|
|
168
201
|
|
|
169
202
|
When `metadata.csp.nonce` is set, the CSP serializer appends that nonce to `script-src` and `style-src` only when those directives are present. A nonce on `style-src` disables the browser's implicit allowance for inline styles, so JSX-rendered `<style>` blocks must also carry a matching nonce or move to an external stylesheet. Use `metadata.head` for route-owned inline styles that need the framework nonce, for example `{ tag: "style", nonce: true, content: "body{color:red}" }`. If you only need script hardening, set `script-src` and leave `style-src` unset.
|
|
170
203
|
|
|
204
|
+
Responses whose enforced or report-only CSP contains a nonce are treated as visitor-dependent: the router marks them `Cache-Control: private, no-store`, excludes them from route caches and prerender manifests, and rejects older persisted entries that contain a nonce. This rule also applies when a final `onResponse` hook adds the CSP header. Hash-based and other nonce-free CSP responses remain eligible for shared caching.
|
|
205
|
+
|
|
171
206
|
Rendered routes include conservative default response headers: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, and `Permissions-Policy: camera=(), microphone=(), geolocation=()`. Use `metadata.security` to opt out of a default, override a policy, add `X-Frame-Options`, or enable HSTS for HTTPS requests. Security header values are validated before they are emitted.
|
|
172
207
|
|
|
173
208
|
Root app files can define crawler and install metadata without adding route handlers. `robots.ts`, `sitemap.ts`, and `manifest.ts` serve `/robots.txt`, `/sitemap.xml`, and `/manifest.webmanifest`; static `robots.txt`, `sitemap.xml`, `manifest.webmanifest`, `favicon.ico`, `icon.*`, `apple-icon.*`, and `opengraph-image.*` files are copied as public assets during builds. Route-local `opengraph-image.tsx` / `.ts` / `.jsx` / `.js` modules serve `/<route>/opengraph-image`, receive `{ params, request }`, may return a `Response`, string, or bytes, and are used as the default `og:image` when the page does not set one explicitly. Static files take precedence over generated metadata routes for the same URL, and icon files are also used as fallback route metadata when a page does not define its own icon or OpenGraph image.
|
|
@@ -206,7 +241,11 @@ export async function loader(context: LoaderContext<{ id: string }>): Promise<Us
|
|
|
206
241
|
}
|
|
207
242
|
|
|
208
243
|
export default definePage<typeof loader>(function UserPage(props) {
|
|
209
|
-
return
|
|
244
|
+
return (
|
|
245
|
+
<h1>
|
|
246
|
+
{props.params.id}: {props.data.name}
|
|
247
|
+
</h1>
|
|
248
|
+
);
|
|
210
249
|
});
|
|
211
250
|
```
|
|
212
251
|
|
|
@@ -257,7 +296,13 @@ Routes that render route-local `<Await>` directly or through app-local server co
|
|
|
257
296
|
|
|
258
297
|
```tsx
|
|
259
298
|
function FeedList(props) {
|
|
260
|
-
return
|
|
299
|
+
return (
|
|
300
|
+
<ul>
|
|
301
|
+
{props.items.map((item) => (
|
|
302
|
+
<li>{item}</li>
|
|
303
|
+
))}
|
|
304
|
+
</ul>
|
|
305
|
+
);
|
|
261
306
|
}
|
|
262
307
|
|
|
263
308
|
export default function Page() {
|
|
@@ -302,9 +347,7 @@ export default function Page() {
|
|
|
302
347
|
placeholderAs="div"
|
|
303
348
|
placeholder={<StorySkeleton count={batch.size} start={batch.start + 1} />}
|
|
304
349
|
>
|
|
305
|
-
{(resolved) =>
|
|
306
|
-
<StoryRows stories={resolved.items} start={resolved.start + 1} />
|
|
307
|
-
)}
|
|
350
|
+
{(resolved) => <StoryRows stories={resolved.items} start={resolved.start + 1} />}
|
|
308
351
|
</Await>
|
|
309
352
|
))}
|
|
310
353
|
</ol>
|
|
@@ -344,7 +387,11 @@ Server-only pages get the lightweight navigation runtime automatically — witho
|
|
|
344
387
|
import { Link } from "@reckona/mreact-router/link";
|
|
345
388
|
|
|
346
389
|
export default function Page() {
|
|
347
|
-
return
|
|
390
|
+
return (
|
|
391
|
+
<Link href="/docs" prefetch="viewport">
|
|
392
|
+
Docs
|
|
393
|
+
</Link>
|
|
394
|
+
);
|
|
348
395
|
}
|
|
349
396
|
```
|
|
350
397
|
|
|
@@ -360,7 +407,7 @@ The build manifest records this separately from `client: true`, emits a shared n
|
|
|
360
407
|
|
|
361
408
|
For Cloudflare Workers, `mreact-router build --target=cloudflare` emits `.mreact/cloudflare/worker.mjs`, `.mreact/cloudflare/route-modules.mjs`, and per-route module chunks for non-prerendered and dynamic App Router pages plus `route.ts` server routes and metadata conventions, so deployments do not need a hand-written Worker entrypoint or Vite-only `import.meta.glob()` transforms. Deploy that output with a Worker config whose `main` is `.mreact/cloudflare/worker.mjs` and whose `ASSETS` binding points at `.mreact/client`.
|
|
362
409
|
|
|
363
|
-
For Cloudflare Pages advanced mode, run `mreact-router package cloudflare-pages --from .mreact --out .mreact/pages` after `mreact-router build --target=cloudflare`, then deploy with `wrangler pages deploy .mreact/pages`. The package command creates a Pages output directory with bundled `_worker.js`, `_mreact/client/*` static route assets, root public assets, and `mreact-cloudflare-pages-artifact.json`. Pages provides the `ASSETS` binding to `_worker.js`; mreact's generated worker reads that binding and serves only generated client manifest assets, route-linked CSS, and public files. The package step bundles the generated Worker entry, route registry, route chunks, and router adapter code, so application deploy scripts do not need to copy `worker.mjs` manually or decide whether Wrangler should bundle package imports.
|
|
410
|
+
For Cloudflare Pages advanced mode, run `mreact-router package cloudflare-pages --from .mreact --out .mreact/pages` after `mreact-router build --target=cloudflare`, then deploy with `wrangler pages deploy .mreact/pages`. The package command creates a Pages output directory with bundled `_worker.js`, `_mreact/client/*` static route assets, root public assets, and `mreact-cloudflare-pages-artifact.json`. Pages provides the `ASSETS` binding to `_worker.js`; mreact's generated worker reads that binding and serves only generated client manifest assets, route-linked CSS, and public files. The package step bundles the generated Worker entry, route registry, route chunks, and router adapter code, so application deploy scripts do not need to copy `worker.mjs` manually or decide whether Wrangler should bundle package imports. Configure the `nodejs_compat` compatibility flag so the adapter can use native request-local storage and dependencies can use Cloudflare's Node.js compatibility runtime. Without it, mreact preserves query-client isolation by serializing renders and emits a one-time warning.
|
|
364
411
|
|
|
365
412
|
User Vite plugins from the project config are forwarded into router bundle builds after the `mreact-router` plugin itself is removed, so MDX-style syntax transforms and custom content loaders can participate in server, client, Cloudflare, and prerender bundles. Vite `define` values are also forwarded into generated Cloudflare server bundles, including `import.meta.env.*` aliases and plain define identifiers used by loaders and route handlers. Client assets, route stylesheet assets imported by pages/layouts/templates or special `error.tsx`/`not-found.tsx` boundaries, and copied public assets are served only when they appear in the generated manifest allow-list. Dynamic routes resolve modules through a build-time registry keyed by `route.file`, not by constructing module ids from request input. Generated Cloudflare route modules preserve app-router layout/template shells and named slots for both string and `stream = true` pages, including route-local `<Await>` boundaries and local server-component imports. Generated Cloudflare server route modules dispatch method exports such as `GET`, `POST`, and `ALL` with decoded dynamic params plus `context.env`, `context.context`, `context.request`, and `context.route`, so handlers can use Worker bindings directly. Cloudflare page rendering installs the per-request query client scope before loader, page, metadata, and document rendering, so server-rendered helpers can call `getQueryClient()` even in runtimes without `AsyncLocalStorage`. The Cloudflare adapter marks streamed HTML with `Cache-Control: no-transform` and `Content-Encoding: identity` so Workers compression does not gzip-buffer the first shell before placeholders can paint. Pass `onResponse` to add cross-cutting response headers to rendered, static, asset, reload, not-found, and error responses. If a generated Cloudflare route module cannot produce the `data-mreact-route-id` marker contract required by client navigation, the adapter returns a reload signal for `x-mreact-navigation: 1` requests so the browser performs a normal document navigation without first buffering the full HTML response.
|
|
366
413
|
|
|
@@ -392,15 +439,15 @@ await warmAwsLambdaRuntime(options);
|
|
|
392
439
|
export const handler = await createPreloadedAwsLambdaRequestHandler(options);
|
|
393
440
|
```
|
|
394
441
|
|
|
395
|
-
Production adapters enforce the app-router import policy when bundling loaders, middleware, route handlers, metadata, and server actions. `mreact-router build` writes `.mreact/server/import-policy.json` from server-side static imports. The built
|
|
442
|
+
Production adapters enforce the app-router import policy when bundling loaders, middleware, route handlers, metadata, and server actions. `mreact-router build` writes `.mreact/server/import-policy.json` from server-side static imports. The public built-output entry points, including `mreact-router start .mreact` and `renderBuiltAppRequest()`, read that generated policy automatically and merge its runtime packages with any explicit policy options. Custom adapters should compose the public render and preload APIs rather than depend on internal runtime constructors. Lambda handlers can use `importPolicy: "generated"`. You can still pass an explicit `importPolicy.allowedPackages` list when you need a hand-audited policy.
|
|
396
443
|
|
|
397
444
|
For Lambda deployments, build with `mreact-router build --target=aws-lambda` or `buildApp({ targets: ["aws-lambda"] })`. The generated buffered and streaming handlers warm the same middleware policy during module initialization by default and then use request handlers with duplicate preload disabled. The artifact manifest records `mreact-handler.handler` and `mreact-streaming-handler.handler` separately. Select a broader or disabled initialization policy with `--aws-lambda-preload=hot-route-requests|all|none` or `buildApp({ awsLambdaPreload: "all", targets: ["aws-lambda"] })` only after measuring the packaged handler; pair `hot-route-requests` with `--aws-lambda-preload-routes=/,/login` or `awsLambdaPreloadRoutes`. `none` disables generated module preload but still materializes the writable runtime directory during generated handler import. The package command preserves the build-generated policy unless package options explicitly override it. The target writes Node-compatible server/client output, `.mreact/server/import-policy.json`, and generated Lambda entries while skipping `.mreact/cloudflare` route modules, so loaders and server helpers may import Node-only dependencies such as database drivers without being bundled for the Workers runtime.
|
|
398
445
|
|
|
399
|
-
Package Lambda deployments with `mreact-router package aws-lambda --from .mreact --out .lambda` instead of pointing deployment tooling at the full project checkout. AWS Lambda enforces a 250 MB unzipped deployment package limit, and the runtime only needs `.mreact/`, `mreact-handler.mjs`, `package.json` / lockfiles, and production `node_modules`; `src/`, tests, dev dependencies, build caches, and Vite/Vitest/Playwright tooling are not required. `mreact-router build --target=aws-lambda` keeps compiled server route artifacts in `.mreact/server/server-modules/*.json` instead of embedding them in one large server manifest, writes compiled module bodies as hashed `.mjs` files, and keeps request/control artifacts separate from render artifacts so loader redirects do not read page render bundles. AWS Lambda request/control artifacts bundle the generated import-policy packages they use for loaders, middleware, route handlers, and metadata, reducing first-hit package resolution on sparse Lambda traffic while leaving render-only, server action, custom handler, and adapter dependencies in production `node_modules`. `createAwsLambdaRequestHandler()` treats `outDir` as read-only and materializes generated runtime files under `/tmp/mreact-router/<hash>/runtime` by default, with a `node_modules` symlink back to the deployed package root. Direct `createAwsLambdaRequestHandler()` and `createAwsLambdaStreamingRequestHandler()` handlers start only middleware and shared runtime preload in the background by default, so all-route preload work does not compete with the first user request. If a request arrives before preload finishes, middleware is resolved first, middleware responses or redirects return without loading the matched page artifact, and continuing requests load only the matched route's artifact closure. Static middleware `config.matcher` and `config.id` values are checked before importing the middleware module, so unmatched health checks and route-local middleware skips avoid evaluating heavyweight middleware dependencies. Route request artifacts omit page render exports, and built loader and route metadata artifacts are split, so loader redirects do not evaluate page-only or metadata-only dependencies before render or metadata is needed.
|
|
446
|
+
Package Lambda deployments with `mreact-router package aws-lambda --from .mreact --out .lambda` instead of pointing deployment tooling at the full project checkout. AWS Lambda enforces a 250 MB unzipped deployment package limit, and the runtime only needs `.mreact/`, `mreact-handler.mjs`, `package.json` / lockfiles, and production `node_modules`; `src/`, tests, dev dependencies, build caches, and Vite/Vitest/Playwright tooling are not required. `mreact-router build --target=aws-lambda` keeps compiled server route artifacts in `.mreact/server/server-modules/*.json` instead of embedding them in one large server manifest, writes compiled module bodies as hashed `.mjs` files, and keeps request/control artifacts separate from render artifacts so loader redirects do not read page render bundles. AWS Lambda request/control artifacts bundle the generated import-policy packages they use for loaders, middleware, route handlers, and metadata, reducing first-hit package resolution on sparse Lambda traffic while leaving render-only, server action, custom handler, and adapter dependencies in production `node_modules`. `createAwsLambdaRequestHandler()` treats `outDir` as read-only and materializes generated runtime files under `/tmp/mreact-router/<hash>/runtime` by default, with a `node_modules` symlink back to the deployed package root. Direct `createAwsLambdaRequestHandler()` and `createAwsLambdaStreamingRequestHandler()` handlers start only middleware and shared runtime preload in the background by default, so all-route preload work does not compete with the first user request. If a request arrives before preload finishes, middleware is resolved first, middleware responses or redirects return without loading the matched page artifact, and continuing requests load only the matched route's artifact closure. Static middleware `config.matcher` and `config.id` values are checked before importing the middleware module, so unmatched health checks and route-local middleware skips avoid evaluating heavyweight middleware dependencies. Route request artifacts omit page render exports, and built loader and route metadata artifacts are split, so loader redirects do not evaluate page-only or metadata-only dependencies before render or metadata is needed. Every loader's outer result settles before page component transforms or render imports for non-stream routes and stream routes, including stream routes with a loading boundary and controls reached through imported helpers. Return non-critical promises through `defer()` and render them with `<Await>` when they should continue behind the loading boundary. `createPreloadedAwsLambdaRequestHandler()` validates the first event before it starts full preload. When a deployment intentionally accepts extra Lambda initialization work to reduce first-valid-request latency, call `await warmAwsLambdaRuntime(options)` during controlled initialization before creating the handler. Pass `runtimeDir` only when you need to control that writable cache location. With pnpm, run `pnpm --dir .lambda install --prod --frozen-lockfile --ignore-scripts --config.node-linker=hoisted`. pnpm's default isolated linker is symlink-heavy, so verify the artifact's symlink count with `find .lambda -type l | wc -l` and measure actual file bytes in addition to `du -sh .lambda` before upload. Packages listed in the generated import policy may still be needed by render-only modules, inferred server actions, custom handlers, or adapter code, so keep them installed in that production artifact.
|
|
400
447
|
|
|
401
448
|
When a deployment needs custom adapter options such as `serverActions.authorize`, `onResponse`, `errorHandler`, or `allowedHosts`, create an app handler and package it with `mreact-router package aws-lambda --from .mreact --out .lambda --handler src/lambda.ts`. The package command bundles that handler into `.lambda/mreact-handler.mjs`, resolves app-local TypeScript imports with Vite-style extensionless paths, and keeps package imports external so production `node_modules` remains the runtime dependency boundary. If the handler imports the same app server modules that routes also import, those modules are compiled into the handler bundle separately from the mreact route artifacts; use `getServerRuntimeState()` for state that must be shared through the router runtime instead of relying on module singletons.
|
|
402
449
|
|
|
403
|
-
Configure `allowedHosts` for production Lambda deployments so absolute URLs, redirects, cache keys, and metadata cannot be derived from attacker-controlled Host input. The Lambda adapter uses `Host` by default and only reads `x-forwarded-host` when `hostPolicy: "trusted-proxy"` is set.
|
|
450
|
+
Configure `allowedHosts` for production Lambda deployments so absolute URLs, redirects, cache keys, and metadata cannot be derived from attacker-controlled Host input. The Lambda adapter uses `Host` by default and only reads `x-forwarded-host` when `hostPolicy: "trusted-proxy"` is set. API Gateway HTTP API v2 and Lambda Function URL requests default to HTTPS because `requestContext.http.protocol` is an HTTP version such as `HTTP/1.1`, not a URL scheme. The adapter ignores `x-forwarded-proto` unless `trustForwardedProto: true` is configured; enable that option only behind a proxy that overwrites the header.
|
|
404
451
|
|
|
405
452
|
Use the Lambda `preload` option to tune that trade-off. Direct handlers default to `"middleware"` so cold starts do not kick off all route module imports in parallel with the first request. `createPreloadedAwsLambdaRequestHandler()` and `createPreloadedAwsLambdaStreamingRequestHandler()` default to `"all"` after event validation. Call `warmAwsLambdaRuntime()` explicitly when initialization-time full preload is an intentional deployment choice. Set `preload: "none"` to disable background preload, `preload: "all"` only when you have measured that all-route background preload helps the deployed function, `preload: { mode: "hot-routes", routes: ["/", "/dashboard"] }` with `warmAwsLambdaRuntime()` to warm selected route closures including render modules before valid traffic, or `preload: { mode: "hot-route-requests", routes: ["/", "/login"] }` to warm only middleware plus selected route loader/server-route request modules without evaluating page/layout render modules. For direct handlers that cannot use top-level async initialization, add `wait: "before-render"` to an object preload strategy to let page-rendering invocations wait for the already-started preload after loader redirects have had a chance to return; add `wait: "first-request"` only when the first invocation can pay the full preload cost. Lambda request timing reports `first-request` waits as `preloadWaitMs`, and render timing reports `before-render` waits as `preloadWaitMs`.
|
|
406
453
|
|
package/dist/actions.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ServerActionHandlerOptions, type ServerActionReplayStore, type ServerActionRequestReference } from "@reckona/mreact-server";
|
|
1
|
+
import { type ServerActionHandlerOptions, type ServerActionReplayClaim, type ServerActionReplayStore, type ServerActionRequestReference } from "@reckona/mreact-server";
|
|
2
2
|
import { type AppRouterCache } from "./cache.js";
|
|
3
3
|
import { type AppRouterImportPolicy } from "./import-policy.js";
|
|
4
4
|
import { type RequestCookies } from "./navigation.js";
|
|
@@ -19,10 +19,15 @@ declare class BoundedReplayStore {
|
|
|
19
19
|
private readonly ttlMs;
|
|
20
20
|
private readonly maxEntries;
|
|
21
21
|
private readonly entries;
|
|
22
|
+
private capacityWarningActive;
|
|
23
|
+
private warnedLeaseReclamation;
|
|
24
|
+
private readonly minimumRetentionMs;
|
|
22
25
|
constructor(ttlMs: number, maxEntries: number);
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
claim(value: string): ServerActionReplayClaim;
|
|
27
|
+
clear(): void;
|
|
25
28
|
size(): number;
|
|
29
|
+
private warnCapacity;
|
|
30
|
+
private warnLeaseReclamation;
|
|
26
31
|
}
|
|
27
32
|
export declare function __clearDefaultReplayStore(): void;
|
|
28
33
|
export declare function __readDefaultReplayStore(): BoundedReplayStore;
|
package/dist/actions.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"AAQA,OAAO,
|
|
1
|
+
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"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"}
|
package/dist/actions.js
CHANGED
|
@@ -3,12 +3,12 @@ import { access, readdir, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, join, relative, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { collectFormActionExpressionReferences, hasModuleDirective, } from "@reckona/mreact-compiler";
|
|
6
|
-
import { createServerActionHandler, } from "@reckona/mreact-server";
|
|
6
|
+
import { createServerActionHandler, ensureServerActionReplayStoreContract, } from "@reckona/mreact-server";
|
|
7
7
|
import { bundleRouterModule } from "./bundle-pipeline.js";
|
|
8
8
|
import { withRouteCacheContext } from "./cache.js";
|
|
9
9
|
import { fileImportMetaUrlPlugin, importAppRouterSourceModule } from "./module-runner.js";
|
|
10
10
|
import { createAppRouterImportPolicyPlugin } from "./import-policy.js";
|
|
11
|
-
import { cookies } from "./navigation.js";
|
|
11
|
+
import { cookies, isSafeInternalRedirect } from "./navigation.js";
|
|
12
12
|
export { createFormCsrfToken, formCsrfCookie, formCsrfFieldName, serverActionCookie, validateFormCsrf, } from "./csrf.js";
|
|
13
13
|
import { formCsrfFieldName, readExistingFormCsrfToken, serverActionCookieName, validateFormCsrf, } from "./csrf.js";
|
|
14
14
|
import { collectRuntimeInferredServerActions, } from "./server-action-inference.js";
|
|
@@ -45,6 +45,7 @@ const actionTokenSecret = configuredActionTokenSecret(process.env.MREACT_SERVER_
|
|
|
45
45
|
// traffic does not trip false-positive 409s.
|
|
46
46
|
const DEFAULT_REPLAY_TTL_MS = 10 * 60 * 1000;
|
|
47
47
|
const DEFAULT_REPLAY_MAX_ENTRIES = 50_000;
|
|
48
|
+
const DEFAULT_REPLAY_MINIMUM_RETENTION_MS = 60_000;
|
|
48
49
|
const DEFAULT_ACTION_BODY_MAX_BYTES = 10 * 1024 * 1024;
|
|
49
50
|
const DEFAULT_ACTION_FORM_MAX_FIELDS = 1_000;
|
|
50
51
|
let warnedUnrestrictedServerActions = false;
|
|
@@ -67,51 +68,107 @@ class BoundedReplayStore {
|
|
|
67
68
|
ttlMs;
|
|
68
69
|
maxEntries;
|
|
69
70
|
entries = new Map();
|
|
71
|
+
capacityWarningActive = false;
|
|
72
|
+
warnedLeaseReclamation = false;
|
|
73
|
+
minimumRetentionMs;
|
|
70
74
|
constructor(ttlMs, maxEntries) {
|
|
71
75
|
this.ttlMs = ttlMs;
|
|
72
76
|
this.maxEntries = maxEntries;
|
|
77
|
+
this.minimumRetentionMs = Math.min(ttlMs, DEFAULT_REPLAY_MINIMUM_RETENTION_MS);
|
|
73
78
|
}
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
claim(value) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (this.entries.size < this.maxEntries) {
|
|
82
|
+
this.capacityWarningActive = false;
|
|
83
|
+
}
|
|
84
|
+
const existing = this.entries.get(value);
|
|
85
|
+
if (existing?.state === "completed" && existing.expiresAt <= now) {
|
|
79
86
|
this.entries.delete(value);
|
|
80
|
-
return false;
|
|
81
87
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
else if (existing?.state === "in-flight" && existing.leaseExpiresAt <= now) {
|
|
89
|
+
this.entries.delete(value);
|
|
90
|
+
this.warnLeaseReclamation();
|
|
91
|
+
}
|
|
92
|
+
if (this.entries.has(value))
|
|
93
|
+
return { status: "replay" };
|
|
86
94
|
if (this.entries.size >= this.maxEntries) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
95
|
+
this.warnCapacity();
|
|
96
|
+
let reclaimableCompletedKey;
|
|
97
|
+
for (const [key, entry] of this.entries) {
|
|
98
|
+
if ((entry.state === "completed" && entry.expiresAt <= now) ||
|
|
99
|
+
(entry.state === "in-flight" && entry.leaseExpiresAt <= now)) {
|
|
100
|
+
this.entries.delete(key);
|
|
101
|
+
}
|
|
102
|
+
else if (reclaimableCompletedKey === undefined &&
|
|
103
|
+
entry.state === "completed" &&
|
|
104
|
+
entry.protectedUntil <= now) {
|
|
105
|
+
reclaimableCompletedKey = key;
|
|
99
106
|
}
|
|
100
107
|
}
|
|
108
|
+
if (this.entries.size >= this.maxEntries && reclaimableCompletedKey !== undefined) {
|
|
109
|
+
this.entries.delete(reclaimableCompletedKey);
|
|
110
|
+
}
|
|
111
|
+
if (this.entries.size >= this.maxEntries)
|
|
112
|
+
return { status: "capacity-exceeded" };
|
|
101
113
|
}
|
|
102
|
-
|
|
114
|
+
const entry = { state: "in-flight", leaseExpiresAt: now + this.ttlMs };
|
|
115
|
+
this.entries.set(value, entry);
|
|
116
|
+
let finalized = false;
|
|
117
|
+
return {
|
|
118
|
+
status: "claimed",
|
|
119
|
+
finalize: () => {
|
|
120
|
+
if (finalized)
|
|
121
|
+
return;
|
|
122
|
+
finalized = true;
|
|
123
|
+
if (this.entries.get(value) === entry) {
|
|
124
|
+
const completedAt = Date.now();
|
|
125
|
+
this.entries.delete(value);
|
|
126
|
+
this.entries.set(value, {
|
|
127
|
+
state: "completed",
|
|
128
|
+
expiresAt: completedAt + this.ttlMs,
|
|
129
|
+
protectedUntil: completedAt + this.minimumRetentionMs,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Exposed for tests; not part of the ServerActionReplayStore interface.
|
|
136
|
+
clear() {
|
|
137
|
+
this.entries.clear();
|
|
138
|
+
this.capacityWarningActive = false;
|
|
139
|
+
this.warnedLeaseReclamation = false;
|
|
103
140
|
}
|
|
104
141
|
// Exposed for tests; not part of the ServerActionReplayStore interface.
|
|
105
142
|
size() {
|
|
106
143
|
return this.entries.size;
|
|
107
144
|
}
|
|
145
|
+
warnCapacity() {
|
|
146
|
+
if (this.capacityWarningActive)
|
|
147
|
+
return;
|
|
148
|
+
this.capacityWarningActive = true;
|
|
149
|
+
let completed = 0;
|
|
150
|
+
let inFlight = 0;
|
|
151
|
+
for (const entry of this.entries.values()) {
|
|
152
|
+
if (entry.state === "completed")
|
|
153
|
+
completed += 1;
|
|
154
|
+
else
|
|
155
|
+
inFlight += 1;
|
|
156
|
+
}
|
|
157
|
+
console.error(`mreact-router: default server action replay store reached capacity (size=${this.entries.size}, maxEntries=${this.maxEntries}, completed=${completed}, inFlight=${inFlight}, minimumRetentionMs=${this.minimumRetentionMs}). Completed claims remain exact through minimumRetentionMs and may be reclaimed under pressure afterward; expired in-flight leases may also be reclaimed. Applications requiring exact protection for the full action lifetime and retention window must use a shared replay store.`);
|
|
158
|
+
}
|
|
159
|
+
warnLeaseReclamation() {
|
|
160
|
+
if (this.warnedLeaseReclamation)
|
|
161
|
+
return;
|
|
162
|
+
this.warnedLeaseReclamation = true;
|
|
163
|
+
console.error(`mreact-router: default server action replay store reclaimed an expired in-flight claim after leaseMs=${this.ttlMs}; exact duplicate-execution protection no longer applies to that abandoned claim.`);
|
|
164
|
+
}
|
|
108
165
|
}
|
|
109
166
|
const usedFormActionNonces = new BoundedReplayStore(DEFAULT_REPLAY_TTL_MS, DEFAULT_REPLAY_MAX_ENTRIES);
|
|
110
167
|
// Test helpers: drop all entries between cases / expose the bounded store
|
|
111
168
|
// so tests can drive its eviction semantics directly. Not part of the
|
|
112
169
|
// public surface (prefixed with `__`).
|
|
113
170
|
export function __clearDefaultReplayStore() {
|
|
114
|
-
usedFormActionNonces.
|
|
171
|
+
usedFormActionNonces.clear();
|
|
115
172
|
}
|
|
116
173
|
export function __readDefaultReplayStore() {
|
|
117
174
|
return usedFormActionNonces;
|
|
@@ -272,7 +329,7 @@ async function dispatchServerActionRequestWithoutCacheContext(options) {
|
|
|
272
329
|
: { allowedActions: jsonAllowedServerActions(options.serverActions.allowedActions) }),
|
|
273
330
|
csrf: { cookieName: serverActionCookieName() },
|
|
274
331
|
maxBodyBytes: options.serverActions?.maxBodyBytes ?? DEFAULT_ACTION_BODY_MAX_BYTES,
|
|
275
|
-
replayProtection: {
|
|
332
|
+
replayProtection: { store: replayStore },
|
|
276
333
|
});
|
|
277
334
|
return handle(options.request);
|
|
278
335
|
}
|
|
@@ -321,10 +378,6 @@ async function dispatchServerActionRequestWithoutCacheContext(options) {
|
|
|
321
378
|
})) {
|
|
322
379
|
return jsonResponse({ ok: false, error: "Unknown server action." }, 404);
|
|
323
380
|
}
|
|
324
|
-
const nonceResponse = validateFormNonce(formData, options.serverActions?.replayStore ?? usedFormActionNonces);
|
|
325
|
-
if (nonceResponse !== undefined) {
|
|
326
|
-
return nonceResponse;
|
|
327
|
-
}
|
|
328
381
|
let registry;
|
|
329
382
|
try {
|
|
330
383
|
registry = await loadServerActionRegistry({
|
|
@@ -351,19 +404,35 @@ async function dispatchServerActionRequestWithoutCacheContext(options) {
|
|
|
351
404
|
if (authorizationResponse !== undefined) {
|
|
352
405
|
return authorizationResponse;
|
|
353
406
|
}
|
|
407
|
+
const replayClaim = await claimFormNonce(nonce, options.serverActions?.replayStore ?? usedFormActionNonces);
|
|
408
|
+
if (replayClaim instanceof Response)
|
|
409
|
+
return replayClaim;
|
|
410
|
+
let actionResponse;
|
|
411
|
+
let actionError;
|
|
354
412
|
try {
|
|
355
413
|
const value = await action(actionFormData, createServerActionContext(options.request));
|
|
356
414
|
if (value instanceof Response) {
|
|
357
|
-
|
|
415
|
+
actionResponse = value;
|
|
416
|
+
}
|
|
417
|
+
else if (value === undefined || value === null) {
|
|
418
|
+
actionResponse = redirectToFormReferer(options.request);
|
|
358
419
|
}
|
|
359
|
-
|
|
360
|
-
|
|
420
|
+
else {
|
|
421
|
+
actionResponse = jsonResponse({ ok: true, value }, 200);
|
|
361
422
|
}
|
|
362
|
-
return jsonResponse({ ok: true, value }, 200);
|
|
363
423
|
}
|
|
364
424
|
catch (error) {
|
|
365
|
-
|
|
425
|
+
actionError = error;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
await replayClaim.finalize();
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return replayStoreUnavailableResponse();
|
|
366
432
|
}
|
|
433
|
+
return actionError === undefined
|
|
434
|
+
? actionResponse
|
|
435
|
+
: jsonResponse({ ok: false, error: actionError instanceof Error ? actionError.message : String(actionError) }, 500);
|
|
367
436
|
}
|
|
368
437
|
function createServerActionContext(request) {
|
|
369
438
|
const clientIp = clientIpFromRequest(request);
|
|
@@ -509,8 +578,9 @@ function sameOriginRefererPath(request) {
|
|
|
509
578
|
try {
|
|
510
579
|
const requestUrl = new URL(request.url);
|
|
511
580
|
const refererUrl = new URL(referer, requestUrl);
|
|
512
|
-
|
|
513
|
-
|
|
581
|
+
const location = `${refererUrl.pathname}${refererUrl.search}`;
|
|
582
|
+
return refererUrl.origin === requestUrl.origin && isSafeInternalRedirect(location)
|
|
583
|
+
? location
|
|
514
584
|
: undefined;
|
|
515
585
|
}
|
|
516
586
|
catch {
|
|
@@ -986,16 +1056,26 @@ function validateServerActionRequestOrigin(request) {
|
|
|
986
1056
|
return jsonResponse({ ok: false, error: "Origin not allowed." }, 403);
|
|
987
1057
|
}
|
|
988
1058
|
}
|
|
989
|
-
function
|
|
990
|
-
|
|
991
|
-
if (
|
|
992
|
-
return
|
|
1059
|
+
async function claimFormNonce(nonce, replayStore) {
|
|
1060
|
+
let claim;
|
|
1061
|
+
if (!ensureServerActionReplayStoreContract(replayStore)) {
|
|
1062
|
+
return replayStoreUnavailableResponse();
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
claim = await replayStore.claim(nonce);
|
|
993
1066
|
}
|
|
994
|
-
|
|
1067
|
+
catch {
|
|
1068
|
+
return replayStoreUnavailableResponse();
|
|
1069
|
+
}
|
|
1070
|
+
if (claim.status === "replay") {
|
|
995
1071
|
return jsonResponse({ ok: false, error: "Server action nonce was already used." }, 409);
|
|
996
1072
|
}
|
|
997
|
-
|
|
998
|
-
|
|
1073
|
+
if (claim.status === "capacity-exceeded")
|
|
1074
|
+
return replayStoreUnavailableResponse();
|
|
1075
|
+
return claim;
|
|
1076
|
+
}
|
|
1077
|
+
function replayStoreUnavailableResponse() {
|
|
1078
|
+
return new Response(JSON.stringify({ ok: false, error: "Server action replay protection is unavailable." }), { status: 503, headers: { "content-type": "application/json", "retry-after": "1" } });
|
|
999
1079
|
}
|
|
1000
1080
|
function cleanActionFormData(formData) {
|
|
1001
1081
|
const cleaned = new FormData();
|