diff --git a/CLOUDFLARE_PARITY.md b/CLOUDFLARE_PARITY.md index 4dfcccf..9dd4da6 100644 --- a/CLOUDFLARE_PARITY.md +++ b/CLOUDFLARE_PARITY.md @@ -22,6 +22,7 @@ is production-ready and what is not**. | Static / prerendered pages (`○`, `●`) | Served from the Worker + R2 | | Client reference manifests | Wired for Next 14 (`.json`) **and** Next 15 (`.js`) — v0.14+. (Manifest plumbing only; whether a given app *hydrates* also depends on its own client code + build-time env.) | | API routes (`ƒ /api/*`) | Dispatched by the Worker | +| Server Actions — execution | Resolved + invoked the way Next does (`__next_app__.require(moduleId)[actionId]`) and the return value is Flight-encoded. Side effects run: mutations, `revalidatePath`/`revalidateTag`, cookies, `redirect()`. **Partial:** JS-invoked actions with bound args / Flight-encoded arg bodies, and full progressive-enhancement re-render of the page after the action, are still incomplete (the latter is coupled to the dynamic-SSR gap below). | | Middleware | Runs ahead of the dispatcher | | Static assets (`/_next/static`, `/public`) | Uploaded to R2, content-hash skipped | | Custom domains | Auto-attached + re-pointed (`override_existing_origin`) | diff --git a/shared/nextcompile/actions.go b/shared/nextcompile/actions.go index 4c2d875..04f106f 100644 --- a/shared/nextcompile/actions.go +++ b/shared/nextcompile/actions.go @@ -38,13 +38,14 @@ import ( // "encryptionKey": "..." // } // -// NOTE ON EXECUTION: the compiled module does NOT expose the action as a named -// export — `.next/server/app/page.js` exports Next's own machinery -// (decodeReply/decodeAction/serverHooks/…), not the action function or the -// moduleId. So actions.mjs's `mod[entry.export]` model cannot invoke a real -// action; correct execution needs Next's action-runtime machinery. Tracked as a -// runtime milestone. Action.Export below carries the moduleId for reference/ -// stability only — it is not a callable export. +// EXECUTION: the compiled page module does NOT expose the action as a named +// export — it exposes the webpack require as `__next_app__.require`, and the +// action worker module (keyed by its moduleId) exports the actions keyed by +// actionId. So actions.mjs resolves + invokes via +// `mod.__next_app__.require()[]` (the way Next itself does), +// then Flight-encodes the return value with the module's renderToReadableStream. +// Verified against real Next 14.2 + 15.1 builds. Action.Export below carries the +// webpack moduleId that keys `__next_app__.require`. // // Our emitted manifest is flattened and stable: // diff --git a/shared/nextcompile/runtime_src/actions.exec.test.mjs b/shared/nextcompile/runtime_src/actions.exec.test.mjs new file mode 100644 index 0000000..7ef4686 --- /dev/null +++ b/shared/nextcompile/runtime_src/actions.exec.test.mjs @@ -0,0 +1,77 @@ +// Server Action EXECUTION tests — the recipe verified against real Next 14.2 / +// 15.1 builds: mod.__next_app__.require(moduleId)[actionId] resolves the action, +// and mod.renderToReadableStream encodes the Flight reply. Run: node --test +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveActionFn, encodeFlightReply, handleServerAction } from "./actions.mjs"; + +// A module shaped exactly like a compiled Next page module: __next_app__.require +// returns the action worker module (keyed by actionId), plus a Flight encoder. +function fakePageModule({ wrapInDefault = false } = {}) { + const actionWorker = { + "act-add": async (a, b) => ({ sum: a + b }), + "act-greet": async (formData) => ({ hello: formData.get("name") }), + }; + const exportsObj = { + __next_app__: { require: (id) => (id === "42" ? actionWorker : {}) }, + // Minimal stand-in for react-server-dom-webpack's renderToReadableStream: + // emit the value as a Flight row-0 line, which is what the real encoder does. + renderToReadableStream: (value) => + new Response(`0:${JSON.stringify(value)}\n`).body, + }; + return wrapInDefault ? { default: exportsObj } : exportsObj; +} + +test("resolveActionFn: require() shape (direct exports)", () => { + const mod = fakePageModule(); + assert.equal(typeof resolveActionFn(mod, "42", "act-add"), "function"); +}); + +test("resolveActionFn: import() shape (exports under .default)", () => { + const mod = fakePageModule({ wrapInDefault: true }); + assert.equal(typeof resolveActionFn(mod, "42", "act-add"), "function"); +}); + +test("resolveActionFn: unknown moduleId / actionId / missing __next_app__ → undefined", () => { + const mod = fakePageModule(); + assert.equal(resolveActionFn(mod, "999", "act-add"), undefined); + assert.equal(resolveActionFn(mod, "42", "nope"), undefined); + assert.equal(resolveActionFn({}, "42", "act-add"), undefined); +}); + +test("encodeFlightReply: encodes via the module's renderToReadableStream", async () => { + const mod = fakePageModule(); + const stream = encodeFlightReply(mod, { sum: 5 }); + assert.ok(stream, "expected a stream"); + const text = await new Response(stream).text(); + assert.equal(text, '0:{"sum":5}\n'); +}); + +test("encodeFlightReply: null when the encoder is absent", () => { + assert.equal(encodeFlightReply({ __next_app__: {} }, { x: 1 }), null); +}); + +test("handleServerAction: resolves, executes, and returns a Flight reply", async () => { + const actionId = "act-add"; + const manifest = { actions: { [actionId]: { module: "app/page", export: "42", runtime: "node" } } }; + const loaders = { "app/page": async () => fakePageModule({ wrapInDefault: true }) }; + + // Same-origin POST with the Next-Action header and a JSON arg body. + const req = new Request("https://app.example.com/", { + method: "POST", + headers: { + "next-action": actionId, + "content-type": "application/json", + origin: "https://app.example.com", + host: "app.example.com", + }, + body: JSON.stringify([2, 3]), + }); + + const res = await handleServerAction(req, {}, { waitUntil() {} }, manifest, loaders); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "text/x-component"); + const text = await res.text(); + assert.equal(text, '0:{"sum":5}\n', "action executed and its result was Flight-encoded"); +}); diff --git a/shared/nextcompile/runtime_src/actions.mjs b/shared/nextcompile/runtime_src/actions.mjs index 9ed2556..6925973 100644 --- a/shared/nextcompile/runtime_src/actions.mjs +++ b/shared/nextcompile/runtime_src/actions.mjs @@ -74,11 +74,17 @@ export async function handleServerAction(request, env, ctx, actionManifest, modu } const mod = await loader(); - const fn = mod?.[entry.export]; + // Resolve the action function the way Next itself does. Server actions are + // NOT named exports of the page module — the page module exposes + // `__next_app__.require` (the webpack require), and the compiled action worker + // module (keyed by its webpack moduleId, carried in entry.export) exports the + // actions keyed by their actionId. So: require(moduleId)[actionId]. Verified + // against real Next 14.2 + 15.1 builds. + const fn = resolveActionFn(mod, entry.export, actionId); if (typeof fn !== "function") { return plainError(500, - `action ${actionId}: module ${entry.module} does not export ${entry.export}.\n` + - `Exports observed: ${Object.keys(mod || {}).join(", ")}`); + `action ${actionId}: could not resolve from module ${entry.module} (moduleId ${entry.export}).\n` + + "The compiled build may not expose __next_app__.require, or the action worker changed shape — verify the Next build output."); } let args; @@ -108,27 +114,75 @@ export async function handleServerAction(request, env, ctx, actionManifest, modu return mergeContextHeaders(result, reqCtx); } - // Plain data — return as JSON for now. The Next client's Flight - // parser will warn but form actions that redirect or return void - // still work because the status + headers drive behavior there. - const body = result === undefined || result === null - ? null - : JSON.stringify(result); + // Encode the return value as a Flight (RSC) reply the Next client decodes + // as the action result — the module's own react-server-dom-webpack + // renderToReadableStream produces the `text/x-component` stream Next + // expects. Falls back to JSON if the module doesn't expose the encoder or + // the result shape can't be encoded. + const flight = encodeFlightReply(mod, result); + if (flight) { + const headers = new Headers({ "content-type": "text/x-component" }); + applyContextHeaders(headers, reqCtx); + return new Response(flight, { status: 200, headers }); + } + const body = result === undefined || result === null ? null : JSON.stringify(result); const headers = new Headers({ "content-type": "application/json", "x-nextcompile-action-response": "json", - // Signal to the Next client that Flight isn't available so it falls - // back to the full-page reload path rather than attempting to - // decode Flight from our JSON. Undocumented but works across 14/15. - "x-next-action-version": "v1-json-fallback", }); applyContextHeaders(headers, reqCtx); - return new Response(body, { status: 200, headers }); }); } +// ── Action resolution + response encoding ──────────────────────────────────── + +// pageExports unwraps CJS/ESM interop: the compiled page module is CommonJS, so +// a dynamic import() lands its real exports under `.default`, while a require() +// returns them directly. Return whichever object actually carries the Next +// runtime surface (__next_app__ / renderToReadableStream). +function pageExports(mod) { + if (!mod) return mod; + if (mod.__next_app__ || mod.renderToReadableStream) return mod; + const d = mod.default; + if (d && (d.__next_app__ || d.renderToReadableStream)) return d; + return mod; +} + +// resolveActionFn resolves a server action function from the loaded page module. +// Next registers actions in a webpack worker module (keyed by its numeric +// moduleId) that exports them keyed by actionId; the page module exposes the +// webpack require as `__next_app__.require`. So: require(moduleId)[actionId]. +// Returns undefined when the shape isn't what we expect (caller 500s clearly). +export function resolveActionFn(mod, moduleId, actionId) { + const req = pageExports(mod)?.__next_app__?.require; + if (typeof req !== "function") return undefined; + let actionModule; + try { + actionModule = req(moduleId); + } catch { + return undefined; + } + const fn = actionModule?.[actionId]; + return typeof fn === "function" ? fn : undefined; +} + +// encodeFlightReply encodes an action's return value as a Flight (RSC) stream +// via the module's react-server-dom-webpack renderToReadableStream — the +// `text/x-component` format Next's client decodes. Returns the stream, or null +// when the encoder is unavailable / throws (caller falls back to JSON). An empty +// client manifest is sufficient for plain-data results (the common case). +export function encodeFlightReply(mod, result) { + const render = pageExports(mod)?.renderToReadableStream; + if (typeof render !== "function") return null; + try { + return render(result === undefined ? null : result, {}); + } catch { + return null; + } +} + // ── Body parsing ───────────────────────────────────────────────────────────── // BodyTooLargeError signals an oversized/unbounded action body so the caller