You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Module-level "use server" exports register by value
The server build registers each export's evaluated terminal initializer
whole, so server-side wrappers (withValidation, withDelay mocks) compose
onto every call path — HTTP dispatch and in-process SSR alike — while the
client build stays bare references. Replaces the unreleased wrapped-export
compile error: the export ≡ registered-function invariant was always about
values, not syntax. registerServerReference now rejects non-function values
at module eval, and anonymous default expressions get a synthesized binding
instead of being silently dropped from both builds.
Co-authored-by: Cursor <cursoragent@cursor.com>
Module-level "use server" exports now register by value: the server build registers each export's evaluated terminal initializer whole, so server-side wrappers compose onto every call path — `export const getUser = withValidation(schema, fn)` applies the wrapper to HTTP dispatch and in-process SSR calls alike, and patterns like `withDelay(fn, 400)` work for server mocks. The client build always emits bare references, so wrappers, schemas, and helpers stay server-only by construction. The compiler never inspects the initializer's shape; `registerServerReference` now throws at module eval when handed a non-function, turning stray non-function exports into loud boot errors instead of dead references. Anonymous default expressions (`export default withDelay(...)`, `export default async () => ...`) get a synthesized binding and register too — previously they were silently dropped from both builds. Supersedes the unreleased wrapped-export compile error.
1.**Wrapper calls round-trip at function level.**`export const getData = GET(async (id) => { "use server"; ... })` compiles by swapping only the function expression, so the surrounding `GET(...)` call survives in both server and client output. This works because the directive marks the function _by position_ — the compiler swaps exactly the expression carrying the directive and touches nothing around it.
37
37
2.**Anything referenced only inside a `"use server"` body never reaches the client.** The extraction replaces the body with a reference, and the directive pass’s orphan-scoped dead-code elimination removes now-unused imports and bindings — schema libraries, database handles, helper imports all vanish from client output. **The directive boundary is itself the privacy mechanism.**
38
38
39
-
One architectural fact worth stating: **a wrapper wraps the _reference_, not the registered function.**`registerServerReference(id, fn)` registers the raw inner function for HTTP dispatch before any wrapper runs, so wrapper-position code can only affect the client transport and the in-process callable — never HTTP dispatch. This is why anything that must run on the dispatch path (validation, auth, logging) belongs _inside_ the body, and why the declaration surface (`GET`) is transport-only.
39
+
One architectural fact worth stating, because the two directive levels land on opposite sides of it: **where a wrapper sits relative to the directive decides what it wraps.**At function level the directive marks the inner function, so `registerServerReference(id, fn)` registers the raw function for HTTP dispatch before any wrapper runs — wrapper-position code (`GET(...)` around a `"use server"` body) affects only the client transport and the in-process callable, never HTTP dispatch. That is why the function-level declaration surface (`GET`) is transport-only, and why dispatch-path concerns there belong inside the body.
40
40
41
-
**Module-level directives take the invariant further: exports are precisely the server functions.** A module-level `"use server"` file’s client build is rebuilt from scratch as reference exports — none of the module’s own code runs on the client. A call wrapper around an export (`export const x = GET(async () => ...)`, `withMeta`, or any userland wrapper) therefore has nowhere honest to go: hoisting the wrapper call into the client build would execute server-module code on the client (a lie about what runs where), while dropping it would silently change behavior between the two builds — and either way HTTP dispatch invokes the registered inner function, so the wrapper never applies to real calls. Rather than pick a lie, **the compiler rejects wrapped module-level exports at compile time.** The error directs to the function-level form, where the directive sits inside the function and the wrapper composes in shared code around each side’s reference. This also keeps the compiler out of the pattern-matching business — recognizing “which argument is the function” inside arbitrary call expressions is exactly the `server$()` mistake that the positional `"use server"` directive was adopted to end. Plain aliasing and separate declaration remain fine (`async function f() {...}` … `export { f }`; alias chains; default exports of a named function): aliasing never changes what the export _is_, wrapping does.
41
+
**Module-level directives invert this: the export’s _evaluated value_ is the server function.** `export const getUser = withValidation(schema, fn)` in a module-level `"use server"` file registers the wrapper’s _return value_ — the composed function — so server-side wrappers (validation, auth, logging, `withDelay(fn, 400)` mock latency) apply to **every call path**: HTTP dispatch and in-process SSR calls alike. Nothing here contradicts the function-level fact; the directive just sits on the other side of the wrapper, so the wrapper is _inside_ the registration instead of outside it. Three properties fall out. First, wrappers are server-only by construction — the client build is rebuilt from scratch as bare reference exports (`createServerReference(id)`), so wrapper code, schemas, and helpers never ship. Second, the compiler stays out of the pattern-matching business: it never asks “which argument is the function” (the `server$()` mistake the positional directive was adopted to end) — it registers the terminal initializer whole, whatever expression it is. Third, the shape check moves to the runtime: `registerServerReference` throws at module eval when handed a non-function, so a stray `export const limit = 5` in a directive module fails the server boot loudly instead of shipping a dead reference the client discovers per-call. Aliasing composes freely (`async function f() {...}` … `export { f }`; alias chains; default exports, named or anonymous — the compiler synthesizes a binding for anonymous default expressions): the alias trace ends at the terminal initializer and registers that. One asymmetry to know: client-transport declarations like `GET` are meaningless _inside_ a module-level file (they are client-side API — there is no client side of a module-level file to declare); they apply at the consumption site or with function-level directives.
42
42
43
43
### The runtime: `@solidjs/web/server-functions`
44
44
@@ -238,7 +238,7 @@ The boundary rule for future additions: **own the exchange, not the application
238
238
### Compiler implications
239
239
240
240
-**None, by design.**`GET` (like any in-body helper) is an ordinary runtime import; the wrapper round-trip and body-scoped DCE that make the design work are existing, verified behavior.
241
-
-**Module-level wrapped exports are a compile error:**in a module-level `"use server"` file, any export whose value wraps a function in a call expression — `GET` included, no wrapper is recognized by name — is rejected with an error directing to the function-level directive (see the invariant under the compiler contract above). The compiler never guesses which call argument “is” the function; the directive’s position is the only marker it honors.
241
+
-**Module-level exports register by value:**the server build registers each export’s terminal initializer whole — `export const x = withValidation(schema, fn)` registers `withValidation(schema, fn)`, the evaluated value — and the client build emits bare references for every export. The compiler traces aliases to the terminal initializer and never inspects its shape (no “which argument is the function” guessing); anonymous default expressions get a synthesized binding so they register like everything else. The runtime owns the shape check: `registerServerReference` throws at module eval on a non-function value.
242
242
-**Shipped since first draft:** the third `registerServerReference(id, fn, name)` argument carrying the compiler-_static_ dev `name` now exists on both proxies (development output only); it seeds the metadata channel as a default that explicit `withMeta`/`GET` writes shallow-merge over. Compiler-_produced_ metadata flowing to the runtime — not a userland convention the compiler recognizes.
243
243
244
244
## Migration / replacement
@@ -267,11 +267,12 @@ Recorded so they don’t reopen:
267
267
-**General `extend`/`transport(meta, fn)` options bag** — originally deferred, not designed-in: method was the only declaration-static capability, and a one-key options bag is worse API than one named function. A narrowed form returned as **`withMeta(fn, meta)`** — transport/declaration metadata only, function-first, never behavior — because the declare-on-function, react-in-hook pattern needed a public writer to the channel (`prepareRequest`’s `meta` was otherwise unreachable for user declarations). The metadata channel remains the stable contract.
268
268
-**Per-function static `headers` metadata** — cut; every concrete use case (bearer tokens, tracing) is session-dynamic and uniform → `prepareRequest`.
269
269
-**Compiler recognition of framework functions** (schema-stripping, `extend` as a compiler convention) — rejected; repeats the `server$` mistake of growing compiler knowledge per capability. Dead, not deferred: body-scoped DCE removes the motivation, since schemas never reach the client in the first place.
270
-
-**Transplanting wrapped module-level exports to the client build** (briefly landed, then reversed) — the compiler extracted the inner function from `export const x = wrap(async () => ...)` in a module-level directive file, replaced it with the reference, and cloned the wrapper call plus its reachable local dependencies into the client output. Rejected on three grounds: it executes server-module code on the client (module-level files promise the opposite), the wrapper never applies to HTTP dispatch anyway (registration precedes wrapping), and deciding which call argument “is” the function reintroduces the pattern-matching that the positional directive exists to avoid. Replaced by the compile error above; a recognized-wrapper allowlist (`GET`/`withMeta` only) was considered and rejected as the same problem with a shorter list.
270
+
-**Transplanting wrapped module-level exports to the client build** (briefly landed, then reversed) — the compiler extracted the inner function from `export const x = wrap(async () => ...)` in a module-level directive file, replaced it with the reference, and cloned the wrapper call plus its reachable local dependencies into the client output. Rejected on three grounds: it executes server-module code on the client (module-level files promise the opposite), the wrapper never applied to HTTP dispatch (it registered the inner function, then wrapped only what the client saw), and deciding which call argument “is” the function reintroduces the pattern-matching that the positional directive exists to avoid.
271
+
- **Forbidding wrapped module-level exports outright** (the immediate replacement for the transplant; also briefly landed, then reversed) — a compile error on any module-level export whose initializer wrapped a function in a call expression, on the theory that exports must be _syntactically_ the functions. Superseded by export-value registration (the compiler contract above), which keeps the sound part of the rule — the export ≡ registered function invariant, no client-side wrapper lies, no argument pattern-matching — while making the natural composition pattern (`withValidation(schema, fn)`, `withDelay(fn, 400)` mocks) simply work on every call path, because the _evaluated value_ is what registers. The error threw away wrappers’ one honest home (server-side, inside the registration) to enforce a syntactic reading of an invariant that was always about values. A recognized-wrapper allowlist (`GET`/`withMeta` only) was considered along the way and rejected as the same pattern-matching problem with a shorter list.
271
272
-**Validation in core (or the router)** — rejected: a validation helper touches zero privileged surface, so it lives outside both (see the decision record above). Core ships mechanisms; the router ships what it consumes; sugar that needs neither lives outside both.
272
273
-**Validation API variants** — a throwing/auto-400 `validate` (bakes the failure-plane choice into the helper), an error class with a `Symbol.for` brand + codec plugin + rehydration (plain data plus a structural guard needs no machinery), and per-position `validateArgs(schemas, args)` (tuple schemas already cover multi-arg) — all cut.
273
274
-**Schema-first router overloads** (`action(schema, fn)`, SvelteKit-style) — dead: validation is preflight or in-body, so router primitives don’t take schemas.
274
-
-**Validation as wrapper/signature metadata** (`withValidation(schema, fn)`) — rejected: wrappers can’t reach the HTTP dispatch path without registry mutation, and schemas would ship to the client or require compiler stripping.
275
+
-**Validation as wrapper/signature metadata** (`withValidation(schema, fn)`) — rejected_as a core/compiler-recognized convention_: at function level wrappers can’t reach the HTTP dispatch path without registry mutation, and schemas would ship to the client or require compiler stripping. Note this rejection is about core blessing the pattern, not the pattern itself: in module-level directive files, export-value registration means a userland `withValidation` wrapper composes onto every call path with schemas server-only by construction — no core recognition involved.
275
276
-**Middleware chains** (client hook chains, per-function server middleware stacks) — single hooks + userland composition instead. The server side of that single hook is `wrapInvocation` (added since first draft): one wrap around the execution with the invocation identity available, on which frameworks build per-function middleware in userland; core still ships no chain, and per-function server concerns remain body code first.
0 commit comments