From 5f9e777442ab4a6c013ab762405e7f3d0d3ba159 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 01:38:55 -0700 Subject: [PATCH] fix(web): unwrap respond() on direct calls; make its envelope a Response A server function called in-process during SSR that returned respond(value) handed its caller the ResponseEnvelope itself, while an HTTP caller decoded the value. A server component wrapped for its headers (respond(View, { headers })) reached the render as an object, so the frames policy never branded it and hydration serialization failed on it. The direct leg now resolves with the value (a thrown envelope rejects with it) before transformDirectResult runs, and appends the envelope's Set-Cookie headers to the render's response head; other headers and the status are the function's address's and the page's, not the document's. ResponseEnvelope now extends Response (built lazily, so the module stays free of side effects and safe where the global is missing), carrying the given response's body, status and headers. A fetch-style consumer with no Solid knowledge, such as a filesystem router's API dispatch, answers with it as-is instead of JSON-encoding the wrapper. The helpers are typed by what they mean to the caller: respond() returns T, and redirect()/reload() are (...): T, vanishing from inferred unions while taking a type from context (annotated returns, Response-typed handlers). A bare "use server" function's signature is its callers' type, with no wrapper narrowing needed. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/respond-envelope-is-a-response.md | 9 + .../solid-2.0/10-server-functions.md | 4 +- packages/web/server-functions/src/server.ts | 64 +++++-- packages/web/src/response.ts | 123 ++++++++----- .../web/test/response-helpers.type-tests.ts | 58 +++++++ .../server-functions-direct-respond.spec.tsx | 163 ++++++++++++++++++ .../server-functions-outcome-digest.spec.tsx | 2 +- 7 files changed, 363 insertions(+), 60 deletions(-) create mode 100644 .changeset/respond-envelope-is-a-response.md create mode 100644 packages/web/test/response-helpers.type-tests.ts create mode 100644 packages/web/test/server/server-functions-direct-respond.spec.tsx diff --git a/.changeset/respond-envelope-is-a-response.md b/.changeset/respond-envelope-is-a-response.md new file mode 100644 index 000000000..dff80b148 --- /dev/null +++ b/.changeset/respond-envelope-is-a-response.md @@ -0,0 +1,9 @@ +--- +"@solidjs/web": patch +--- + +`respond()` is typed as its value and its envelope is a real `Response`; direct calls unwrap it. + +- **Direct calls unwrap envelopes.** A server function called in-process during SSR that returned `respond(value)` handed its caller the `ResponseEnvelope` itself, while an HTTP caller decoded `value` — and a server component wrapped for its headers (`respond(View, { headers })`) reached the render as an object, so the frames policy never branded it and the hydration serializer failed on the envelope. The direct leg now resolves with the value (a thrown envelope rejects with it) before `transformDirectResult` runs. Of the metadata, `Set-Cookie` is appended to the render's response head; other headers describe the function's own address and the status is the page's, so neither is applied to the document. +- **The envelope is a `Response`.** `ResponseEnvelope` now extends `Response`, carrying the given response's body, status and headers, so a consumer with no Solid knowledge (a fetch-style API dispatch) answers with it as-is instead of JSON-encoding the wrapper. `new ResponseEnvelope(response, value)` keeps its signature; `response` is the envelope itself, or `undefined` when constructed without one. Solid's own consumers still recognize it with `isResponseEnvelope()`. +- **The helpers are typed by what they mean to the caller.** `respond()` returns `T`, and `redirect()`/`reload()` are `(…): T` — control flow, not values, so they vanish from the returning function's inferred type, while an annotated return or a `Response`-typed request handler supplies `T` (a literal `never` would also mark code after a bare call unreachable). A bare `"use server"` function's own signature is therefore its callers' type, with no wrapper narrowing (`NarrowResponse`) needed. At runtime the helpers are unchanged. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index d279e87f8..c7876946a 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -94,7 +94,9 @@ return reload({ revalidate: "todos" }); return respond(item, { status: 201, revalidate: "items" }); ``` -`respond()` produces a `ResponseEnvelope` — HTTP metadata paired with an in-memory value. The handler forwards the envelope’s headers and status and encodes the value as the body, while scripted callers receive the value transparently. Crucially, the carried response holds a **real JSON body**, so progressive-enhancement consumers (no-JS form posts, direct HTTP) get real JSON while scripted calls get the in-memory value — no reparse. Thrown envelopes ride the same path with an error tag (`X-Server-Function-Error`) and their status forwarded. Check with `isResponseEnvelope()` (a registered-symbol brand, correct across duplicated bundles — always prefer it over `instanceof`). +`respond()` produces a `ResponseEnvelope` — HTTP metadata paired with an in-memory value. The handler forwards the envelope’s headers and status and encodes the value as the body, while scripted callers receive the value transparently. Crucially, the envelope **is a real `Response`** with a JSON body, so progressive-enhancement consumers (no-JS form posts, direct HTTP) get real JSON while scripted calls get the in-memory value — no reparse — and a consumer that knows nothing of Solid (a filesystem router's API dispatch, any fetch-style handler) answers with it as-is. Thrown envelopes ride the same path with an error tag (`X-Server-Function-Error`) and their status forwarded. Check with `isResponseEnvelope()` (a registered-symbol brand, correct across duplicated bundles — always prefer it over `instanceof`). + +The helpers are typed by what they mean to the caller, not as the objects they build: `respond(value)` is typed as `value`, and `redirect()`/`reload()` — control flow for the integration, not values — never show up in the returning function's type (`(…): T`: the `never` vanishes from the inferred union, and where the context names a type, such as an annotated return or a `Response`-typed request handler, `T` takes it). So a bare `"use server"` function returning `respond(item)` on one branch and `redirect("/login")` on another is typed `Promise`, with no wrapper needed to narrow it. A direct call during SSR receives `respond()`'s value too (a thrown envelope rejects with it), and the envelope's `Set-Cookie` headers reach the page's response; its other headers and its status describe the function's own address and are not applied to the document. **Redirects to scripted callers ride a dedicated carrier.** fetch follows the redirect statuses (301/302/303/307/308) before the transport can read them, so a scripted answer masks the 3xx to 200 and carries the redirect in `X-Server-Function-Redirect`: the author’s status plus the target **resolved against the request URL** — exactly the meaning HTTP assigns the `Location` a form post would have received. Resolving server-side means `redirect("/")` and `redirect(new URL("/", url).href)` arrive identical, so an integration compares origins on a real URL instead of guessing navigation strategy from how the author spelled the target (#3102, #3107); decode with `decodeRedirectHeaderValue`. `Location` itself never rides a masked answer — on a 200 it has no HTTP meaning, and an authored `Location` on a forwarding status (a 201’s created-at) stays what it is: data. Unscripted callers get the real 3xx, and the non-followable 3xx band (304) forwards untouched for everyone. diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index c21715713..9e49d242e 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -1364,20 +1364,34 @@ export function createServerReference({ id, fn, name }) { // provideEventOnce): a broken hook used to double-commit or skip the // body silently during a render, where there is no status line to // notice it by. - let result = provideEventOnce(provideEvent, evt, () => { - const run = () => fn.apply(thisArg, args); - // The wrapper must return run()'s value (this path stays - // synchronous for synchronous functions). Observed as a whole — - // policy included — as the `"invocation"` record on `OBSERVE.records`; - // a no-op with no listener and outside observe builds. - return observeInvocation({ id, direct: true, event: evt, args }, () => - reportDirectFailure( - () => (wrap ? wrap(run, { id, args, event: evt, direct: true }) : run()), - id, - hook - ) - ); - }); + let result; + try { + result = provideEventOnce(provideEvent, evt, () => { + const run = () => fn.apply(thisArg, args); + // The wrapper must return run()'s value (this path stays + // synchronous for synchronous functions). Observed as a whole — + // policy included — as the `"invocation"` record on `OBSERVE.records`; + // a no-op with no listener and outside observe builds. + return observeInvocation({ id, direct: true, event: evt, args }, () => + reportDirectFailure( + () => (wrap ? wrap(run, { id, args, event: evt, direct: true }) : run()), + id, + hook + ) + ); + }); + } catch (error) { + throw directEnvelopeValue(error, evt); + } + result = + result && typeof result.then === "function" + ? result.then( + value => directEnvelopeValue(value, evt), + error => { + throw directEnvelopeValue(error, evt); + } + ) + : directEnvelopeValue(result, evt); // A generator or stream body runs when the caller pulls it, after the // call-time scope above has gone. Bind the WRAPPER'S result (not merely // fn's) so a deferred wrapInvocation keeps the same semantics. @@ -3446,6 +3460,28 @@ function reportDirectFailure(run, id, hook) { return result && typeof result.then === "function" ? result.then(undefined, report) : result; } +/** + * A `respond()` envelope a direct call returned or threw, as its caller + * receives it: the value, exactly as an HTTP caller decodes it — the + * in-process leg has no Response to hand over. Of the metadata, only + * `Set-Cookie` reaches the render's response head: a cookie is state the + * function established, and the browser must receive it whichever leg ran + * the call. The rest describes the function's own address (a GET's + * `Cache-Control` is about that url, not the page composed from it) and the + * status is the page's, so neither is applied to the document. + */ +function directEnvelopeValue(result, event) { + if (!isResponseEnvelope(result)) return result; + const { response, value } = result; + const stub = event.response; + if (response && stub && stub.headers && response.headers.getSetCookie) { + for (const cookie of response.headers.getSetCookie()) { + stub.headers.append("Set-Cookie", cookie); + } + } + return value; +} + export function sanitizeServerError(value) { if (DEV) return value; if (isSafeError(value)) return value; diff --git a/packages/web/src/response.ts b/packages/web/src/response.ts index dfb071eb0..0043a8af2 100644 --- a/packages/web/src/response.ts +++ b/packages/web/src/response.ts @@ -22,35 +22,48 @@ const ENVELOPE = Symbol.for("solid.ResponseEnvelope"); * payloads); the HTTP handler forwards `response`'s headers and * (non-redirect) status and encodes `value` as the body through the codec, * while client-only integrations read `value` directly — no reparse. + * + * The envelope IS a `Response` — the given response's body, status and + * headers — so a consumer that knows nothing of Solid (a filesystem + * router's API dispatch, any fetch-style handler) answers with it as-is. + * `response` is the envelope itself, or `undefined` when constructed + * without one. */ -// PURE-annotated factory (same convention as solid's MockPromise): the brand -// lives on the prototype, but a bare top-level `C.prototype[X] = true` is a -// module side effect that pins the class into every bundle including this -// module — client bundles that never construct or brand-check an envelope -// were retaining it. Wrapping the declaration and the brand assignment in one -// pure expression lets the whole thing shake when unreferenced. (A `static {}` -// block would NOT work: bundlers treat static blocks as side-effectful.) -export interface ResponseEnvelope { +export interface ResponseEnvelope extends Response { response: Response | undefined; value: T; } +// Built on first construction: `extends Response` evaluated at module load +// would throw wherever the global is missing, and a top-level class with a +// prototype write is a side effect that pins it into every bundle importing +// this module. +let EnvelopeClass: any; + export const ResponseEnvelope: { new (response: Response | undefined, value: T): ResponseEnvelope; -} = /* @__PURE__ */ (() => { - class ResponseEnvelope { - response: Response | undefined; - value: unknown; - constructor(response: Response | undefined, value: unknown) { - this.response = response; - this.value = value; - } +} = function ResponseEnvelope(response: Response | undefined, value: unknown) { + if (!EnvelopeClass) { + EnvelopeClass = class extends Response { + response: Response | undefined; + value: unknown; + constructor(response: Response | undefined, value: unknown) { + super( + response ? response.body : null, + response && { + status: response.status, + statusText: response.statusText, + headers: copyHeaders(response.headers) + } + ); + this.response = response ? this : undefined; + this.value = value; + } + }; + EnvelopeClass.prototype[ENVELOPE] = true; } - (ResponseEnvelope.prototype as any)[ENVELOPE] = true; - return ResponseEnvelope; -})() as { - new (response: Response | undefined, value: T): ResponseEnvelope; -}; + return new EnvelopeClass(response, value); +} as any; /** Whether `value` is a `ResponseEnvelope` (robust across module copies). */ export function isResponseEnvelope(value: unknown): value is ResponseEnvelope { @@ -167,25 +180,25 @@ export interface ResponseHelperInit extends ResponseInit { /** @internal */ export const RESPONSE_HEADER_VALUE_LIMIT = 4096; +// Copy preserving multiple Set-Cookie values: Headers-to-Headers copying +// through the constructor folds them into one comma-joined entry on some +// runtimes (a folded Set-Cookie is corrupt). Plain-object inits cannot +// carry duplicates and pass through as-is. +function copyHeaders(init: HeadersInit | undefined): Headers { + const source = init as Headers | undefined; + if (!source || !source.getSetCookie) return new Headers(init); + const headers = new Headers(); + source.forEach((value, key) => { + if (key !== "set-cookie") headers.append(key, value); + }); + for (const cookie of source.getSetCookie()) headers.append("Set-Cookie", cookie); + return headers; +} + function initWithRevalidate(init: number | ResponseHelperInit = {}) { const resolved: any = typeof init === "number" ? { status: init } : init; const { revalidate, ...responseInit } = resolved; - // Copy preserving multiple Set-Cookie values: Headers-to-Headers copying - // through the constructor folds them into one comma-joined entry on some - // runtimes (a folded Set-Cookie is corrupt). Plain-object inits cannot - // carry duplicates and pass through as-is. - let headers: Headers; - if (responseInit.headers && responseInit.headers.getSetCookie) { - headers = new Headers(); - responseInit.headers.forEach((value: string, key: string) => { - if (key !== "set-cookie") headers.append(key, value); - }); - for (const cookie of responseInit.headers.getSetCookie()) { - headers.append("Set-Cookie", cookie); - } - } else { - headers = new Headers(responseInit.headers); - } + const headers = copyHeaders(responseInit.headers); if (revalidate !== undefined) { const list = Array.isArray(revalidate) ? revalidate : [revalidate]; if (list.length > 1 && list.includes(REVALIDATE_ALL)) { @@ -213,8 +226,19 @@ function initWithRevalidate(init: number | ResponseHelperInit = {}) { /** * Response redirecting to `url` (default 302). `revalidate` names the * cache keys the mutation invalidated. + * + * Typed so it never shows up in the type of the function returning it: a + * redirect is control flow for the integration to act on, not a value. + * `T` defaults to `never`, which vanishes from the inferred return type's + * union; where the context expects a type (an annotated return, a + * `Response`-typed request handler) `T` takes it. A literal `never` return + * would also mark code after a bare call unreachable; a type parameter + * does not. At runtime it is a real `Response`. */ -export function redirect(url: string | Href, init: number | ResponseHelperInit = 302) { +export function redirect( + url: string | Href, + init: number | ResponseHelperInit = 302 +): T { if (typeof url !== "string" && !isHref(url)) { throw new TypeError( "redirect() expects a string URL or an Href-branded value (Symbol.for('solid.Href'))." @@ -256,16 +280,19 @@ export function redirect(url: string | Href, init: number | ResponseHelperInit = ); } headers.set("Location", encoded); - return new Response(null, { ...responseInit, headers }); + return new Response(null, { ...responseInit, headers }) as T; } /** * Empty response requesting revalidation of the named cache keys (all of * them when omitted). + * + * Typed as `redirect` is, for the same reason: it never shows up in the + * type of the function returning it. At runtime it is a real `Response`. */ -export function reload(init: ResponseHelperInit = {}) { +export function reload(init: ResponseHelperInit = {}): T { const { responseInit, headers } = initWithRevalidate(init); - return new Response(null, { ...responseInit, headers }); + return new Response(null, { ...responseInit, headers }) as T; } /** @@ -284,8 +311,13 @@ export const NULL_BODY_STATUSES: ReadonlySet = new Set([204, 205, 304]); * stays invisible: the carried response holds a plain JSON body so * consumers without the client runtime (no-JS form posts, direct HTTP) * get real JSON, while integrations read `value` — no reparse. + * + * Typed as `value`'s type: every caller of the function returning it — + * over HTTP or in-process — receives the value, never the envelope. At + * runtime it is a `ResponseEnvelope`, a real `Response` that integrations + * recognize with `isResponseEnvelope()`. */ -export function respond(value: T, init: ResponseHelperInit = {}) { +export function respond(value: T, init: ResponseHelperInit = {}): T { const { responseInit, headers } = initWithRevalidate(init); // A null-body status cannot carry the passthrough JSON body — building it // would throw right here, at 200, masking the author's intent (#3095). @@ -296,11 +328,14 @@ export function respond(value: T, init: ResponseHelperInit = {}) { // else — and on a null-body status there is no body at all (#3197). for (const header of COMPOSED_BODY_FRAMING) headers.delete(header); if (NULL_BODY_STATUSES.has(responseInit.status)) { - return new ResponseEnvelope(new Response(null, { ...responseInit, headers }), value); + return new ResponseEnvelope( + new Response(null, { ...responseInit, headers }), + value + ) as unknown as T; } headers.set("Content-Type", "application/json"); return new ResponseEnvelope( new Response(JSON.stringify(value), { ...responseInit, headers }), value - ); + ) as unknown as T; } diff --git a/packages/web/test/response-helpers.type-tests.ts b/packages/web/test/response-helpers.type-tests.ts new file mode 100644 index 000000000..29aa2cacf --- /dev/null +++ b/packages/web/test/response-helpers.type-tests.ts @@ -0,0 +1,58 @@ +// The response helpers are typed by what they mean to the caller of the +// function returning them: `respond(value)` is `value`, and a `redirect()` +// or `reload()` — control flow, not a value — never shows up. That holds +// for a bare `"use server"` function, typed by its own signature, as much +// as through `GET()`. Compile-only, under `test-types`. +import { GET as clientGET } from "../server-functions/src/client.js"; +import { GET as serverGET } from "../server-functions/src/server.js"; +import { redirect, reload, respond } from "../src/response.js"; + +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; +function assertType(): T | void {} +type Resolved any> = Awaited>; +type User = { id: string }; + +assertType>, { id: number }>>(); +const redirected = () => redirect("/login"); +const reloaded = () => reload({ revalidate: "users" }); +assertType, never>>(); +assertType, never>>(); +// A type parameter defaulting to `never`, not a literal `never` return: the +// latter would also mark code after a bare call unreachable. +assertType>, Response>>(); +assertType>, Response>>(); + +// A bare server function's type is the caller's type. +async function getStory(id: number) { + "use server"; + return respond({ id }, { headers: { "cache-control": "public, max-age=60" } }); +} +assertType, { id: number }>>(); + +// Control flow on any branch leaves only the value. +async function rename(id: string) { + "use server"; + if (!id) return redirect("/login"); + if (id === "stale") return reload({ revalidate: "users" }); + return respond({ id, renamed: true }, { status: 201 }); +} +assertType, { id: string; renamed: boolean }>>(); + +// Where the context names a type, the helper takes it: an annotated +// return, or a request handler that must answer with a Response. +async function annotated(id: string): Promise { + "use server"; + if (!id) return redirect("/login"); + return { id }; +} +annotated; +const handler: (request: Request) => Response = request => + request.headers.has("cookie") ? Response.json({ ok: true }) : redirect("/login"); +handler; + +// A component wrapped for its headers is still a component to the caller. +for (const GET of [clientGET, serverGET]) { + const getView = GET(async () => respond(() => "view")); + assertType, () => "view">>(); +} diff --git a/packages/web/test/server/server-functions-direct-respond.spec.tsx b/packages/web/test/server/server-functions-direct-respond.spec.tsx new file mode 100644 index 000000000..bc264b3fb --- /dev/null +++ b/packages/web/test/server/server-functions-direct-respond.spec.tsx @@ -0,0 +1,163 @@ +/** + * A `respond()` envelope on the direct leg — a server function called + * in-process during SSR. Over HTTP the handler unwraps the envelope and the + * caller decodes its value; in-process the caller received the envelope + * itself, so `await fn()` answered two different shapes depending on which + * leg ran it, and a server component wrapped in `respond()` reached the + * render as an object — the frames policy never branded it, and the + * hydration serializer met an envelope holding a function. + * + * Of the metadata, `Set-Cookie` reaches the render's response head (state + * the function established); the other headers describe the function's own + * address and the status is the page's, so neither applies to the document. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { ResponseEnvelope, createRequestEvent, isResponseEnvelope, respond } from "@solidjs/web"; +import { + configureServerFunctionsServer, + createServerReference +} from "@solidjs/web/server-functions/server"; +import { frameTransformDirectResult } from "@solidjs/web/frames/server"; + +const RequestContext = Symbol.for("solid.RequestContext"); +const SERVER_COMPONENT = Symbol.for("solid.server-component"); +const requestContext = new AsyncLocalStorage(); + +beforeAll(() => { + (globalThis as any)[RequestContext] = requestContext; +}); + +afterAll(() => { + delete (globalThis as any)[RequestContext]; +}); + +afterEach(() => { + configureServerFunctionsServer({ transformDirectResult: null as any }); +}); + +function reference any>(id: string, fn: F) { + return createServerReference({ id, fn } as any) as unknown as F; +} + +function pageEvent() { + return createRequestEvent(new Request("https://app.example/page")); +} + +function cookieInit() { + const headers = new Headers({ "cache-control": "public, max-age=60", "x-extra": "1" }); + headers.append("Set-Cookie", "a=1; Path=/"); + headers.append("Set-Cookie", "b=2; Path=/"); + return { status: 201, headers }; +} + +describe("respond() is a Response", () => { + it("carries the JSON body, status and headers, with the value in memory", async () => { + const envelope: any = respond({ id: 7 }, cookieInit()); + expect(envelope).toBeInstanceOf(Response); + expect(isResponseEnvelope(envelope)).toBe(true); + expect(envelope.value).toEqual({ id: 7 }); + expect(envelope.response).toBe(envelope); + expect(envelope.status).toBe(201); + expect(envelope.headers.get("content-type")).toBe("application/json"); + expect(envelope.headers.get("cache-control")).toBe("public, max-age=60"); + expect(envelope.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); + expect(await envelope.json()).toEqual({ id: 7 }); + }); + + it("a null-body status stays bodiless", () => { + const envelope: any = respond(undefined, { status: 304 }); + expect(envelope).toBeInstanceOf(Response); + expect(envelope.status).toBe(304); + expect(envelope.body).toBeNull(); + }); + + it("answers a dispatcher that knows nothing of Solid", async () => { + // The fetch-style API dispatch convention (filesystem-routing's): a + // Response passes through, anything else is JSON-encoded. + const dispatch = (result: unknown) => + result instanceof Response ? result : Response.json(result); + const response = dispatch(respond({ id: 7 }, { status: 201, headers: { "x-extra": "1" } })); + expect(response.status).toBe(201); + expect(response.headers.get("x-extra")).toBe("1"); + expect(await response.json()).toEqual({ id: 7 }); + }); + + it("constructed without a response it is an empty 200 with no metadata", () => { + const envelope = new ResponseEnvelope(undefined, "value"); + expect(envelope).toBeInstanceOf(Response); + expect(envelope.response).toBeUndefined(); + expect(envelope.value).toBe("value"); + expect(envelope.status).toBe(200); + }); + + it("constructed from a response, it takes that response's metadata", () => { + const headers = new Headers({ "x-policy": "1" }); + headers.append("Set-Cookie", "a=1"); + headers.append("Set-Cookie", "b=2"); + const envelope = new ResponseEnvelope(new Response(null, { status: 202, headers }), 3); + expect(envelope.status).toBe(202); + expect(envelope.headers.get("x-policy")).toBe("1"); + expect(envelope.headers.getSetCookie()).toEqual(["a=1", "b=2"]); + expect(envelope.response).toBe(envelope); + }); +}); + +describe("respond() on the direct leg", () => { + it("an async call resolves with the envelope's value", async () => { + const getItem = reference("direct-respond-async", async () => + respond({ id: 7 }, { headers: { "cache-control": "max-age=60" } }) + ); + const event = pageEvent(); + expect(await requestContext.run(event, () => getItem())).toEqual({ id: 7 }); + }); + + it("a synchronous call returns the envelope's value synchronously", () => { + const getItem = reference("direct-respond-sync", () => respond("value")); + expect(requestContext.run(pageEvent(), () => getItem())).toBe("value"); + }); + + it("a thrown envelope rejects with its value, both async and sync", async () => { + const failing = reference("direct-respond-throw-async", async () => { + throw respond({ reason: "nope" }, { status: 400 }); + }); + const failingSync = reference("direct-respond-throw-sync", () => { + throw respond({ reason: "sync" }, { status: 400 }); + }); + const event = pageEvent(); + await expect(requestContext.run(event, () => failing())).rejects.toEqual({ reason: "nope" }); + expect(() => requestContext.run(event, () => failingSync())).toThrow( + expect.objectContaining({ reason: "sync" }) + ); + }); + + it("forwards each Set-Cookie onto the render's response head, and nothing else", async () => { + const login = reference("direct-respond-cookies", async () => respond("ok", cookieInit())); + const event = pageEvent(); + expect(await requestContext.run(event, () => login())).toBe("ok"); + expect(event.response.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); + expect(event.response.headers.get("cache-control")).toBeNull(); + expect(event.response.headers.get("x-extra")).toBeNull(); + expect(event.response.status).toBeUndefined(); + }); + + it("a thrown envelope's cookies reach the head too", async () => { + const failing = reference("direct-respond-throw-cookies", async () => { + throw respond("denied", cookieInit()); + }); + const event = pageEvent(); + await expect(requestContext.run(event, () => failing())).rejects.toBe("denied"); + expect(event.response.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); + }); + + it("the direct-result policy sees the value: respond(Component) brands as a server component", async () => { + configureServerFunctionsServer({ transformDirectResult: frameTransformDirectResult }); + const View = () => "story"; + const getStory = reference("direct-respond-component", async () => + respond(View, { headers: { "cache-control": "public, max-age=60" } }) + ); + const result: any = await requestContext.run(pageEvent(), () => getStory()); + expect(typeof result).toBe("function"); + expect(result[SERVER_COMPONENT]).toBe("direct-respond-component"); + }); +}); diff --git a/packages/web/test/server/server-functions-outcome-digest.spec.tsx b/packages/web/test/server/server-functions-outcome-digest.spec.tsx index ae7feab0d..49be2121b 100644 --- a/packages/web/test/server/server-functions-outcome-digest.spec.tsx +++ b/packages/web/test/server/server-functions-outcome-digest.spec.tsx @@ -219,7 +219,7 @@ describe("the invalidation scope", () => { expect(() => reload({ revalidate: ["*", "orders"] })).toThrow(/stands alone/); expect(() => redirect("/", { revalidate: ["orders", "*"] })).toThrow(/stands alone/); expect(() => respond(1, { revalidate: ["*"] })).not.toThrow(); - expect(reload({ revalidate: "*" }).headers.get("X-Revalidate")).toBe("*"); + expect(reload({ revalidate: "*" }).headers.get("X-Revalidate")).toBe("*"); }); });