Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLOUDFLARE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
15 changes: 8 additions & 7 deletions shared/nextcompile/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(<moduleId>)[<actionId>]` (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:
//
Expand Down
77 changes: 77 additions & 0 deletions shared/nextcompile/runtime_src/actions.exec.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
82 changes: 68 additions & 14 deletions shared/nextcompile/runtime_src/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading