diff --git a/docs/api-reference/veryfront/extensions.md b/docs/api-reference/veryfront/extensions.md index f2ea363be7..4a2d154583 100644 --- a/docs/api-reference/veryfront/extensions.md +++ b/docs/api-reference/veryfront/extensions.md @@ -538,13 +538,13 @@ import { | `expiresImmediately` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/ttl.ts#L45) | | `isRevisionedCacheBackend` | Test whether a backend exposes the complete atomic revision capability. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/capabilities.ts#L81) | | `isRevisionedCacheKey` | Test whether a key belongs to the valid revisioned-key builder image. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/capabilities.ts#L172) | -| `parseSerializedCachePayload` | Reject oversized or malformed JSON before constructing an untrusted object graph. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/rendering/cache/cache-payload.ts#L1019) | +| `parseSerializedCachePayload` | Reject oversized or malformed JSON before constructing an untrusted object graph. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/rendering/cache/cache-payload.ts#L1075) | | `registerOwnedDistributedCacheKeyPrefix` | Register an opaque namespace without making it eligible for project invalidation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/distributed-keyspace.ts#L300) | | `registerRenderDistributedCacheNamespace` | Register a namespace containing render-cache keys. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/distributed-keyspace.ts#L288) | | `requireCacheExchangeResult` | Validate a provider-returned compare-exchange result. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/capabilities.ts#L140) | | `requirePositiveIntegerCacheTtlSeconds` | Validate a constructor-level TTL for whole-second cache protocols. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/ttl.ts#L68) | | `resolveIntegerCacheTtlSeconds` | Resolve a TTL for protocols that accept only whole seconds. Positive fractions round up so integer conversion never expires an entry earlier than requested; non-positive values retain their immediate-expiry meaning. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/ttl.ts#L37) | -| `serializeCachePayload` | Serialize using the origin-compatible payload shape. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/rendering/cache/cache-payload.ts#L950) | +| `serializeCachePayload` | Serialize using the origin-compatible payload shape. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/rendering/cache/cache-payload.ts#L1005) | | `snapshotCacheRevisionResult` | Validate and detach a provider-returned revision snapshot. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/capabilities.ts#L101) | | `stripOwnedDistributedCacheKeyPrefix` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/distributed-keyspace.ts#L320) | | `validateDistributedCacheKeyPrefix` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/cache/backends/distributed-keyspace.ts#L178) | diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index b832ac4e3a..ceed09778c 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -51,7 +51,7 @@ export function GET() { | `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L127) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | -| `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L45) | +| `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | | `parseFormData` | Parse and validate multipart or URL-encoded form data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L139) | | `parseJsonBody` | Parse and validate a JSON request body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L68) | | `parseQueryParams` | Parse and validate query parameters from a bounded request URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L195) | @@ -68,13 +68,17 @@ export function GET() { | `APIHandler` | Function signature for API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L122) | | `APIResponse` | Structured response shape for API route helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L115) | | `APIRoute` | Route module shape with method handlers and an optional default handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/module-loader/types.ts#L30) | -| `DataContext` | Context passed to `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L54) | -| `InferGetServerDataProps` | Utility type to infer props from a page with data | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L28) | -| `MDXFrontmatter` | Parsed frontmatter values from an MDX page. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L90) | -| `PageContext` | Runtime page context passed to page components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L107) | -| `PageWithData` | Page with data fetching capabilities | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L16) | +| `DataContext` | Context passed to `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L116) | +| `DataResponseMetadata` | Custom document response metadata returned from `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L121) | +| `DataResult` | Props, routing control, caching, and response metadata returned from `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L126) | +| `InferGetServerDataProps` | Utility type to infer props from a page with data | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L36) | +| `MDXFrontmatter` | Parsed frontmatter values from an MDX page. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L91) | +| `PageContext` | Runtime page context passed to page components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L108) | +| `PageWithData` | Page with data fetching capabilities | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L24) | +| `ResponseCookie` | One cookie emitted as a distinct Set-Cookie response field. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L119) | | `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | -| `StaticPathsResult` | Return type for `getStaticPaths()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L61) | +| `StaticDataResult` | Cache-safe result returned from `getStaticData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L133) | +| `StaticPathsResult` | Return type for `getStaticPaths()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L146) | | `ValidatedHandlerConfig` | Configuration for `createValidatedHandler()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L11) | | `ValidatedHandlerFunction` | Handler signature that receives validated request data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L18) | | `VeryfrontConfig` | Project configuration. The underlying runtime schema stores `extensions` as `unknown[]`; this tightened alias surfaces the expected `ExtensionConfigEntry[]` shape to TypeScript consumers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/schemas/index.ts#L24) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index bb0b165a40..9311677cf5 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -70,7 +70,7 @@ export function getServerData(ctx: DataContext) { | `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L127) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | -| `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L45) | +| `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | | `parseFormData` | Parse and validate multipart or URL-encoded form data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L139) | | `parseJsonBody` | Parse and validate a JSON request body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L68) | | `parseQueryParams` | Parse and validate query parameters from a bounded request URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L195) | @@ -89,13 +89,17 @@ export function getServerData(ctx: DataContext) { | `APIHandler` | Function signature for API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L122) | | `APIResponse` | Structured response shape for API route helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L115) | | `APIRoute` | Route module shape with method handlers and an optional default handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/module-loader/types.ts#L30) | -| `DataContext` | Context passed to `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L54) | -| `InferGetServerDataProps` | Utility type to infer props from a page with data | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L28) | -| `MDXFrontmatter` | Parsed frontmatter values from an MDX page. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L90) | -| `PageContext` | Runtime page context passed to page components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L107) | -| `PageWithData` | Page with data fetching capabilities | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L16) | +| `DataContext` | Context passed to `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L116) | +| `DataResponseMetadata` | Custom document response metadata returned from `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L121) | +| `DataResult` | Props, routing control, caching, and response metadata returned from `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L126) | +| `InferGetServerDataProps` | Utility type to infer props from a page with data | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L36) | +| `MDXFrontmatter` | Parsed frontmatter values from an MDX page. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L91) | +| `PageContext` | Runtime page context passed to page components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L108) | +| `PageWithData` | Page with data fetching capabilities | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L24) | +| `ResponseCookie` | One cookie emitted as a distinct Set-Cookie response field. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L119) | | `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | -| `StaticPathsResult` | Return type for `getStaticPaths()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L61) | +| `StaticDataResult` | Cache-safe result returned from `getStaticData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L133) | +| `StaticPathsResult` | Return type for `getStaticPaths()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L146) | | `ValidatedHandlerConfig` | Configuration for `createValidatedHandler()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L11) | | `ValidatedHandlerFunction` | Handler signature that receives validated request data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L18) | | `VeryfrontConfig` | Project configuration. The underlying runtime schema stores `extensions` as `unknown[]`; this tightened alias surfaces the expected `ExtensionConfigEntry[]` shape to TypeScript consumers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/schemas/index.ts#L24) | diff --git a/docs/guides/data-fetching.md b/docs/guides/data-fetching.md index d872ad437b..82734b9925 100644 --- a/docs/guides/data-fetching.md +++ b/docs/guides/data-fetching.md @@ -19,7 +19,8 @@ Examples below use the default app router. Set `router: "pages"` in `veryfront.c ## Server data -`getServerData` runs on every request. Use it when data depends on the request (auth, query params, cookies): +`getServerData` runs on every request. Use it when data depends on the request, +such as authentication, query parameters, or cookie reads: ```tsx // app/dashboard/page.tsx @@ -60,6 +61,59 @@ The `DataContext` provides: | `query` | `URLSearchParams` | Query string parameters | | `url` | `URL` | Parsed request URL | +## Set response headers and cookies + +Return `headers` or `cookies` from `getServerData` to add metadata to the full +document response: + +```tsx +// app/account/page.tsx +import type { DataResult } from "veryfront"; + +interface AccountProps { + displayName: string; +} + +export function getServerData(): DataResult { + const sessionId = crypto.randomUUID(); + + return { + props: { displayName: "Ada" }, + headers: { "x-account-state": "fresh" }, + cookies: [{ + name: "session", + value: sessionId, + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }], + }; +} + +export default function Account({ displayName }: AccountProps) { + return

Welcome, {displayName}

; +} +``` + +Each cookie supports `name`, `value`, `domain`, `path`, `expires`, `maxAge`, +`httpOnly`, `secure`, and `sameSite`. Use an RFC 7231 date string for +`expires`. Veryfront URI-encodes cookie values and emits every cookie as a +distinct `Set-Cookie` field. + +Veryfront owns CORS, cache, content, redirect, security, transport, and +`x-veryfront-*` headers. Returning one of those headers throws an error. Use +`cookies` instead of a `Set-Cookie` entry in `headers`. + +Layouts merge from outermost to innermost, then the page. The closest loader +wins when custom header names conflict. Cookies append in that same order. +Any response with a cookie uses `no-cache`, omits its ETag, and is not stored +in the render cache. + +Response metadata applies to full document responses. `getStaticData` rejects +it because static caches must not replay response cookies. Use an API route or +middleware when a client-side navigation request must write response metadata. + ## Static data `getStaticData` supplies cacheable data for static builds and production @@ -138,6 +192,28 @@ export async function getServerData({ params }: DataContext) { redirect("/new-url", true); // 301 permanent redirect ``` +When redirecting from `getServerData`, pass response metadata as the third +argument to set a cookie or header on the redirect response: + +```ts +import { redirect } from "veryfront"; + +export function getServerData() { + const sessionId = crypto.randomUUID(); + + return redirect("/account", false, { + cookies: [{ + name: "session", + value: sessionId, + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }], + }); +} +``` + Throwing works the same way. `throw notFound()` and `throw redirect(...)` behave exactly like returning them, which is useful inside a helper that has no clean way to return to the data function: ```tsx @@ -185,6 +261,9 @@ export default function Search() { - For `getServerData`, hit the page with `curl http://localhost:3000/` and confirm the response contains the value you returned in `props`. +- To verify response metadata, run + `curl -sD - -o /dev/null http://localhost:3000/` and inspect the + custom header and separate `Set-Cookie` fields. - For `getStaticData`, run `veryfront build` and inspect the generated HTML for the page. The HTML should contain the static value rather than a client-side fetch loop. diff --git a/src/data/README.md b/src/data/README.md index 45b6ce829b..2924f80b9f 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -19,7 +19,7 @@ The Data module exports: - **`DataFetcher`** - Main class for data fetching with caching support - **`notFound()`** - Helper to return 404 responses - **`redirect()`** - Helper to return redirect responses -- **Types** - `DataContext`, `DataResult`, `PageWithData`, `StaticPathsResult`, `InferGetServerDataProps`, `CacheEntry` +- **Types** - `DataContext`, `DataResult`, `DataResponseMetadata`, `ResponseCookie`, `PageWithData`, `StaticDataResult`, `StaticPathsResult`, `InferGetServerDataProps`, `CacheEntry` ## File Structure @@ -175,6 +175,18 @@ interface DataContext { Data fetching functions return a result object: ```typescript +interface ResponseCookie { + name: string; + value: string; + domain?: string; + path?: string; + expires?: string; + maxAge?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "lax" | "strict" | "none"; +} + interface DataResult { props?: T; // Props to pass to component redirect?: { // Redirect response @@ -183,9 +195,18 @@ interface DataResult { }; notFound?: boolean; // Return 404 revalidate?: number; // ISR revalidation (seconds) + headers?: Record; // Custom document response headers + cookies?: ResponseCookie[]; // Distinct Set-Cookie response fields } ``` +The response metadata fields apply only to `getServerData`. Veryfront validates +the value from `getStaticData` as a `StaticDataResult`, which excludes response +metadata so static caches cannot replay cookies. The public page type continues +to accept legacy `DataResult` return annotations, but metadata returned at +runtime is rejected. Framework-owned CORS, cache, content, redirect, security, +transport, and `x-veryfront-*` headers cannot be set through `headers`. + ### 3. Response Helpers Convenience functions for common responses: diff --git a/src/data/data-result-validation.test.ts b/src/data/data-result-validation.test.ts new file mode 100644 index 0000000000..25540dac60 --- /dev/null +++ b/src/data/data-result-validation.test.ts @@ -0,0 +1,138 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { validateDataResult } from "./data-result-validation.ts"; +import { + DataResultSchema, + ResponseCookieSchema, + StaticDataResultSchema, +} from "./schemas/data.schema.ts"; + +describe("validateDataResult", () => { + it("preserves redirect and not-found precedence over props", () => { + assertEquals( + validateDataResult( + { + props: { ignored: true }, + redirect: { destination: "/next" }, + notFound: true, + revalidate: Number.POSITIVE_INFINITY, + headers: { "x-page-state": "redirected" }, + }, + "getServerData", + ), + { + redirect: { destination: "/next" }, + headers: { "x-page-state": "redirected" }, + }, + ); + + assertEquals( + validateDataResult( + { + props: { ignored: true }, + notFound: true, + revalidate: "ignored", + headers: { "x-page-state": "missing" }, + }, + "getServerData", + ), + { + notFound: true, + headers: { "x-page-state": "missing" }, + }, + ); + }); + + it("rejects negative revalidation values", () => { + assertThrows( + () => + validateDataResult( + { props: { value: "fresh" }, revalidate: -100 }, + "getStaticData", + ), + TypeError, + "getStaticData must return a valid data result object", + ); + }); + + it("rejects negative revalidation values in the exported schemas", () => { + for (const schema of [DataResultSchema, StaticDataResultSchema]) { + assertEquals(schema.safeParse({ props: {}, revalidate: -1 }).success, false); + assertEquals(schema.safeParse({ props: {}, revalidate: 0 }).success, true); + assertEquals( + schema.safeParse({ redirect: { destination: "/next" }, revalidate: -1 }).success, + true, + ); + assertEquals( + schema.safeParse({ notFound: true, revalidate: "ignored" }).success, + true, + ); + } + }); + + it("keeps exported schema validation aligned with response metadata rules", () => { + assertEquals( + StaticDataResultSchema.safeParse({ + props: {}, + cookies: [{ name: "session", value: "unsafe" }], + }).success, + false, + ); + assertEquals( + StaticDataResultSchema.safeParse({ props: {}, headers: { "x-state": "ready" } }).success, + false, + ); + assertEquals( + DataResultSchema.safeParse({ props: {}, headers: { "set-cookie": "unsafe=1" } }).success, + false, + ); + assertEquals( + DataResultSchema.safeParse({ + props: {}, + cookies: [{ name: "session", value: "safe", maxAge: 1.5 }], + }).success, + false, + ); + assertEquals( + DataResultSchema.safeParse({ + props: {}, + cookies: [{ name: "session", value: "safe", unsupported: true }], + }).success, + false, + ); + assertEquals( + DataResultSchema.safeParse({ + props: {}, + headers: { "x-page-state": "ready" }, + cookies: [{ name: "session", value: "safe", maxAge: 60, httpOnly: true }], + }).success, + true, + ); + }); + + it("keeps standalone cookie validation aligned with runtime rules", () => { + for ( + const cookie of [ + { name: "session", value: "unsafe", maxAge: 1.5 }, + { name: "session", value: "unsafe", expires: "not-a-date" }, + { name: "__Secure-session", value: "unsafe" }, + { name: "__Host-session", value: "unsafe", secure: true, path: "/nested" }, + { name: "session", value: "unsafe", sameSite: "none" }, + ] + ) { + assertEquals(ResponseCookieSchema.safeParse(cookie).success, false); + } + + assertEquals( + ResponseCookieSchema.safeParse({ + name: "__Host-session", + value: "safe", + secure: true, + path: "/", + sameSite: "none", + }).success, + true, + ); + }); +}); diff --git a/src/data/data-result-validation.ts b/src/data/data-result-validation.ts new file mode 100644 index 0000000000..c7587ba212 --- /dev/null +++ b/src/data/data-result-validation.ts @@ -0,0 +1,67 @@ +import type { DataResult, StaticDataResult } from "./types.ts"; +import { normalizeDataResponseMetadata } from "./response-metadata.ts"; + +/** Validate and snapshot a project hook result before recording success. */ +export function validateDataResult( + value: unknown, + hookName: "getStaticData", +): StaticDataResult; +export function validateDataResult( + value: unknown, + hookName: "getServerData", +): DataResult; +export function validateDataResult( + value: unknown, + hookName: "getServerData" | "getStaticData", +): DataResult { + const fail = (): never => { + throw new TypeError(`${hookName} must return a valid data result object`); + }; + if (value === null || typeof value !== "object" || Array.isArray(value)) return fail(); + + const result = value as Record; + const props = result.props; + const redirect = result.redirect; + const notFound = result.notFound; + const revalidate = result.revalidate; + const responseMetadata = normalizeDataResponseMetadata(result, hookName); + let redirectDestination: string | undefined; + let redirectPermanent: boolean | undefined; + + if ( + redirect !== undefined && + (redirect === null || typeof redirect !== "object" || Array.isArray(redirect)) + ) { + return fail(); + } + if (redirect !== undefined) { + const redirectRecord = redirect as Record; + if ( + typeof redirectRecord.destination !== "string" || + (redirectRecord.permanent !== undefined && typeof redirectRecord.permanent !== "boolean") + ) return fail(); + redirectDestination = redirectRecord.destination; + redirectPermanent = redirectRecord.permanent as boolean | undefined; + } + if (notFound !== undefined && typeof notFound !== "boolean") return fail(); + const normalized: DataResult = {}; + if (redirectDestination !== undefined) { + normalized.redirect = { + destination: redirectDestination, + ...(redirectPermanent !== undefined ? { permanent: redirectPermanent } : {}), + }; + } else if (notFound === true) { + normalized.notFound = true; + } else { + if ( + revalidate !== undefined && revalidate !== false && + (typeof revalidate !== "number" || !Number.isFinite(revalidate) || revalidate < 0) + ) return fail(); + if (props !== undefined) normalized.props = props; + if (notFound !== undefined) normalized.notFound = notFound; + if (revalidate !== undefined) normalized.revalidate = revalidate as number | false; + } + if (responseMetadata.headers) normalized.headers = responseMetadata.headers; + if (responseMetadata.cookies) normalized.cookies = responseMetadata.cookies; + return normalized; +} diff --git a/src/data/helpers.test.ts b/src/data/helpers.test.ts index cc8e8f566d..b9ea4aeb94 100644 --- a/src/data/helpers.test.ts +++ b/src/data/helpers.test.ts @@ -31,6 +31,22 @@ describe("helpers.ts", () => { assertEquals(result.redirect?.permanent, false); }); + it("carries response metadata when a redirect is returned or thrown", () => { + const result = redirect("/account", false, { + headers: { "x-auth-result": "signed-in" }, + cookies: [{ name: "session", value: "abc", path: "/", httpOnly: true }], + }); + + assertEquals(result.headers, { "x-auth-result": "signed-in" }); + assertEquals(result.cookies, [{ + name: "session", + value: "abc", + path: "/", + httpOnly: true, + }]); + assertEquals(isDataControlResult(result), true); + }); + it("should handle absolute URLs", () => { const result = redirect("https://example.com/external"); @@ -77,6 +93,13 @@ describe("helpers.ts", () => { assertEquals(result1.notFound, result2.notFound); }); + + it("carries response metadata on a not-found result", () => { + const result = notFound({ headers: { "x-missing-reason": "gone" } }); + + assertEquals(result.notFound, true); + assertEquals(result.headers, { "x-missing-reason": "gone" }); + }); }); describe("isDataControlResult", () => { diff --git a/src/data/helpers.ts b/src/data/helpers.ts index f0f2c7c709..09630c5e00 100644 --- a/src/data/helpers.ts +++ b/src/data/helpers.ts @@ -1,4 +1,4 @@ -import type { DataResult } from "./types.ts"; +import type { DataResponseMetadata, DataResult, StaticDataResult } from "./types.ts"; /** * Brand marking an object as produced by {@link notFound} or {@link redirect}. @@ -20,7 +20,7 @@ const DATA_CONTROL_RESULT = Symbol.for("veryfront.dataControlResult"); * `JSON.stringify`, and the `DataResult` schema. A returned control result * behaves exactly as it did before the brand existed. */ -function brandDataControlResult(result: DataResult): DataResult { +function brandDataControlResult(result: T): T { Object.defineProperty(result, DATA_CONTROL_RESULT, { value: true }); return result; } @@ -31,8 +31,26 @@ function brandDataControlResult(result: DataResult): DataResult { * Return it or throw it. `throw redirect("/login")` behaves exactly like * `return redirect("/login")`. */ -export function redirect(destination: string, permanent = false): DataResult { - return brandDataControlResult({ redirect: { destination, permanent } }); +export function redirect( + destination: string, + permanent?: boolean, + response?: undefined, +): StaticDataResult; +export function redirect( + destination: string, + permanent: boolean | undefined, + response: DataResponseMetadata, +): DataResult; +export function redirect( + destination: string, + permanent = false, + response?: DataResponseMetadata, +): DataResult { + return brandDataControlResult({ + redirect: { destination, permanent }, + ...(response?.headers !== undefined ? { headers: response.headers } : {}), + ...(response?.cookies !== undefined ? { cookies: response.cookies } : {}), + }); } /** @@ -42,8 +60,14 @@ export function redirect(destination: string, permanent = false): DataResult { * `return notFound()`, which is useful deep inside a helper that has no clean * way to return to the loader. */ -export function notFound(): DataResult { - return brandDataControlResult({ notFound: true }); +export function notFound(response?: undefined): StaticDataResult; +export function notFound(response: DataResponseMetadata): DataResult; +export function notFound(response?: DataResponseMetadata): DataResult { + return brandDataControlResult({ + notFound: true, + ...(response?.headers !== undefined ? { headers: response.headers } : {}), + ...(response?.cookies !== undefined ? { cookies: response.cookies } : {}), + }); } /** @@ -78,62 +102,16 @@ export function isDataControlResult(value: unknown): value is DataResult { * for the project. */ export function toDataControlResult(result: DataResult): DataResult { - if (result.redirect) return { redirect: result.redirect }; - return { notFound: true }; -} - -/** Validate and snapshot a project hook result before recording success. */ -export function validateDataResult( - value: unknown, - hookName: "getServerData" | "getStaticData", -): DataResult { - const fail = (): never => { - throw new TypeError(`${hookName} must return a valid data result object`); - }; - if (value === null || typeof value !== "object" || Array.isArray(value)) return fail(); - - const result = value as Record; - const props = result.props; - const redirect = result.redirect; - const notFound = result.notFound; - const revalidate = result.revalidate; - let redirectDestination: string | undefined; - let redirectPermanent: boolean | undefined; - - if ( - redirect !== undefined && - (redirect === null || typeof redirect !== "object" || Array.isArray(redirect)) - ) { - return fail(); - } - if (redirect !== undefined) { - const redirectRecord = redirect as Record; - if ( - typeof redirectRecord.destination !== "string" || - (redirectRecord.permanent !== undefined && typeof redirectRecord.permanent !== "boolean") - ) return fail(); - redirectDestination = redirectRecord.destination; - redirectPermanent = redirectRecord.permanent as boolean | undefined; - } - if (notFound !== undefined && typeof notFound !== "boolean") return fail(); - if ( - revalidate !== undefined && revalidate !== false && - (typeof revalidate !== "number" || !Number.isFinite(revalidate) || revalidate < 0) - ) return fail(); - - const activeOutcomes = Number(props !== undefined) + Number(redirect !== undefined) + - Number(notFound === true); - if (activeOutcomes > 1) return fail(); - - const normalized: DataResult = {}; - if (props !== undefined) normalized.props = props; - if (redirectDestination !== undefined) { - normalized.redirect = { - destination: redirectDestination, - ...(redirectPermanent !== undefined ? { permanent: redirectPermanent } : {}), + if (result.redirect) { + return { + redirect: result.redirect, + ...(result.headers ? { headers: result.headers } : {}), + ...(result.cookies ? { cookies: result.cookies } : {}), }; } - if (notFound !== undefined) normalized.notFound = notFound; - if (revalidate !== undefined) normalized.revalidate = revalidate as number | false; - return normalized; + return { + notFound: true, + ...(result.headers ? { headers: result.headers } : {}), + ...(result.cookies ? { cookies: result.cookies } : {}), + }; } diff --git a/src/data/index.ts b/src/data/index.ts index af4b136de8..e2e1d3c62a 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -8,9 +8,12 @@ export type { CacheEntry, DataContext, + DataResponseMetadata, DataResult, InferGetServerDataProps, PageWithData, + ResponseCookie, + StaticDataResult, StaticPathsResult, } from "./types.ts"; export { DataFetcher, type FetchDataOptions } from "./data-fetcher.ts"; diff --git a/src/data/response-metadata.test.ts b/src/data/response-metadata.test.ts new file mode 100644 index 0000000000..73e6ce17bf --- /dev/null +++ b/src/data/response-metadata.test.ts @@ -0,0 +1,196 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + appendDataResponseMetadata, + getAttachedDataResponseMetadata, + mergeDataResponseMetadata, + normalizeDataResponseMetadata, + serializeResponseCookie, + unwrapDataResponseMetadataError, + wrapDataResponseMetadataError, +} from "./response-metadata.ts"; + +describe("data response metadata", () => { + it("normalizes header names and lets the page override a layout header", () => { + const result = mergeDataResponseMetadata([ + { headers: { "X-Owner": "layout", "x-layout": "yes" } }, + { headers: { "x-owner": "page", "x-page": "yes" } }, + ]); + + assertEquals(result.headers, { + "x-owner": "page", + "x-layout": "yes", + "x-page": "yes", + }); + }); + + it("rejects metadata that exceeds aggregate response limits", () => { + assertThrows( + () => + mergeDataResponseMetadata([ + { + headers: Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`x-layout-${index}`, "value"]), + ), + }, + { + headers: Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`x-page-${index}`, "value"]), + ), + }, + ]), + TypeError, + "cannot return more than 64 response headers", + ); + + assertThrows( + () => + mergeDataResponseMetadata([ + { + cookies: Array.from({ length: 32 }, (_, index) => ({ + name: `layout-${index}`, + value: "value", + })), + }, + { + cookies: Array.from({ length: 33 }, (_, index) => ({ + name: `page-${index}`, + value: "value", + })), + }, + ]), + TypeError, + "cannot return more than 64 response cookies", + ); + }); + + it("rejects framework-owned and case-insensitively duplicate headers", () => { + for ( + const name of [ + "Cache-Control", + "Set-Cookie", + "Access-Control-Allow-Origin", + "Cross-Origin-Opener-Policy", + "X-Veryfront-Dependency-Pins", + ] + ) { + assertThrows( + () => normalizeDataResponseMetadata({ headers: { [name]: "value" } }), + TypeError, + "cannot set framework-owned response header", + ); + } + assertThrows( + () => + normalizeDataResponseMetadata({ + headers: { "X-Trace": "first", "x-trace": "second" }, + }), + TypeError, + 'returned duplicate response header "x-trace"', + ); + assertThrows( + () => normalizeDataResponseMetadata({ headers: { "x-trace": "unsafe\u0001value" } }), + TypeError, + 'returned invalid value for response header "x-trace"', + ); + assertThrows( + () => normalizeDataResponseMetadata({ headers: { "x-trace": "unsafe 🌟 value" } }), + TypeError, + 'returned invalid value for response header "x-trace"', + ); + }); + + it("serializes cookie values and attributes without allowing header injection", () => { + assertEquals( + serializeResponseCookie({ + name: "session", + value: "hello world", + domain: "example.com", + path: "/", + expires: "Tue, 19 Jan 2038 03:14:07 GMT", + maxAge: 60, + httpOnly: true, + secure: true, + sameSite: "lax", + }), + "session=hello%20world; Domain=example.com; Path=/; " + + "Expires=Tue, 19 Jan 2038 03:14:07 GMT; Max-Age=60; HttpOnly; Secure; SameSite=Lax", + ); + assertThrows( + () => serializeResponseCookie({ name: "session", value: "abc", path: "/\r\nunsafe" }), + TypeError, + "returned an invalid path", + ); + assertThrows( + () => serializeResponseCookie({ name: "session", value: "abc", path: "/\u0001unsafe" }), + TypeError, + "returned an invalid path", + ); + assertThrows( + () => serializeResponseCookie({ name: "session", value: "abc", path: "/🌟" }), + TypeError, + "returned an invalid path", + ); + assertThrows( + () => serializeResponseCookie({ name: "session", value: "🌟".repeat(1_000) }), + TypeError, + "exceeds the serialized size limit", + ); + }); + + it("appends custom headers and preserves distinct Set-Cookie fields", () => { + const headers = new Headers({ "x-page-state": "framework" }); + appendDataResponseMetadata(headers, { + headers: { "x-page-state": "application" }, + cookies: [ + { name: "first", value: "1", path: "/" }, + { name: "second", value: "2", secure: true, sameSite: "none" }, + ], + }); + + assertEquals(headers.get("x-page-state"), "framework, application"); + assertEquals(headers.getSetCookie(), [ + "first=1; Path=/", + "second=2; Secure; SameSite=None", + ]); + }); + + it("enforces secure cookie prefix and SameSite=None requirements", () => { + assertThrows( + () => serializeResponseCookie({ name: "__Secure-session", value: "abc" }), + TypeError, + "must set secure", + ); + assertThrows( + () => + serializeResponseCookie({ + name: "__Host-session", + value: "abc", + secure: true, + path: "/account", + }), + TypeError, + 'must set secure, use path "/", and omit domain', + ); + assertThrows( + () => serializeResponseCookie({ name: "session", value: "abc", sameSite: "none" }), + TypeError, + 'with sameSite "none" must set secure', + ); + }); + + it("carries an undefined failure without exposing metadata in the error", () => { + const carrier = wrapDataResponseMetadataError(undefined, { + headers: { "x-page-state": "resolved" }, + cookies: [{ name: "page-seen", value: "1", path: "/" }], + }); + + assertEquals(carrier.message, "Non-Error render failure"); + assertEquals(unwrapDataResponseMetadataError(carrier), undefined); + assertEquals(getAttachedDataResponseMetadata(carrier), { + headers: { "x-page-state": "resolved" }, + cookies: [{ name: "page-seen", value: "1", path: "/" }], + }); + }); +}); diff --git a/src/data/response-metadata.ts b/src/data/response-metadata.ts new file mode 100644 index 0000000000..ba9286a983 --- /dev/null +++ b/src/data/response-metadata.ts @@ -0,0 +1,385 @@ +import type { DataResponseMetadata, ResponseCookie } from "./schemas/data.schema.ts"; +import { HTTP_TOKEN_PATTERN } from "#veryfront/utils/cors-policy-limits.ts"; + +const MAX_RESPONSE_HEADER_COUNT = 64; +const MAX_RESPONSE_HEADER_NAME_LENGTH = 256; +const MAX_RESPONSE_HEADER_VALUE_LENGTH = 8_192; +const MAX_RESPONSE_COOKIE_COUNT = 64; +const MAX_RESPONSE_COOKIE_VALUE_LENGTH = 4_096; +const MAX_SERIALIZED_RESPONSE_COOKIE_LENGTH = 8_192; + +const objectEntries = Object.entries; +const objectKeys = Object.keys; +const getPrototypeOf = Object.getPrototypeOf; +const numberIsSafeInteger = Number.isSafeInteger; +const encodeCookieValue = encodeURIComponent; +const regexpTest = RegExp.prototype.test; +const reflectApply = Reflect.apply; +const stringCharCodeAt = String.prototype.charCodeAt; +const attachedResponseMetadata = new WeakMap(); +const responseMetadataErrorCauses = new WeakMap(); + +const FRAMEWORK_OWNED_RESPONSE_HEADERS = new Set([ + "accept-ch", + "cache-control", + "connection", + "content-encoding", + "content-length", + "content-range", + "content-security-policy", + "content-security-policy-report-only", + "content-type", + "critical-ch", + "date", + "etag", + "expires", + "keep-alive", + "location", + "permissions-policy", + "pragma", + "proxy-authenticate", + "proxy-authorization", + "referrer-policy", + "reporting-endpoints", + "server", + "server-timing", + "set-cookie", + "strict-transport-security", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "vary", + "x-content-type-options", + "x-dns-prefetch-control", + "x-frame-options", + "x-powered-by", + "x-xss-protection", +]); + +const RESPONSE_COOKIE_KEYS = new Set([ + "name", + "value", + "domain", + "path", + "expires", + "maxAge", + "httpOnly", + "secure", + "sameSite", +]); +function matches(pattern: RegExp, value: string): boolean { + return reflectApply(regexpTest, pattern, [value]) as boolean; +} + +function isValidResponseHeaderValue(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = reflectApply(stringCharCodeAt, value, [index]) as number; + if (code > 0xff || code === 0x7f || (code < 0x20 && code !== 0x09)) return false; + } + return true; +} + +function isValidCookieAttribute(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = reflectApply(stringCharCodeAt, value, [index]) as number; + if (code > 0xff || code < 0x20 || code === 0x3b || code === 0x7f) return false; + } + return true; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function invalidMetadata(hookName: "getServerData" | "getStaticData", detail: string): never { + throw new TypeError(`${hookName} ${detail}`); +} + +function isFrameworkOwnedHeader(name: string): boolean { + return FRAMEWORK_OWNED_RESPONSE_HEADERS.has(name) || + name.startsWith("access-control-") || + name.startsWith("cross-origin-") || + name.startsWith("x-veryfront-"); +} + +function normalizeHeaders( + value: unknown, + hookName: "getServerData" | "getStaticData", +): Record | undefined { + if (value === undefined) return undefined; + if (!isPlainRecord(value)) { + return invalidMetadata(hookName, "response headers must be a string record"); + } + + const entries = objectEntries(value); + if (entries.length > MAX_RESPONSE_HEADER_COUNT) { + return invalidMetadata( + hookName, + `cannot return more than ${MAX_RESPONSE_HEADER_COUNT} response headers`, + ); + } + + const normalizedEntries: Array<[string, string]> = []; + const seen = new Set(); + for (const [rawName, rawValue] of entries) { + const name = rawName.toLowerCase(); + if ( + rawName.length === 0 || rawName.length > MAX_RESPONSE_HEADER_NAME_LENGTH || + !matches(HTTP_TOKEN_PATTERN, rawName) + ) { + return invalidMetadata(hookName, `returned invalid response header name "${rawName}"`); + } + if (isFrameworkOwnedHeader(name)) { + return invalidMetadata( + hookName, + `cannot set framework-owned response header "${name}"`, + ); + } + if (seen.has(name)) { + return invalidMetadata(hookName, `returned duplicate response header "${name}"`); + } + if ( + typeof rawValue !== "string" || rawValue.length > MAX_RESPONSE_HEADER_VALUE_LENGTH || + !isValidResponseHeaderValue(rawValue) + ) { + return invalidMetadata(hookName, `returned invalid value for response header "${name}"`); + } + seen.add(name); + normalizedEntries.push([name, rawValue]); + } + + return normalizedEntries.length > 0 ? Object.fromEntries(normalizedEntries) : undefined; +} + +function normalizeCookie( + value: unknown, + hookName: "getServerData" | "getStaticData", +): ResponseCookie { + if (!isPlainRecord(value)) { + return invalidMetadata(hookName, "response cookies must be plain objects"); + } + for (const key of objectKeys(value)) { + if (!RESPONSE_COOKIE_KEYS.has(key)) { + return invalidMetadata(hookName, `returned unknown response cookie field "${key}"`); + } + } + + const name = value.name; + const cookieValue = value.value; + if ( + typeof name !== "string" || name.length === 0 || + name.length > MAX_RESPONSE_HEADER_NAME_LENGTH || !matches(HTTP_TOKEN_PATTERN, name) + ) { + return invalidMetadata(hookName, "returned an invalid response cookie name"); + } + if (typeof cookieValue !== "string" || cookieValue.length > MAX_RESPONSE_COOKIE_VALUE_LENGTH) { + return invalidMetadata(hookName, `returned an invalid value for response cookie "${name}"`); + } + try { + encodeCookieValue(cookieValue); + } catch { + return invalidMetadata(hookName, `returned an invalid value for response cookie "${name}"`); + } + + const result: ResponseCookie = { name, value: cookieValue }; + for (const key of ["domain", "path", "expires"] as const) { + const field = value[key]; + if (field === undefined) continue; + if ( + typeof field !== "string" || field.length === 0 || + field.length > MAX_RESPONSE_HEADER_VALUE_LENGTH || + !isValidCookieAttribute(field) + ) { + return invalidMetadata(hookName, `returned an invalid ${key} for response cookie "${name}"`); + } + result[key] = field; + } + if (result.expires !== undefined && !Number.isFinite(Date.parse(result.expires))) { + return invalidMetadata(hookName, `returned an invalid expires for response cookie "${name}"`); + } + + const maxAge = value.maxAge; + if (maxAge !== undefined) { + if (typeof maxAge !== "number" || !numberIsSafeInteger(maxAge)) { + return invalidMetadata(hookName, `returned an invalid maxAge for response cookie "${name}"`); + } + result.maxAge = maxAge; + } + for (const key of ["httpOnly", "secure"] as const) { + const field = value[key]; + if (field === undefined) continue; + if (typeof field !== "boolean") { + return invalidMetadata(hookName, `returned an invalid ${key} for response cookie "${name}"`); + } + result[key] = field; + } + + const sameSite = value.sameSite; + if (sameSite !== undefined) { + if (sameSite !== "lax" && sameSite !== "strict" && sameSite !== "none") { + return invalidMetadata( + hookName, + `returned an invalid sameSite for response cookie "${name}"`, + ); + } + result.sameSite = sameSite; + } + + if (name.startsWith("__Secure-") && result.secure !== true) { + return invalidMetadata(hookName, `response cookie "${name}" must set secure`); + } + if ( + name.startsWith("__Host-") && + (result.secure !== true || result.path !== "/" || result.domain !== undefined) + ) { + return invalidMetadata( + hookName, + `response cookie "${name}" must set secure, use path "/", and omit domain`, + ); + } + if (result.sameSite === "none" && result.secure !== true) { + return invalidMetadata( + hookName, + `response cookie "${name}" with sameSite "none" must set secure`, + ); + } + if (serializeNormalizedResponseCookie(result).length > MAX_SERIALIZED_RESPONSE_COOKIE_LENGTH) { + return invalidMetadata( + hookName, + `response cookie "${name}" exceeds the serialized size limit`, + ); + } + + return result; +} + +function normalizeCookies( + value: unknown, + hookName: "getServerData" | "getStaticData", +): ResponseCookie[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + return invalidMetadata(hookName, "response cookies must be an array"); + } + if (value.length > MAX_RESPONSE_COOKIE_COUNT) { + return invalidMetadata( + hookName, + `cannot return more than ${MAX_RESPONSE_COOKIE_COUNT} response cookies`, + ); + } + const cookies = value.map((cookie) => normalizeCookie(cookie, hookName)); + return cookies.length > 0 ? cookies : undefined; +} + +export function normalizeDataResponseMetadata( + value: { headers?: unknown; cookies?: unknown }, + hookName: "getServerData" | "getStaticData" = "getServerData", +): DataResponseMetadata { + if ( + hookName === "getStaticData" && + (value.headers !== undefined || value.cookies !== undefined) + ) { + return invalidMetadata(hookName, "cannot return response headers or cookies"); + } + + const headers = normalizeHeaders(value.headers, hookName); + const cookies = normalizeCookies(value.cookies, hookName); + return { + ...(headers ? { headers } : {}), + ...(cookies ? { cookies } : {}), + }; +} + +export function mergeDataResponseMetadata( + metadata: readonly DataResponseMetadata[], +): DataResponseMetadata { + const headers = new Map(); + const cookies: ResponseCookie[] = []; + for (const value of metadata) { + const normalized = normalizeDataResponseMetadata(value); + for (const [name, headerValue] of objectEntries(normalized.headers ?? {})) { + headers.set(name, headerValue); + } + cookies.push(...(normalized.cookies ?? [])); + } + return normalizeDataResponseMetadata({ + ...(headers.size > 0 ? { headers: Object.fromEntries(headers) } : {}), + ...(cookies.length > 0 ? { cookies } : {}), + }); +} + +/** + * Carry validated response metadata through an internal error path without + * placing cookie values in enumerable error context, logs, or telemetry. + */ +export function attachDataResponseMetadata( + carrier: T, + metadata: DataResponseMetadata, +): T { + attachedResponseMetadata.set(carrier, normalizeDataResponseMetadata(metadata)); + return carrier; +} + +/** + * Carry response metadata on a request-local error while retaining the project + * error for classification, reporting, and stack rendering. + */ +export function wrapDataResponseMetadataError( + error: unknown, + metadata: DataResponseMetadata, +): Error { + const classifiedError = error instanceof Error ? unwrapDataResponseMetadataError(error) : error; + const carrier = classifiedError instanceof Error + ? new Error(classifiedError.message, { cause: classifiedError }) + : new Error("Non-Error render failure"); + if (classifiedError instanceof Error) { + carrier.name = classifiedError.name; + carrier.stack = classifiedError.stack; + } + responseMetadataErrorCauses.set(carrier, classifiedError); + return attachDataResponseMetadata(carrier, metadata); +} + +/** Return the project failure carried by {@link wrapDataResponseMetadataError}. */ +export function unwrapDataResponseMetadataError(error: Error): unknown { + return responseMetadataErrorCauses.has(error) ? responseMetadataErrorCauses.get(error) : error; +} + +/** Read response metadata attached by {@link attachDataResponseMetadata}. */ +export function getAttachedDataResponseMetadata(carrier: Error): DataResponseMetadata { + return attachedResponseMetadata.get(carrier) ?? {}; +} + +function serializeNormalizedResponseCookie(cookie: ResponseCookie): string { + const parts = [`${cookie.name}=${encodeCookieValue(cookie.value)}`]; + if (cookie.domain) parts.push(`Domain=${cookie.domain}`); + if (cookie.path) parts.push(`Path=${cookie.path}`); + if (cookie.expires) parts.push(`Expires=${new Date(cookie.expires).toUTCString()}`); + if (cookie.maxAge !== undefined) parts.push(`Max-Age=${cookie.maxAge}`); + if (cookie.httpOnly) parts.push("HttpOnly"); + if (cookie.secure) parts.push("Secure"); + if (cookie.sameSite) { + parts.push(`SameSite=${cookie.sameSite[0]!.toUpperCase()}${cookie.sameSite.slice(1)}`); + } + return parts.join("; "); +} + +export function serializeResponseCookie(cookie: ResponseCookie): string { + return serializeNormalizedResponseCookie(normalizeCookie(cookie, "getServerData")); +} + +export function appendDataResponseMetadata( + target: Headers, + metadata: DataResponseMetadata, +): void { + const normalized = normalizeDataResponseMetadata(metadata); + for (const [name, value] of objectEntries(normalized.headers ?? {})) { + target.append(name, value); + } + for (const cookie of normalized.cookies ?? []) { + target.append("Set-Cookie", serializeResponseCookie(cookie)); + } +} diff --git a/src/data/schemas/data.schema.ts b/src/data/schemas/data.schema.ts index 545d15ab7b..11c4ee9432 100644 --- a/src/data/schemas/data.schema.ts +++ b/src/data/schemas/data.schema.ts @@ -1,5 +1,35 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; +import { normalizeDataResponseMetadata } from "../response-metadata.ts"; + +function hasValidResponseMetadata(value: { headers?: unknown; cookies?: unknown }): boolean { + try { + normalizeDataResponseMetadata(value); + return true; + } catch { + return false; + } +} + +function hasValidResponseCookie(value: unknown): boolean { + try { + normalizeDataResponseMetadata({ cookies: [value] }); + return true; + } catch { + return false; + } +} + +function hasValidActiveRevalidate(value: { + redirect?: unknown; + notFound?: unknown; + revalidate?: unknown; +}): boolean { + if (value.redirect !== undefined || value.notFound === true) return true; + return value.revalidate === undefined || value.revalidate === false || + (typeof value.revalidate === "number" && + Number.isFinite(value.revalidate) && value.revalidate >= 0); +} /** Context passed to data fetching functions */ export const getDataContextSchema = defineSchema((v) => @@ -18,14 +48,46 @@ export const getRedirectSchema = defineSchema((v) => }) ); +export const getResponseCookieSchema = defineSchema((v) => + v.object({ + name: v.string(), + value: v.string(), + domain: v.string().optional(), + path: v.string().optional(), + expires: v.string().optional(), + maxAge: v.number().optional(), + httpOnly: v.boolean().optional(), + secure: v.boolean().optional(), + sameSite: v.union([v.literal("lax"), v.literal("strict"), v.literal("none")]).optional(), + }).strict().refine(hasValidResponseCookie, "Response cookie is invalid") +); + /** Result returned from data fetching functions */ export const getDataResultSchema = defineSchema((v) => v.object({ props: v.unknown().optional(), redirect: getRedirectSchema().optional(), notFound: v.boolean().optional(), - revalidate: v.union([v.number(), v.literal(false)]).optional(), + revalidate: v.unknown().optional(), + headers: v.record(v.string(), v.string()).optional(), + cookies: v.array(getResponseCookieSchema()).optional(), }) + .refine(hasValidActiveRevalidate, "Data result revalidate value is invalid") + .refine(hasValidResponseMetadata, "Data result response metadata is invalid") +); + +/** Cache-safe result returned from getStaticData. */ +export const getStaticDataResultSchema = defineSchema((v) => + v.object({ + props: v.unknown().optional(), + redirect: getRedirectSchema().optional(), + notFound: v.boolean().optional(), + revalidate: v.unknown().optional(), + headers: v.custom(() => false, "getStaticData cannot return response headers") + .optional(), + cookies: v.custom(() => false, "getStaticData cannot return response cookies") + .optional(), + }).refine(hasValidActiveRevalidate, "Static data result revalidate value is invalid") ); export const getStaticPathEntrySchema = defineSchema((v) => @@ -43,7 +105,7 @@ export const getStaticPathsResultSchema = defineSchema((v) => export const getCacheEntrySchema = defineSchema((v) => v.object({ - data: getDataResultSchema(), + data: getStaticDataResultSchema(), timestamp: v.number(), revalidate: v.union([v.number(), v.literal(false)]).optional(), }) @@ -53,20 +115,45 @@ export const getCacheEntrySchema = defineSchema((v) => /** Context passed to `getServerData()`. */ export type DataContext = InferSchema>; export type Redirect = InferSchema>; -export type DataResult = InferSchema> & { - props?: T; -}; +/** One cookie emitted as a distinct Set-Cookie response field. */ +export type ResponseCookie = InferSchema>; +/** Custom document response metadata returned from `getServerData()`. */ +export interface DataResponseMetadata { + headers?: Record; + cookies?: ResponseCookie[]; +} +/** Props, routing control, caching, and response metadata returned from `getServerData()`. */ +export type DataResult = + & Omit>, "props" | "revalidate"> + & { + props?: T; + revalidate?: number | false; + }; +/** Cache-safe result returned from `getStaticData()`. */ +export type StaticDataResult = + & Omit< + InferSchema>, + "props" | "revalidate" | "headers" | "cookies" + > + & { + props?: T; + revalidate?: number | false; + headers?: never; + cookies?: never; + }; export type StaticPathEntry = InferSchema>; /** Return type for `getStaticPaths()`. */ export type StaticPathsResult = InferSchema>; export type CacheEntry = InferSchema> & { - data: DataResult; + data: StaticDataResult; }; // Backward compat aliases export const DataContextSchema = lazySchema(getDataContextSchema); export const RedirectSchema = lazySchema(getRedirectSchema); +export const ResponseCookieSchema = lazySchema(getResponseCookieSchema); export const DataResultSchema = lazySchema(getDataResultSchema); +export const StaticDataResultSchema = lazySchema(getStaticDataResultSchema); export const StaticPathEntrySchema = lazySchema(getStaticPathEntrySchema); export const StaticPathsResultSchema = lazySchema(getStaticPathsResultSchema); export const CacheEntrySchema = lazySchema(getCacheEntrySchema); diff --git a/src/data/schemas/index.ts b/src/data/schemas/index.ts index a749d15ab9..a9d8926662 100644 --- a/src/data/schemas/index.ts +++ b/src/data/schemas/index.ts @@ -9,10 +9,15 @@ export { CacheEntrySchema, type DataContext, DataContextSchema, + type DataResponseMetadata, type DataResult, DataResultSchema, type Redirect, RedirectSchema, + type ResponseCookie, + ResponseCookieSchema, + type StaticDataResult, + StaticDataResultSchema, type StaticPathEntry, StaticPathEntrySchema, type StaticPathsResult, diff --git a/src/data/server-data-fetcher.test.ts b/src/data/server-data-fetcher.test.ts index 6c1ef3a7ae..2c3dc9cd4a 100644 --- a/src/data/server-data-fetcher.test.ts +++ b/src/data/server-data-fetcher.test.ts @@ -226,6 +226,52 @@ describe("ServerDataFetcher", () => { assertEquals(result.revalidate, 60); }); + it("preserves response metadata returned from getServerData", async () => { + const fetcher = new ServerDataFetcher(); + const pageModule: PageWithData = { + default: () => null, + getServerData: (): DataResult => ({ + props: { ok: true }, + headers: { "x-page-state": "fresh" }, + cookies: [{ + name: "session", + value: "abc", + path: "/", + httpOnly: true, + sameSite: "lax", + }], + }), + }; + + const result = await fetcher.fetch(pageModule, createContext()); + + assertEquals(result.headers, { "x-page-state": "fresh" }); + assertEquals(result.cookies, [{ + name: "session", + value: "abc", + path: "/", + httpOnly: true, + sameSite: "lax", + }]); + }); + + it("rejects framework-owned headers from getServerData", async () => { + const fetcher = new ServerDataFetcher(); + const pageModule: PageWithData = { + default: () => null, + getServerData: () => ({ + props: {}, + headers: { "set-cookie": "session=unsafe" }, + } as DataResult & { headers: Record }), + }; + + await assertRejects( + () => fetcher.fetch(pageModule, createContext()), + TypeError, + 'getServerData cannot set framework-owned response header "set-cookie"', + ); + }); + it("should handle revalidate: false", async () => { const fetcher = new ServerDataFetcher(); const pageModule: PageWithData = { @@ -498,6 +544,24 @@ describe("ServerDataFetcher", () => { assertEquals(result.notFound, undefined); }); + it("preserves response metadata from a thrown redirect()", async () => { + const fetcher = new ServerDataFetcher(); + const pageModule: PageWithData = { + default: () => null, + getServerData: () => { + throw redirect("/account", false, { + headers: { "x-auth-result": "signed-in" }, + cookies: [{ name: "session", value: "abc", path: "/" }], + }); + }, + }; + + const result = await fetcher.fetch(pageModule, createContext()); + + assertEquals(result.headers, { "x-auth-result": "signed-in" }); + assertEquals(result.cookies, [{ name: "session", value: "abc", path: "/" }]); + }); + it("still propagates a genuine Error", async () => { const fetcher = new ServerDataFetcher(); const pageModule: PageWithData = { @@ -702,7 +766,11 @@ describe("ServerDataFetcher", () => { it("treats a thrown redirect() as a redirect result", async () => { const { modulePath, projectDir: dir } = await writeIsolatedPage( `export function getServerData() { - const result = { redirect: { destination: "/login", permanent: true } }; + const result = { + redirect: { destination: "/login", permanent: true }, + headers: { "x-auth-result": "signed-in" }, + cookies: [{ name: "session", value: "abc", path: "/" }], + }; ${BRAND_SOURCE} throw result; } @@ -714,6 +782,8 @@ describe("ServerDataFetcher", () => { assertEquals(result.redirect?.destination, "/login"); assertEquals(result.redirect?.permanent, true); assertEquals(result.notFound, undefined); + assertEquals(result.headers, { "x-auth-result": "signed-in" }); + assertEquals(result.cookies, [{ name: "session", value: "abc", path: "/" }]); }); it("still propagates a genuine Error thrown in the worker", async () => { diff --git a/src/data/server-data-fetcher.ts b/src/data/server-data-fetcher.ts index 91bb8425a8..16424cedd0 100644 --- a/src/data/server-data-fetcher.ts +++ b/src/data/server-data-fetcher.ts @@ -1,5 +1,6 @@ import type { DataContext, DataResult, PageWithData } from "./types.ts"; import { isDataControlResult, toDataControlResult } from "./helpers.ts"; +import { validateDataResult } from "./data-result-validation.ts"; import { serverLogger } from "#veryfront/utils"; import { DATA_FETCH_TIMEOUT_MS } from "#veryfront/config/defaults.ts"; import { TimeoutError, withTimeoutThrow } from "#veryfront/rendering/utils/stream-utils.ts"; @@ -172,10 +173,10 @@ export class ServerDataFetcher { } }); - if (result.redirect) return { redirect: result.redirect }; - if (result.notFound) return { notFound: true }; + const validated = validateDataResult(result, "getServerData"); + if (validated.redirect || validated.notFound) return validated; - return { props: result.props ?? {}, revalidate: result.revalidate }; + return { ...validated, props: validated.props ?? {} }; } catch (error) { const durationMs = Math.round(performance.now() - start); diff --git a/src/data/static-data-fetcher.test.ts b/src/data/static-data-fetcher.test.ts index 1d257bf3db..e40a850813 100644 --- a/src/data/static-data-fetcher.test.ts +++ b/src/data/static-data-fetcher.test.ts @@ -81,6 +81,23 @@ describe("StaticDataFetcher", () => { assertEquals(receivedUrl.pathname, "/posts/123"); }); + it("rejects response metadata from getStaticData so caches cannot replay it", async () => { + const { fetcher } = createFetcher(); + const pageModule: PageWithData = { + default: () => null, + getStaticData: () => ({ + props: {}, + cookies: [{ name: "session", value: "stale" }], + } as any), + }; + + await assertRejects( + () => withProductionContext(() => fetcher.fetch(pageModule, createContext())), + TypeError, + "getStaticData cannot return response headers or cookies", + ); + }); + it("should NOT pass request or query to getStaticData", async () => { const { fetcher } = createFetcher(); let receivedContext: @@ -463,6 +480,24 @@ describe("StaticDataFetcher", () => { }; } + it("rejects response metadata from a thrown static control result", async () => { + const { fetcher } = createFetcher(); + + await assertRejects( + () => + fetcher.fetch( + throwing( + redirect("/login", false, { + cookies: [{ name: "session", value: "stale" }], + }), + ), + createContext(), + ), + TypeError, + "getStaticData cannot return response headers or cookies", + ); + }); + it("treats a thrown notFound() as a 404 result without a cache context", async () => { const { fetcher } = createFetcher(); const result = await fetcher.fetch(throwing(notFound()), createContext()); diff --git a/src/data/static-data-fetcher.ts b/src/data/static-data-fetcher.ts index ea1cb6ab8f..57d52c92ba 100644 --- a/src/data/static-data-fetcher.ts +++ b/src/data/static-data-fetcher.ts @@ -1,6 +1,7 @@ import type { CacheManager } from "./data-fetching-cache.ts"; import { isDataControlResult, toDataControlResult } from "./helpers.ts"; -import type { DataContext, DataResult, PageWithData } from "./types.ts"; +import { validateDataResult } from "./data-result-validation.ts"; +import type { DataContext, PageWithData, StaticDataResult } from "./types.ts"; import { serverLogger } from "#veryfront/utils"; import { DATA_FETCH_TIMEOUT_MS } from "#veryfront/config/defaults.ts"; import { TimeoutError, withTimeoutThrow } from "#veryfront/rendering/utils/stream-utils.ts"; @@ -72,7 +73,7 @@ export class StaticDataFetcher { pageModule: PageWithData, context: DataContext, options: StaticDataFetchOptions = {}, - ): Promise { + ): Promise { const getStaticData = pageModule.getStaticData; if (typeof getStaticData !== "function") return { props: {} }; @@ -127,25 +128,28 @@ export class StaticDataFetcher { context: DataContext, timeoutMs: number, label: string, - ): Promise { + ): Promise { try { - return await withTimeoutThrow( + const result = await withTimeoutThrow( Promise.resolve(getStaticData(this.createStaticDataContext(context))), timeoutMs, label, ); + return validateDataResult(result, "getStaticData"); } catch (error) { // `throw notFound()` / `throw redirect(...)`: treat a thrown control // result exactly like a returned one. Normalising at the one place every // path runs the handler covers the cached path as well as the preview // one, and keeps a 404 from counting against the caller's circuit // breaker. - if (isDataControlResult(error)) return toDataControlResult(error); + if (isDataControlResult(error)) { + return validateDataResult(toDataControlResult(error), "getStaticData"); + } throw error; } } - private storeCacheEntry(cacheKey: string, result: DataResult): void { + private storeCacheEntry(cacheKey: string, result: StaticDataResult): void { this.cacheManager.set(cacheKey, { data: result, timestamp: Date.now(), @@ -156,7 +160,7 @@ export class StaticDataFetcher { private async fetchFreshNoCache( getStaticData: StaticDataHandler, context: DataContext, - ): Promise { + ): Promise { const pathname = context.url?.pathname ?? "unknown"; const start = performance.now(); @@ -188,7 +192,7 @@ export class StaticDataFetcher { getStaticData: StaticDataHandler, context: DataContext, cacheKey: string, - ): Promise { + ): Promise { const pathname = context.url?.pathname ?? "unknown"; // Extract projectId from request headers (set by proxy) for proper circuit breaker isolation const projectId = resolveProjectId(context, "default"); diff --git a/src/data/types.test.ts b/src/data/types.test.ts index ec175329e5..8f376e434f 100644 --- a/src/data/types.test.ts +++ b/src/data/types.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { notFound, redirect } from "./helpers.ts"; import type { CacheEntry, DataContext, @@ -81,6 +82,24 @@ describe("types.ts", () => { assertEquals(result.revalidate, false); }); + + it("should support document response headers and cookies", () => { + const result: DataResult = { + props: {}, + headers: { "x-page-state": "fresh" }, + cookies: [{ + name: "session", + value: "abc", + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + }], + }; + + assertEquals(result.headers?.["x-page-state"], "fresh"); + assertEquals(result.cookies?.[0]?.name, "session"); + }); }); describe("PageWithData", () => { @@ -105,6 +124,32 @@ describe("types.ts", () => { assertExists(pageModule.getStaticData); }); + it("supports metadata-free control helpers from getStaticData", () => { + const missingPage: PageWithData = { + default: () => null, + getStaticData: () => notFound(), + }; + const redirectingPage: PageWithData = { + default: () => null, + getStaticData: () => redirect("/next"), + }; + + assertExists(missingPage.getStaticData); + assertExists(redirectingPage.getStaticData); + }); + + it("keeps legacy DataResult annotations assignable to getStaticData", () => { + const getStaticData = (): DataResult<{ data: string }> => ({ + props: { data: "static" }, + }); + const pageModule: PageWithData = { + default: () => null, + getStaticData, + }; + + assertExists(pageModule.getStaticData); + }); + it("should support getStaticPaths", () => { const pageModule: PageWithData = { default: () => null, diff --git a/src/data/types.ts b/src/data/types.ts index 0613fa7a3d..4704d622f6 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -5,7 +5,15 @@ */ // Re-export schema types -export type { CacheEntry, DataContext, DataResult, StaticPathsResult } from "./schemas/index.ts"; +export type { + CacheEntry, + DataContext, + DataResponseMetadata, + DataResult, + ResponseCookie, + StaticDataResult, + StaticPathsResult, +} from "./schemas/index.ts"; // Import for use in interfaces import type { DataContext, DataResult, StaticPathsResult } from "./schemas/index.ts"; diff --git a/src/index.client.ts b/src/index.client.ts index 643cb9cff3..533c45aefb 100644 --- a/src/index.client.ts +++ b/src/index.client.ts @@ -51,8 +51,12 @@ export type { APIContext, APIHandler, APIResponse, APIRoute } from "#veryfront/r export { notFound, redirect } from "#veryfront/data"; export type { DataContext, + DataResponseMetadata, + DataResult, InferGetServerDataProps, PageWithData, + ResponseCookie, + StaticDataResult, StaticPathsResult, } from "#veryfront/data"; diff --git a/src/index.ts b/src/index.ts index be80aec827..893e9a142b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,8 +56,12 @@ export type { APIContext, APIHandler, APIResponse, APIRoute } from "#veryfront/r export { notFound, redirect } from "#veryfront/data"; export type { DataContext, + DataResponseMetadata, + DataResult, InferGetServerDataProps, PageWithData, + ResponseCookie, + StaticDataResult, StaticPathsResult, } from "#veryfront/data"; diff --git a/src/rendering/cache/cache-coordinator.test.ts b/src/rendering/cache/cache-coordinator.test.ts index aaa54f772f..4ffad0b73d 100644 --- a/src/rendering/cache/cache-coordinator.test.ts +++ b/src/rendering/cache/cache-coordinator.test.ts @@ -104,6 +104,30 @@ describe("CacheCoordinator", () => { assertEquals(stored?.staleUntil, (stored?.expiresAt ?? 0) + 501); }); + it("persists custom response headers in lifecycle cache payloads", async () => { + let stored: CachePayload | undefined; + const store: CacheStore = { + get: () => Promise.resolve(stored), + set: (_key, value) => { + stored = value; + return Promise.resolve(); + }, + delete: () => Promise.resolve(), + clear: () => Promise.resolve(), + destroy: () => Promise.resolve(), + }; + const coordinator = new CacheCoordinator({ store, projectId: "headers" }); + const result = makeResult("header result"); + result.headers = { "x-page-state": "cached" }; + + await coordinator.persistResult(result, "entry"); + + assertEquals(stored?.result.headers, { "x-page-state": "cached" }); + assertEquals((await coordinator.checkCache("entry")).cachedResult?.headers, { + "x-page-state": "cached", + }); + }); + it("evicts malformed store values and treats them as misses", async () => { let deletedKey: string | undefined; const store: CacheStore = { diff --git a/src/rendering/cache/cache-coordinator.ts b/src/rendering/cache/cache-coordinator.ts index 10ff2a63bc..6e9cc8a709 100644 --- a/src/rendering/cache/cache-coordinator.ts +++ b/src/rendering/cache/cache-coordinator.ts @@ -216,6 +216,7 @@ export class CacheCoordinator { stream: null, ssrHash: result.ssrHash, pageModule: result.pageModule, + ...(result.headers ? { headers: result.headers } : {}), }, ...(sealedHtml.placeholder === undefined ? {} diff --git a/src/rendering/cache/cache-payload.test.ts b/src/rendering/cache/cache-payload.test.ts index 0b0d48a881..a9b0c83234 100644 --- a/src/rendering/cache/cache-payload.test.ts +++ b/src/rendering/cache/cache-payload.test.ts @@ -30,6 +30,17 @@ function setFrontmatter(payload: CachePayload, value: unknown): void { } describe("rendering/cache/cache-payload", () => { + it("round-trips custom response headers through memory and serialized payloads", () => { + const payload = payloadWithNodeMap(); + payload.result.headers = { "x-page-state": "cached" }; + + const memory = cloneCachePayload(payload); + const serialized = parseSerializedCachePayload(serializeCachePayload(payload)); + + assertEquals(memory.result.headers, { "x-page-state": "cached" }); + assertEquals(serialized?.result.headers, { "x-page-state": "cached" }); + }); + it("keeps memory snapshots equivalent to serialized snapshots", () => { const memory = cloneCachePayload(payloadWithNodeMap()); const serialized = parseCachePayload(JSON.parse(serializeCachePayload(payloadWithNodeMap()))); diff --git a/src/rendering/cache/cache-payload.ts b/src/rendering/cache/cache-payload.ts index b6329c356c..8954859eb0 100644 --- a/src/rendering/cache/cache-payload.ts +++ b/src/rendering/cache/cache-payload.ts @@ -1,6 +1,7 @@ import type { RenderResult } from "../orchestrator/types.ts"; import type { CachePayload } from "./types.ts"; import { isHtmlNonceCachePlaceholder } from "#veryfront/html/nonce-injection.ts"; +import { normalizeDataResponseMetadata } from "#veryfront/data/response-metadata.ts"; const MAX_CACHE_VALUE_DEPTH = 64; const MAX_CACHE_VALUE_NODES = 100_000; @@ -160,6 +161,58 @@ function optionalTimestamp( return value; } +function cloneResponseHeaders( + value: unknown, + state: CloneState, +): Record | undefined { + if (value === undefined) return undefined; + if (!isPlainRecord(value)) fail("result.headers must be an object"); + + const detached: Record = {}; + for (const key of Object.keys(value)) { + const entry = ownDataValue(value, key); + if (typeof entry !== "string") fail("result.headers must contain only strings"); + Object.defineProperty(detached, key, { + value: entry, + enumerable: true, + configurable: true, + writable: true, + }); + } + + let normalized: Record | undefined; + try { + normalized = normalizeDataResponseMetadata({ headers: detached }).headers; + } catch { + fail("result.headers is invalid"); + } + if (!normalized) return undefined; + + countNode(state, 0); + const cloned: Record = {}; + for (const [name, headerValue] of Object.entries(normalized)) { + const clonedName = cloneBoundedString( + name, + state, + "result.headers name", + MAX_METADATA_STRING_UTF8_BYTES, + ); + const clonedValue = cloneBoundedString( + headerValue, + state, + "result.headers value", + MAX_METADATA_STRING_UTF8_BYTES, + ); + Object.defineProperty(cloned, clonedName, { + value: clonedValue, + enumerable: true, + configurable: true, + writable: true, + }); + } + return cloned; +} + function cloneNodeMapEntries( result: Record, payload: Record, @@ -891,6 +944,7 @@ function buildCachePayload(value: unknown): CachePayload { "htmlNoncePlaceholder", MAX_METADATA_STRING_UTF8_BYTES, ); + const headers = cloneResponseHeaders(ownDataValue(result, "headers"), state); const frontmatter = cloneJsonValue(rawFrontmatter, state); if (!isPlainRecord(frontmatter)) fail("result.frontmatter must be an object"); @@ -925,6 +979,7 @@ function buildCachePayload(value: unknown): CachePayload { stream: null, ...(pageModule === undefined ? {} : { pageModule }), ...(clonedSsrHash === undefined ? {} : { ssrHash: clonedSsrHash }), + ...(headers === undefined ? {} : { headers }), }, ...(htmlNoncePlaceholder === undefined ? {} : { htmlNoncePlaceholder }), storedAt, @@ -983,6 +1038,7 @@ export function serializeCachePayload(value: CachePayload): string { ? {} : { pageModule: snapshot.result.pageModule }), ...(snapshot.result.ssrHash === undefined ? {} : { ssrHash: snapshot.result.ssrHash }), + ...(snapshot.result.headers === undefined ? {} : { headers: snapshot.result.headers }), }, ...(snapshot.htmlNoncePlaceholder === undefined ? {} diff --git a/src/rendering/orchestrator/pipeline.behavior.test.ts b/src/rendering/orchestrator/pipeline.behavior.test.ts index 67e6b8726f..b2d3612d8a 100644 --- a/src/rendering/orchestrator/pipeline.behavior.test.ts +++ b/src/rendering/orchestrator/pipeline.behavior.test.ts @@ -3,7 +3,7 @@ import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.t import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { FakeTime } from "#std/testing/time"; import { RenderPipeline, type RenderPipelineConfig } from "./pipeline.ts"; -import type { RenderOptions } from "./types.ts"; +import type { RenderOptions, RenderResult } from "./types.ts"; import { isTenantBuildFailure, markBuildFailure } from "./module-loader/build-failure.ts"; import { COMPILATION_ERROR, createError, SSG_GENERATION_ERROR, toError } from "#veryfront/errors"; import { cachePageCss, getPageCssCacheKey } from "./css-cache.ts"; @@ -27,6 +27,12 @@ import { globalModuleCache, } from "#veryfront/modules/react-loader/ssr-module-loader/cache/index.ts"; import { hashString } from "#veryfront/cache/hash.ts"; +import { resolveSSRControlOutcome } from "#veryfront/rendering/ssr-outcome.ts"; +import { + getAttachedDataResponseMetadata, + unwrapDataResponseMetadataError, +} from "#veryfront/data/response-metadata.ts"; +import { notFound } from "#veryfront/data/helpers.ts"; const RELEASE_CSS_HASH = "c".repeat(64); @@ -555,6 +561,305 @@ describe("RenderPipeline behavior", () => { }); }); + it("merges layout and page response metadata and does not cache cookies", async () => { + const pagePath = "/project/pages/response-metadata.tsx"; + const rootLayoutPath = "/project/layouts/root.tsx"; + const nestedLayoutPath = "/project/layouts/docs.tsx"; + let cacheWrites = 0; + const pipeline = createPipeline(pagePath, { + cacheCoordinator: { + checkCache: async () => null, + persistResult: async () => { + cacheWrites++; + }, + } as any, + layoutOrchestrator: { + collectLayouts: async () => ({ + layoutBundle: undefined, + nestedLayouts: [ + { kind: "tsx", componentPath: rootLayoutPath }, + { kind: "tsx", componentPath: nestedLayoutPath }, + ], + }), + preloadLayoutModules: async () => ({ + tsxTotal: 2, + tsxSuccess: 2, + tsxFailures: [], + mdxTotal: 0, + mdxSuccess: 0, + mdxFailures: [], + importMapSuccess: true, + durationMs: 0, + allSuccess: true, + }), + applyLayoutsAndWrappers: async (element: unknown) => element, + } as any, + }); + + (pipeline as any).loadModule = async (path: string) => ({ + getServerData: () => { + if (path === rootLayoutPath) { + return { + props: {}, + headers: { "x-owner": "root", "x-root": "yes" }, + cookies: [{ name: "root", value: "1", path: "/" }], + }; + } + if (path === nestedLayoutPath) { + return { + props: {}, + headers: { "x-owner": "nested", "x-nested": "yes" }, + cookies: [{ name: "nested", value: "2", path: "/" }], + }; + } + return { + props: {}, + headers: { "x-owner": "page", "x-page": "yes" }, + cookies: [{ name: "page", value: "3", path: "/" }], + }; + }, + }); + + const result = await pipeline.renderPage("/response-metadata", { + request: new Request("http://localhost/response-metadata"), + url: new URL("http://localhost/response-metadata"), + }) as RenderResult; + + assertEquals(result.headers, { + "x-owner": "page", + "x-root": "yes", + "x-nested": "yes", + "x-page": "yes", + }); + assertEquals(result.cookies?.map((cookie) => cookie.name), ["root", "nested", "page"]); + assertEquals(cacheWrites, 0); + }); + + it("merges response metadata into script page results", async () => { + const pagePath = "/project/pages/response-metadata.ts"; + const pipeline = createPipeline(pagePath, { + pageRenderer: { + preparePageBundles: async () => ({ + collectedMetadata: {}, + scriptResult: { + html: "script", + frontmatter: {}, + stream: null, + }, + }), + } as any, + }); + + (pipeline as any).loadModule = async () => ({ + getServerData: () => ({ + props: {}, + headers: { "x-script-state": "resolved" }, + cookies: [{ name: "script-seen", value: "1", path: "/" }], + }), + }); + + const result = await pipeline.renderPage("/response-metadata", { + request: new Request("http://localhost/response-metadata"), + url: new URL("http://localhost/response-metadata"), + }); + + assertEquals(result.headers, { "x-script-state": "resolved" }); + assertEquals(result.cookies, [{ name: "script-seen", value: "1", path: "/" }]); + }); + + it("preserves successful layout metadata when page data fails", async () => { + const pagePath = "/project/pages/response-metadata-data-error.tsx"; + const layoutPath = "/project/layouts/root.tsx"; + const pageError = new Error("Page data failed"); + const pipeline = createPipeline(pagePath, { + layoutOrchestrator: { + collectLayouts: async () => ({ + layoutBundle: undefined, + nestedLayouts: [{ kind: "tsx", componentPath: layoutPath }], + }), + preloadLayoutModules: async () => ({ + tsxTotal: 1, + tsxSuccess: 1, + tsxFailures: [], + mdxTotal: 0, + mdxSuccess: 0, + mdxFailures: [], + importMapSuccess: true, + durationMs: 0, + allSuccess: true, + }), + applyLayoutsAndWrappers: async (element: unknown) => element, + } as any, + }); + (pipeline as any).loadModule = async (path: string) => ({ + getServerData: () => { + if (path === layoutPath) { + return { + props: {}, + headers: { "x-layout-state": "resolved" }, + cookies: [{ name: "layout-seen", value: "1", path: "/" }], + }; + } + throw pageError; + }, + }); + + let thrown: unknown; + try { + await pipeline.renderPage("/response-metadata-data-error", { + request: new Request("http://localhost/response-metadata-data-error"), + url: new URL("http://localhost/response-metadata-data-error"), + }); + } catch (error) { + thrown = error; + } + + assert(thrown instanceof Error); + assertEquals(unwrapDataResponseMetadataError(thrown), pageError); + assertEquals(getAttachedDataResponseMetadata(thrown), { + headers: { "x-layout-state": "resolved" }, + cookies: [{ name: "layout-seen", value: "1", path: "/" }], + }); + }); + + it("preserves an earlier page control when later layout data fails", async () => { + const pagePath = "/project/pages/page-control-before-layout-error.tsx"; + const layoutPath = "/project/layouts/root.tsx"; + const pipeline = createPipeline(pagePath, { + layoutOrchestrator: { + collectLayouts: async () => ({ + layoutBundle: undefined, + nestedLayouts: [{ kind: "tsx", componentPath: layoutPath }], + }), + preloadLayoutModules: async () => ({ + tsxTotal: 1, + tsxSuccess: 1, + tsxFailures: [], + mdxTotal: 0, + mdxSuccess: 0, + mdxFailures: [], + importMapSuccess: true, + durationMs: 0, + allSuccess: true, + }), + applyLayoutsAndWrappers: async (element: unknown) => element, + } as any, + }); + (pipeline as any).loadModule = async (path: string) => ({ + getServerData: () => { + if (path === pagePath) { + return { + notFound: true, + headers: { "x-missing-reason": "page-control" }, + }; + } + throw new Error("Layout data failed after page control"); + }, + }); + + const error = await assertRejects( + () => + pipeline.resolvePageData("/page-control-before-layout-error", { + request: new Request("http://localhost/page-control-before-layout-error"), + url: new URL("http://localhost/page-control-before-layout-error"), + }), + Error, + "Page/Layout returned notFound", + ); + + assertEquals(resolveSSRControlOutcome(error), { + kind: "not-found", + headers: { "x-missing-reason": "page-control" }, + }); + }); + + it("attaches resolved response metadata when SSR later fails", async () => { + const pagePath = "/project/pages/response-metadata-error.tsx"; + const sharedRenderError = new Error("SSR failed after data resolution"); + let dataCalls = 0; + const pipeline = createPipeline(pagePath, { + ssrOrchestrator: { + performSSRRendering: async () => { + throw sharedRenderError; + }, + resolveErrorComponentPath: async () => null, + } as any, + }); + (pipeline as any).loadModule = async () => ({ + getServerData: () => { + dataCalls++; + return dataCalls === 1 + ? { + props: {}, + headers: { "x-page-state": "resolved" }, + cookies: [{ name: "session", value: "request-specific", path: "/" }], + } + : { props: {} }; + }, + }); + + const thrown: unknown[] = []; + for (let requestIndex = 0; requestIndex < 2; requestIndex++) { + try { + await pipeline.renderPage("/response-metadata-error", { + request: new Request("http://localhost/response-metadata-error"), + url: new URL("http://localhost/response-metadata-error"), + }); + } catch (error) { + thrown.push(error); + } + } + + assert(thrown[0] instanceof Error); + assertEquals(getAttachedDataResponseMetadata(thrown[0]), { + headers: { "x-page-state": "resolved" }, + cookies: [{ name: "session", value: "request-specific", path: "/" }], + }); + assert(thrown[1] instanceof Error); + assertEquals( + getAttachedDataResponseMetadata(thrown[1]), + {}, + "a reused project Error cannot retain another request's response metadata", + ); + }); + + it("carries resolved metadata through a non-Error SSR control", async () => { + const pagePath = "/project/pages/response-metadata-control.tsx"; + const control = notFound({ headers: { "x-control": "missing" } }); + const pipeline = createPipeline(pagePath, { + ssrOrchestrator: { + performSSRRendering: async () => { + throw control; + }, + resolveErrorComponentPath: async () => null, + } as any, + }); + (pipeline as any).loadModule = async () => ({ + getServerData: () => ({ + props: {}, + headers: { "x-page-state": "resolved" }, + cookies: [{ name: "page-seen", value: "1", path: "/" }], + }), + }); + + let thrown: unknown; + try { + await pipeline.renderPage("/response-metadata-control", { + request: new Request("http://localhost/response-metadata-control"), + url: new URL("http://localhost/response-metadata-control"), + }); + } catch (error) { + thrown = error; + } + + assert(thrown instanceof Error); + assertEquals(unwrapDataResponseMetadataError(thrown), control); + assertEquals(getAttachedDataResponseMetadata(thrown), { + headers: { "x-page-state": "resolved" }, + cookies: [{ name: "page-seen", value: "1", path: "/" }], + }); + }); + it("staticDataOnly skips request-only data hooks during static rendering", async () => { const pagePath = "/project/pages/static-only.tsx"; let serverCalls = 0; @@ -769,10 +1074,13 @@ describe("RenderPipeline behavior", () => { (pipeline as any).loadModule = async () => ({ getServerData: () => ({}) }); (pipeline as any).dataFetcher = { - fetchData: async () => ({ notFound: true }), + fetchData: async () => ({ + notFound: true, + headers: { "x-missing-reason": "gone" }, + }), }; - await assertRejects( + const error = await assertRejects( () => pipeline.resolvePageData(slug, { projectId, @@ -782,6 +1090,12 @@ describe("RenderPipeline behavior", () => { Error, "Page/Layout returned notFound", ); + assertEquals((error as { context?: { headers?: unknown } }).context?.headers, undefined); + assertEquals(JSON.stringify(error).includes("gone"), false); + assertEquals(resolveSSRControlOutcome(error), { + kind: "not-found", + headers: { "x-missing-reason": "gone" }, + }); }); it("runs data hooks and extracts params for configured page roots", async () => { @@ -855,10 +1169,14 @@ describe("RenderPipeline behavior", () => { (pipeline as any).loadModule = async () => ({ getServerData: () => ({}) }); (pipeline as any).dataFetcher = { - fetchData: async () => ({ redirect: { destination: "/login", permanent: false } }), + fetchData: async () => ({ + redirect: { destination: "/login", permanent: false }, + headers: { "x-auth-result": "required" }, + cookies: [{ name: "return-to", value: "/private", path: "/" }], + }), }; - await assertRejects( + const error = await assertRejects( () => pipeline.resolvePageData(slug, { projectId, @@ -868,6 +1186,16 @@ describe("RenderPipeline behavior", () => { Error, "Redirect to /login", ); + assertEquals((error as { context?: { headers?: unknown } }).context?.headers, undefined); + assertEquals((error as { context?: { cookies?: unknown } }).context?.cookies, undefined); + assertEquals(JSON.stringify(error).includes("/private"), false); + assertEquals(resolveSSRControlOutcome(error), { + kind: "redirect", + location: "/login", + permanent: false, + headers: { "x-auth-result": "required" }, + cookies: [{ name: "return-to", value: "/private", path: "/" }], + }); }); it("resolvePageData fails when a page module cannot be loaded", async () => { diff --git a/src/rendering/orchestrator/pipeline.ts b/src/rendering/orchestrator/pipeline.ts index 547ada8765..5805748cee 100644 --- a/src/rendering/orchestrator/pipeline.ts +++ b/src/rendering/orchestrator/pipeline.ts @@ -50,7 +50,18 @@ import type { LayoutOrchestrator } from "./layout.ts"; import type { SSROrchestrator } from "./ssr-orchestrator.ts"; import type { PageDataResponse, RenderOptions, RenderResult } from "./types.ts"; import { DataFetcher, type FetchDataOptions } from "#veryfront/data/index.ts"; -import type { DataContext, PageWithData } from "#veryfront/data/types.ts"; +import type { + DataContext, + DataResponseMetadata, + PageWithData, + ResponseCookie, +} from "#veryfront/data/types.ts"; +import { + attachDataResponseMetadata, + mergeDataResponseMetadata, + unwrapDataResponseMetadataError, + wrapDataResponseMetadataError, +} from "#veryfront/data/response-metadata.ts"; import { clearSSRModuleCacheForProject } from "#veryfront/modules/react-loader/index.ts"; import { setupSSRGlobals } from "../ssr-globals.ts"; import { LAYOUT_EXTENSIONS } from "../layouts/types.ts"; @@ -161,6 +172,8 @@ interface DataResolutionResult { params: Record; pageProps: Record; layoutProps: Map>; + headers?: Record; + cookies?: ResponseCookie[]; } interface MdxMetadataResult { @@ -584,9 +597,14 @@ export class RenderPipeline { ), ); - this.applyFetchedDataResults(slug, dataResults, pageProps, layoutProps); + const responseMetadata = this.applyFetchedDataResults( + slug, + dataResults, + pageProps, + layoutProps, + ); - return { params, pageProps, layoutProps }; + return { params, pageProps, layoutProps, ...responseMetadata }; } /** @@ -635,23 +653,45 @@ export class RenderPipeline { dataResults: FetchedDataResult[], pageProps: Record, layoutProps: Map>, - ): void { + ): DataResponseMetadata { + // Layouts are collected outermost to innermost. Apply them in that order, + // then the page, so the closest owner wins a duplicate custom header. + // Cookies remain distinct and preserve the same outer-to-inner-to-page order. + const responseMetadata = mergeDataResponseMetadata( + [ + ...dataResults.filter(({ type }) => type === "layout"), + ...dataResults.filter(({ type }) => type === "page"), + ] + .flatMap(({ result }) => result ? [result] : []), + ); + for (const { type, id, result, error } of dataResults) { - if (error) throw error; + if (error) { + if (responseMetadata.headers || responseMetadata.cookies) { + throw wrapDataResponseMetadataError(error, responseMetadata); + } + throw error; + } if (!result) continue; if (result.notFound) { - throw FILE_NOT_FOUND.create({ - detail: "Page/Layout returned notFound", - context: { slug, component: id }, - }); + throw attachDataResponseMetadata( + FILE_NOT_FOUND.create({ + detail: "Page/Layout returned notFound", + context: { slug, component: id }, + }), + responseMetadata, + ); } if (result.redirect) { - throw RENDER_ERROR.create({ - detail: `Redirect to ${result.redirect.destination}`, - context: { slug, redirect: result.redirect }, - }); + throw attachDataResponseMetadata( + RENDER_ERROR.create({ + detail: `Redirect to ${result.redirect.destination}`, + context: { slug, redirect: result.redirect }, + }), + responseMetadata, + ); } if (!result.props) continue; @@ -662,6 +702,8 @@ export class RenderPipeline { layoutProps.set(id, result.props as Record); } } + + return responseMetadata; } async renderPage(slug: string, options?: RenderOptions): Promise { @@ -731,6 +773,8 @@ export class RenderPipeline { pageInfo.entity.path, this.config.projectDir, ); + let responseHeaders: Record | undefined; + let responseCookies: ResponseCookie[] | undefined; try { const skipLayouts = isDotPath({ @@ -780,6 +824,8 @@ export class RenderPipeline { ? internalPreResolvedData.pageProps : undefined; layoutDataMap = internalPreResolvedData.layoutProps; + responseHeaders = internalPreResolvedData.headers; + responseCookies = internalPreResolvedData.cookies; } else if (options?.url && (options.request || options.staticDataOnly)) { await profilePhase( "render.data_fetching", @@ -799,6 +845,8 @@ export class RenderPipeline { ? dataResolution.pageProps : undefined; layoutDataMap = dataResolution.layoutProps; + responseHeaders = dataResolution.headers; + responseCookies = dataResolution.cookies; } catch (error) { if (error instanceof VeryfrontError) throw error; @@ -844,7 +892,23 @@ export class RenderPipeline { ); timing.bundlePrep = Math.round(performance.now() - bundlePrepStart); - if (pageBundleResult.scriptResult) return pageBundleResult.scriptResult; + if (pageBundleResult.scriptResult) { + const scriptResponseMetadata = mergeDataResponseMetadata([ + { + ...(pageBundleResult.scriptResult.headers + ? { headers: pageBundleResult.scriptResult.headers } + : {}), + ...(pageBundleResult.scriptResult.cookies + ? { cookies: pageBundleResult.scriptResult.cookies } + : {}), + }, + { + ...(responseHeaders ? { headers: responseHeaders } : {}), + ...(responseCookies ? { cookies: responseCookies } : {}), + }, + ]); + return { ...pageBundleResult.scriptResult, ...scriptResponseMetadata }; + } if (!pageBundleResult.pageElement || !pageBundleResult.pageBundle) { throw RENDER_ERROR.create({ @@ -971,6 +1035,8 @@ export class RenderPipeline { cacheCoordinator: this.config.cacheCoordinator, logger: renderPipelineLog, nonce: renderOptions.nonce, + headers: responseHeaders, + cookies: responseCookies, }); timing.total = Math.round(performance.now() - pipelineStartTime); @@ -979,7 +1045,20 @@ export class RenderPipeline { return result; } catch (error) { if (error instanceof Error) { - (error as Error & { sourceFile?: string }).sourceFile = sourceFile; + const classifiedError = unwrapDataResponseMetadataError(error); + const sourceError = classifiedError instanceof Error ? classifiedError : error; + (sourceError as Error & { sourceFile?: string }).sourceFile = sourceFile; + } + if (responseHeaders || responseCookies) { + throw wrapDataResponseMetadataError( + error, + mergeDataResponseMetadata([ + { + ...(responseHeaders ? { headers: responseHeaders } : {}), + ...(responseCookies ? { cookies: responseCookies } : {}), + }, + ]), + ); } throw error; } diff --git a/src/rendering/orchestrator/render-result-assembly.ts b/src/rendering/orchestrator/render-result-assembly.ts index cf51e03ba7..0326fcc85f 100644 --- a/src/rendering/orchestrator/render-result-assembly.ts +++ b/src/rendering/orchestrator/render-result-assembly.ts @@ -1,4 +1,5 @@ import type { PageBundle, RenderResult } from "#veryfront/types"; +import type { ResponseCookie } from "#veryfront/data/types.ts"; interface RenderResultAssemblyCache { persistResult( @@ -31,6 +32,8 @@ export interface AssembleRenderResultOptions { cacheCoordinator?: RenderResultAssemblyCache; logger?: RenderResultAssemblyLogger; nonce?: string; + headers?: Record; + cookies?: ResponseCookie[]; } export function assembleRenderResult(options: AssembleRenderResultOptions): RenderResult { @@ -49,10 +52,12 @@ export function assembleRenderResult(options: AssembleRenderResultOptions): Rend nodeMap: options.pageBundle.nodeMap, stream: options.ssrResult.finalStream, ssrHash: options.ssrResult.ssrHash, + ...(options.headers ? { headers: options.headers } : {}), + ...(options.cookies ? { cookies: options.cookies } : {}), ...(pageModule ? { pageModule } : {}), }; - if (options.shouldCache && !options.skipCachePersist) { + if (options.shouldCache && !options.skipCachePersist && !options.cookies?.length) { void options.cacheCoordinator?.persistResult( result, options.slug, diff --git a/src/rendering/renderer.test.ts b/src/rendering/renderer.test.ts index 8858326fbf..9acbc3404a 100644 --- a/src/rendering/renderer.test.ts +++ b/src/rendering/renderer.test.ts @@ -30,6 +30,13 @@ import { clearReactVersionCache, type DependencyPinningSource, } from "#veryfront/transforms/esm/package-registry.ts"; +import { + attachDataResponseMetadata, + getAttachedDataResponseMetadata, + unwrapDataResponseMetadataError, + wrapDataResponseMetadataError, +} from "#veryfront/data/response-metadata.ts"; +import { redirect } from "#veryfront/data/helpers.ts"; function getEnv(name: string): string | undefined { // deno-lint-ignore no-explicit-any @@ -273,6 +280,362 @@ describe("Renderer helpers", () => { }); }); +describe("Renderer response metadata", () => { + it("preserves headers and cookies without caching a cookie response", async () => { + const store = createInMemoryStore(); + const renderer = new Renderer({ cache: { store } }); + (renderer as unknown as { initialized: boolean }).initialized = true; + (renderer as unknown as { + createServicesForContext: () => { + pipeline: { + renderPage: () => Promise; + }; + }; + }).createServicesForContext = () => ({ + pipeline: { + renderPage: () => + Promise.resolve({ + html: "metadata", + frontmatter: {}, + stream: null, + headers: { "x-page-state": "fresh" }, + cookies: [{ name: "session", value: "abc", path: "/" }], + }), + }, + }); + + const result = await renderer.renderPage("/metadata", makeRenderContext(), { + environment: "production", + releaseId: "rel-1", + releaseAssetManifest: null, + }); + + assertEquals(result.headers, { "x-page-state": "fresh" }); + assertEquals(result.cookies, [{ name: "session", value: "abc", path: "/" }]); + assertEquals(store.data.size, 0); + }); + + it("preserves response headers through render cache hits", async () => { + const store = createInMemoryStore(); + const renderer = new Renderer({ cache: { store } }); + (renderer as unknown as { initialized: boolean }).initialized = true; + let renderCalls = 0; + (renderer as unknown as { + createServicesForContext: () => { + pipeline: { + renderPage: () => Promise; + }; + }; + }).createServicesForContext = () => ({ + pipeline: { + renderPage: () => { + renderCalls++; + return Promise.resolve({ + html: "cached metadata", + frontmatter: {}, + stream: null, + headers: { "x-page-state": "cacheable" }, + }); + }, + }, + }); + const options = { + environment: "production" as const, + releaseId: "rel-1", + releaseAssetManifest: null, + }; + + const first = await renderer.renderPage("/cached-metadata", makeRenderContext(), options); + const second = await renderer.renderPage("/cached-metadata", makeRenderContext(), options); + + assertEquals(first.headers, { "x-page-state": "cacheable" }); + assertEquals(second.headers, { "x-page-state": "cacheable" }); + assertEquals(renderCalls, 1); + }); + + it("rerenders singleflight followers when the leader returns cookies", async () => { + const store = createInMemoryStore(); + const renderer = new Renderer({ cache: { store } }); + (renderer as unknown as { initialized: boolean }).initialized = true; + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + let renderCalls = 0; + (renderer as unknown as { + createServicesForContext: () => { + pipeline: { + renderPage: (_slug: string, options?: RenderOptions) => Promise; + }; + }; + }).createServicesForContext = () => ({ + pipeline: { + renderPage: async (_slug, options) => { + renderCalls++; + if (renderCalls === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } + const user = options?.request?.headers.get("x-test-user") ?? "missing"; + return { + html: `${user}`, + frontmatter: {}, + stream: null, + cookies: [{ name: "session", value: user, path: "/" }], + }; + }, + }, + }); + const baseOptions = { + environment: "production" as const, + releaseId: "rel-1", + releaseAssetManifest: null, + }; + + const leader = renderer.renderPage("/concurrent-cookie", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-cookie", { + headers: { "x-test-user": "leader" }, + }), + }); + await firstStarted.promise; + const follower = renderer.renderPage("/concurrent-cookie", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-cookie", { + headers: { "x-test-user": "follower" }, + }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirst.resolve(); + + const [leaderResult, followerResult] = await Promise.all([leader, follower]); + assertEquals(leaderResult.cookies?.[0]?.value, "leader"); + assertEquals(followerResult.cookies?.[0]?.value, "follower"); + assertEquals(renderCalls, 2); + assertEquals(store.data.size, 0); + }); + + it("rerenders singleflight followers when the leader throws with cookies", async () => { + const store = createInMemoryStore(); + const renderer = new Renderer({ cache: { store } }); + (renderer as unknown as { initialized: boolean }).initialized = true; + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + let renderCalls = 0; + (renderer as unknown as { + createServicesForContext: () => { + pipeline: { + renderPage: (_slug: string, options?: RenderOptions) => Promise; + }; + }; + }).createServicesForContext = () => ({ + pipeline: { + renderPage: async (_slug, options) => { + renderCalls++; + if (renderCalls === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } + const user = options?.request?.headers.get("x-test-user") ?? "missing"; + throw attachDataResponseMetadata(new Error(user), { + cookies: [{ name: "session", value: user, path: "/" }], + }); + }, + }, + }); + const baseOptions = { + environment: "production" as const, + releaseId: "rel-1", + releaseAssetManifest: null, + }; + const captureFailure = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + if (error instanceof Error) return error; + } + throw new Error("Expected render to fail"); + }; + + const leader = captureFailure( + renderer.renderPage("/concurrent-cookie-error", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-cookie-error", { + headers: { "x-test-user": "leader" }, + }), + }), + ); + await firstStarted.promise; + const follower = captureFailure( + renderer.renderPage("/concurrent-cookie-error", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-cookie-error", { + headers: { "x-test-user": "follower" }, + }), + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirst.resolve(); + + const [leaderError, followerError] = await Promise.all([leader, follower]); + assertEquals(getAttachedDataResponseMetadata(leaderError).cookies?.[0]?.value, "leader"); + assertEquals(getAttachedDataResponseMetadata(followerError).cookies?.[0]?.value, "follower"); + assertEquals(renderCalls, 2); + assertEquals(store.data.size, 0); + }); + + it("rerenders singleflight followers when the leader throws a cookie-bearing control", async () => { + const store = createInMemoryStore(); + const renderer = new Renderer({ cache: { store } }); + (renderer as unknown as { initialized: boolean }).initialized = true; + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + let renderCalls = 0; + (renderer as unknown as { + createServicesForContext: () => { + pipeline: { + renderPage: (_slug: string, options?: RenderOptions) => Promise; + }; + }; + }).createServicesForContext = () => ({ + pipeline: { + renderPage: async (_slug, options) => { + renderCalls++; + if (renderCalls === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } + const user = options?.request?.headers.get("x-test-user") ?? "missing"; + throw redirect("/login", false, { + cookies: [{ name: "session", value: user, path: "/" }], + }); + }, + }, + }); + const baseOptions = { + environment: "production" as const, + releaseId: "rel-1", + releaseAssetManifest: null, + }; + const captureFailure = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + return error; + } + throw new Error("Expected render to fail"); + }; + + const leader = captureFailure( + renderer.renderPage("/concurrent-control", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-control", { + headers: { "x-test-user": "leader" }, + }), + }), + ); + await firstStarted.promise; + const follower = captureFailure( + renderer.renderPage("/concurrent-control", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-control", { + headers: { "x-test-user": "follower" }, + }), + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirst.resolve(); + + const [leaderControl, followerControl] = await Promise.all([leader, follower]); + assertEquals( + (leaderControl as { cookies?: Array<{ value?: string }> }).cookies?.[0]?.value, + "leader", + ); + assertEquals( + (followerControl as { cookies?: Array<{ value?: string }> }).cookies?.[0]?.value, + "follower", + ); + assertEquals(renderCalls, 2); + assertEquals(store.data.size, 0); + }); + + it("rerenders singleflight followers when a metadata wrapper carries a cookie control", async () => { + const store = createInMemoryStore(); + const renderer = new Renderer({ cache: { store } }); + (renderer as unknown as { initialized: boolean }).initialized = true; + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + let renderCalls = 0; + (renderer as unknown as { + createServicesForContext: () => { + pipeline: { + renderPage: (_slug: string, options?: RenderOptions) => Promise; + }; + }; + }).createServicesForContext = () => ({ + pipeline: { + renderPage: async (_slug, options) => { + renderCalls++; + if (renderCalls === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } + const user = options?.request?.headers.get("x-test-user") ?? "missing"; + throw wrapDataResponseMetadataError( + redirect("/login", false, { + cookies: [{ name: "session", value: user, path: "/" }], + }), + { headers: { "x-loader": "ready" } }, + ); + }, + }, + }); + const baseOptions = { + environment: "production" as const, + releaseId: "rel-1", + releaseAssetManifest: null, + }; + const captureFailure = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + if (error instanceof Error) return error; + } + throw new Error("Expected render to fail"); + }; + + const leader = captureFailure( + renderer.renderPage("/concurrent-wrapped-control", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-wrapped-control", { + headers: { "x-test-user": "leader" }, + }), + }), + ); + await firstStarted.promise; + const follower = captureFailure( + renderer.renderPage("/concurrent-wrapped-control", makeRenderContext(), { + ...baseOptions, + request: new Request("https://example.test/concurrent-wrapped-control", { + headers: { "x-test-user": "follower" }, + }), + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseFirst.resolve(); + + const [leaderError, followerError] = await Promise.all([leader, follower]); + const leaderControl = unwrapDataResponseMetadataError(leaderError) as { + cookies?: Array<{ value?: string }>; + }; + const followerControl = unwrapDataResponseMetadataError(followerError) as { + cookies?: Array<{ value?: string }>; + }; + assertEquals(leaderControl.cookies?.[0]?.value, "leader"); + assertEquals(followerControl.cookies?.[0]?.value, "follower"); + assertEquals(renderCalls, 2); + assertEquals(store.data.size, 0); + }); +}); + describe("Renderer release asset cache isolation", () => { const originalManifestFlag = getHostEnv(RELEASE_ASSET_MANIFEST_ENV_FLAG); diff --git a/src/rendering/renderer.ts b/src/rendering/renderer.ts index 8d377f3614..ec1cd9e020 100644 --- a/src/rendering/renderer.ts +++ b/src/rendering/renderer.ts @@ -97,6 +97,11 @@ import { resolveDependencyWritebackTarget, } from "#veryfront/transforms/esm/package-registry.ts"; import { bindHtmlNonceFromCache, sealHtmlNonceForCache } from "#veryfront/html/nonce-injection.ts"; +import { + getAttachedDataResponseMetadata, + unwrapDataResponseMetadataError, +} from "#veryfront/data/response-metadata.ts"; +import { resolveSSRControlOutcome } from "./ssr-outcome.ts"; const logger = rendererLogger.component("renderer"); @@ -153,6 +158,8 @@ interface CachedRenderData { headings?: RenderResult["headings"]; ssrHash?: string; pageModule?: RenderResult["pageModule"]; + headers?: RenderResult["headers"]; + cookies?: RenderResult["cookies"]; } function createCacheRenderNonce(): string { @@ -823,6 +830,33 @@ export class Renderer { ) : await runRender(); } catch (error) { + const attachedCookies = error instanceof Error + ? getAttachedDataResponseMetadata(error).cookies + : undefined; + const unwrappedError = error instanceof Error + ? unwrapDataResponseMetadataError(error) + : error; + const controlCookies = resolveSSRControlOutcome(unwrappedError)?.cookies ?? + resolveSSRControlOutcome(error)?.cookies; + if ( + isFollower && + ((attachedCookies?.length ?? 0) > 0 || (controlCookies?.length ?? 0) > 0) + ) { + logger.debug("Rerendering follower after cookie-bearing render failure", { + slug, + projectId: ctx.projectId, + }); + return await this.doRenderPage( + slug, + ctx, + options, + startTime, + null, + callerSignal, + admission, + false, + ); + } if ( retryBackgroundOverload && admission === "foreground" && @@ -847,6 +881,23 @@ export class Renderer { throw error; } + if (isFollower && cachedData.cookies?.length) { + logger.debug("Rerendering follower after cookie-bearing render", { + slug, + projectId: ctx.projectId, + }); + return await this.doRenderPage( + slug, + ctx, + options, + startTime, + null, + callerSignal, + admission, + false, + ); + } + if (isFollower) { logger.debug("Render deduplicated (follower)", { slug, @@ -866,6 +917,8 @@ export class Renderer { headings: cachedData.headings, ssrHash: cachedData.ssrHash, pageModule: cachedData.pageModule, + ...(cachedData.headers ? { headers: cachedData.headers } : {}), + ...(cachedData.cookies ? { cookies: cachedData.cookies } : {}), stream: null, }; } @@ -966,7 +1019,7 @@ export class Renderer { }, ); - if (cacheKey !== null) { + if (cacheKey !== null && !result.cookies?.length) { await this.cache.persistResult( result, slug, @@ -996,6 +1049,8 @@ export class Renderer { headings: result.headings, ssrHash: result.ssrHash, pageModule: result.pageModule, + ...(result.headers ? { headers: result.headers } : {}), + ...(result.cookies ? { cookies: result.cookies } : {}), }; } finally { if (globalAcquired) renderSemaphore.release(); diff --git a/src/rendering/shared/context-aware-cache.ts b/src/rendering/shared/context-aware-cache.ts index 9822f4e7fd..c1d60d38e1 100644 --- a/src/rendering/shared/context-aware-cache.ts +++ b/src/rendering/shared/context-aware-cache.ts @@ -358,6 +358,9 @@ export class ContextAwareCacheCoordinator { if (result.pageModule) { cloned.pageModule = { ...result.pageModule }; } + if (result.headers) { + cloned.headers = { ...result.headers }; + } return cloned; } diff --git a/src/rendering/ssr-outcome.test.ts b/src/rendering/ssr-outcome.test.ts index d4644e5195..027d0902a9 100644 --- a/src/rendering/ssr-outcome.test.ts +++ b/src/rendering/ssr-outcome.test.ts @@ -17,6 +17,7 @@ import { resolveSSRControlOutcome, resolveSSRFailure, } from "./ssr-outcome.ts"; +import { attachDataResponseMetadata } from "#veryfront/data/response-metadata.ts"; function assertSSRFailureOutcome( actual: SSRFailureOutcome, @@ -118,17 +119,41 @@ describe("ssr-outcome.ts", () => { describe("resolveSSRControlOutcome", () => { it("recognises the routing brands the render pipeline raises", () => { assertEquals( - resolveSSRControlOutcome(FILE_NOT_FOUND.create({ detail: "Page not found: x" })), - { kind: "not-found" }, + resolveSSRControlOutcome( + attachDataResponseMetadata( + FILE_NOT_FOUND.create({ detail: "Page not found: x" }), + { + headers: { "x-missing-reason": "gone" }, + cookies: [{ name: "visited-missing", value: "1", path: "/" }], + }, + ), + ), + { + kind: "not-found", + headers: { "x-missing-reason": "gone" }, + cookies: [{ name: "visited-missing", value: "1", path: "/" }], + }, ); assertEquals( resolveSSRControlOutcome( - RENDER_ERROR.create({ - detail: "Redirect to /login", - context: { redirect: { destination: "/login", permanent: true } }, - }), + attachDataResponseMetadata( + RENDER_ERROR.create({ + detail: "Redirect to /login", + context: { redirect: { destination: "/login", permanent: true } }, + }), + { + headers: { "x-auth-result": "signed-in" }, + cookies: [{ name: "session", value: "abc", path: "/" }], + }, + ), ), - { kind: "redirect", location: "/login", permanent: true }, + { + kind: "redirect", + location: "/login", + permanent: true, + headers: { "x-auth-result": "signed-in" }, + cookies: [{ name: "session", value: "abc", path: "/" }], + }, ); assertEquals(resolveSSRControlOutcome(notFound()), { kind: "not-found" }); assertEquals( diff --git a/src/rendering/ssr-outcome.ts b/src/rendering/ssr-outcome.ts index ab01113320..ec92e08e9f 100644 --- a/src/rendering/ssr-outcome.ts +++ b/src/rendering/ssr-outcome.ts @@ -1,14 +1,18 @@ import { isDataControlResult } from "#veryfront/data/helpers.ts"; -import type { DataResult } from "#veryfront/data/types.ts"; +import type { DataResponseMetadata, DataResult } from "#veryfront/data/types.ts"; +import { + getAttachedDataResponseMetadata, + normalizeDataResponseMetadata, +} from "#veryfront/data/response-metadata.ts"; import { VeryfrontError } from "#veryfront/errors"; export type SSRControlOutcome = - | { kind: "not-found" } + | ({ kind: "not-found" } & DataResponseMetadata) | { kind: "redirect"; location: string; permanent: boolean; - }; + } & DataResponseMetadata; export type SSRFailureOutcome = | SSRControlOutcome @@ -42,6 +46,8 @@ interface RedirectResultContext { destination?: unknown; permanent?: unknown; }; + headers?: unknown; + cookies?: unknown; } interface ErrorBoundarySignal { @@ -93,7 +99,12 @@ export function resolveSSRControlOutcome(error: unknown): SSRControlOutcome | nu const control = findSSRControlOutcome(error); if (control) return control; - if (isFileNotFoundError(error)) return { kind: "not-found" }; + if (isFileNotFoundError(error)) { + return { + kind: "not-found", + ...getAttachedDataResponseMetadata(error), + }; + } if (error instanceof VeryfrontError && error.slug === "render-error") { const redirect = extractRedirectLocation(error); @@ -102,6 +113,7 @@ export function resolveSSRControlOutcome(error: unknown): SSRControlOutcome | nu kind: "redirect", location: redirect.destination, permanent: redirect.permanent, + ...redirect.responseMetadata, }; } } @@ -172,25 +184,39 @@ function toSSRControlOutcome(result: DataResult): SSRControlOutcome { kind: "redirect", location: result.redirect.destination, permanent: result.redirect.permanent === true, + ...normalizeDataResponseMetadata(result), }; } - return { kind: "not-found" }; + return { + kind: "not-found", + ...normalizeDataResponseMetadata(result), + }; } function extractRedirectLocation( error: VeryfrontError, -): { destination: string; permanent: boolean } | null { - const redirect = (error.context as RedirectResultContext | undefined)?.redirect; +): { + destination: string; + permanent: boolean; + responseMetadata: DataResponseMetadata; +} | null { + const context = error.context as RedirectResultContext | undefined; + const redirect = context?.redirect; if (!redirect || typeof redirect.destination !== "string") return null; - return { - destination: redirect.destination, - permanent: redirect.permanent === true, - }; + try { + return { + destination: redirect.destination, + permanent: redirect.permanent === true, + responseMetadata: getAttachedDataResponseMetadata(error), + }; + } catch { + return null; + } } -function isFileNotFoundError(error: unknown): boolean { +function isFileNotFoundError(error: unknown): error is VeryfrontError { return error instanceof VeryfrontError && error.slug === "file-not-found"; } diff --git a/src/security/sandbox/project-worker.test.ts b/src/security/sandbox/project-worker.test.ts index 7d1ec09300..22216bca7e 100644 --- a/src/security/sandbox/project-worker.test.ts +++ b/src/security/sandbox/project-worker.test.ts @@ -21,7 +21,7 @@ import { WORKER_INTERNAL_EGRESS_OVERRIDE_ENV } from "./worker-egress-guard.ts"; import type { WorkerEgressBroker } from "./worker-egress-guard.ts"; import { computeHash } from "#veryfront/utils"; import { SERVICE_OVERLOADED, VeryfrontError } from "#veryfront/errors"; -import { validateDataResult } from "#veryfront/data/helpers.ts"; +import { validateDataResult } from "#veryfront/data/data-result-validation.ts"; import { fromFileUrl, join, toFileUrl } from "#veryfront/compat/path"; const testSuite = isDeno ? describe : describe.skip; @@ -1555,14 +1555,40 @@ testSuite("ProjectWorker - real worker request isolation", () => { assertInvalidIsolatedDataResult(response); }); - it("rejects malformed fetch-data result outcome combinations", async () => { - const response = await executeIsolatedDataModule( - `export function getServerData() { - return { props: {}, redirect: { destination: "/other" } }; - }`, - "malformed-data-result", - ); - assertInvalidIsolatedDataResult(response); + it("preserves control precedence for fetch-data outcome combinations", async () => { + for ( + const [source, expected, slug] of [ + [ + `export function getServerData() { + return { + props: { ignored: true }, + redirect: { destination: "/other" }, + notFound: true, + revalidate: Number.POSITIVE_INFINITY, + }; + }`, + { redirect: { destination: "/other" } }, + "redirect-precedence-data-result", + ], + [ + `export function getServerData() { + return { + props: { ignored: true }, + notFound: true, + revalidate: "ignored", + }; + }`, + { notFound: true }, + "not-found-precedence-data-result", + ], + ] as const + ) { + const response = await executeIsolatedDataModule(source, slug); + + assertEquals(response.type, "data-result"); + if (response.type !== "data-result") throw new Error("expected data result response"); + assertEquals(response.result, expected); + } }); it("drops unknown fields from isolated data results before snapshotting", async () => { @@ -1652,6 +1678,63 @@ testSuite("ProjectWorker - real worker request isolation", () => { }); }); + it("rejects negative revalidation metadata", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { props: { ok: true }, revalidate: -100 }; + }`, + "negative-revalidation-data-result", + ); + + assertInvalidIsolatedDataResult(response); + }); + + it("preserves response metadata across the isolated data boundary", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { + props: { ok: true }, + headers: { "x-page-state": "fresh" }, + cookies: [{ + name: "session", + value: "abc", + path: "/", + httpOnly: true, + sameSite: "lax", + }], + }; + }`, + "response-metadata-data-result", + ); + + assertEquals(response.type, "data-result"); + if (response.type !== "data-result") throw new Error("expected data result response"); + assertEquals(response.result, { + props: { ok: true }, + headers: { "x-page-state": "fresh" }, + cookies: [{ + name: "session", + value: "abc", + path: "/", + httpOnly: true, + sameSite: "lax", + }], + }); + }); + + it("rejects unknown isolated response cookie fields", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { + props: {}, + cookies: [{ name: "session", value: "abc", ignored: true }], + }; + }`, + "unknown-response-cookie-field", + ); + assertInvalidIsolatedDataResult(response); + }); + it("rejects direct Deno file reads outside scoped worker read permissions", async () => { const projectDir = await Deno.makeTempDir(); const outsideDir = await Deno.makeTempDir(); diff --git a/src/security/sandbox/worker-script.ts b/src/security/sandbox/worker-script.ts index a1fa08d76d..90c602ec8d 100644 --- a/src/security/sandbox/worker-script.ts +++ b/src/security/sandbox/worker-script.ts @@ -1419,6 +1419,84 @@ function invalidIsolatedDataResult(): never { throw new NativeTypeError("Invalid isolated data result"); } +const MAX_WORKER_RESPONSE_METADATA_ENTRIES = 64; + +function snapshotDataResponseHeaders(value: unknown): Record { + return snapshotStringRecord( + value, + "data result headers", + false, + MAX_WORKER_HEADER_UTF8_BYTES, + MAX_WORKER_RESPONSE_METADATA_ENTRIES * 2, + ); +} + +function snapshotDataResponseCookies( + value: unknown, +): NonNullable { + const input = requireDenseArray( + value, + "data result cookies", + MAX_WORKER_RESPONSE_METADATA_ENTRIES, + ); + const output = new NativeArray[number]>( + input.length, + ); + const optional = [ + "domain", + "path", + "expires", + "maxAge", + "httpOnly", + "secure", + "sameSite", + ] as const; + + for (let index = 0; index < input.length; index++) { + const record = requireRecordShape( + arrayElement(input, index, "data result cookies"), + ["name", "value"], + optional, + "data result cookie", + ); + const cookie: NonNullable[number] = { + name: requireString(readDataProperty(record, "name"), "data result cookie name"), + value: requireString(readDataProperty(record, "value"), "data result cookie value"), + }; + + const domain = snapshotOptionalString(record, "domain", MAX_WORKER_VALUE_CHARS); + const path = snapshotOptionalString(record, "path", MAX_WORKER_VALUE_CHARS); + const expires = snapshotOptionalString(record, "expires", MAX_WORKER_VALUE_CHARS); + const sameSite = snapshotOptionalString(record, "sameSite", 6); + if (domain !== undefined) cookie.domain = domain; + if (path !== undefined) cookie.path = path; + if (expires !== undefined) cookie.expires = expires; + if (sameSite !== undefined) { + if (sameSite !== "lax" && sameSite !== "strict" && sameSite !== "none") { + return invalidIsolatedDataResult(); + } + cookie.sameSite = sameSite; + } + const maxAge = readOptionalDataProperty(record, "maxAge"); + if (maxAge.present && maxAge.value !== undefined) { + if (typeof maxAge.value !== "number" || !numberIsSafeInteger(maxAge.value)) { + return invalidIsolatedDataResult(); + } + cookie.maxAge = maxAge.value; + } + for (const key of ["httpOnly", "secure"] as const) { + const field = readOptionalDataProperty(record, key); + if (field.present && field.value !== undefined) { + if (typeof field.value !== "boolean") return invalidIsolatedDataResult(); + cookie[key] = field.value; + } + } + defineDataProperty(output, NativeString(index), cookie); + } + + return output; +} + function snapshotDataResultForBoundary(value: unknown): SerializedDataResult { try { const { record: result } = requirePlainDataRecord( @@ -1429,10 +1507,14 @@ function snapshotDataResultForBoundary(value: unknown): SerializedDataResult { const rawRedirect = readOptionalDataProperty(result, "redirect"); const rawNotFound = readOptionalDataProperty(result, "notFound"); const rawRevalidate = readOptionalDataProperty(result, "revalidate"); + const rawHeaders = readOptionalDataProperty(result, "headers"); + const rawCookies = readOptionalDataProperty(result, "cookies"); const hasProps = rawProps.present && rawProps.value !== undefined; const hasRedirect = rawRedirect.present && rawRedirect.value !== undefined; const hasNotFound = rawNotFound.present && rawNotFound.value !== undefined; const hasRevalidate = rawRevalidate.present && rawRevalidate.value !== undefined; + const hasHeaders = rawHeaders.present && rawHeaders.value !== undefined; + const hasCookies = rawCookies.present && rawCookies.value !== undefined; let normalizedRedirect: | { destination: string; permanent?: boolean } @@ -1468,36 +1550,46 @@ function snapshotDataResultForBoundary(value: unknown): SerializedDataResult { return invalidIsolatedDataResult(); } const normalizedNotFound = hasNotFound ? rawNotFound.value as boolean : undefined; - const activeOutcomes = (hasProps ? 1 : 0) + - (hasRedirect ? 1 : 0) + - (normalizedNotFound === true ? 1 : 0); - if (activeOutcomes > 1) { - return invalidIsolatedDataResult(); - } - - let normalizedRevalidate: number | false | undefined; - if (hasRevalidate) { - if ( - rawRevalidate.value !== false && - (typeof rawRevalidate.value !== "number" || - !numberIsFinite(rawRevalidate.value) || - rawRevalidate.value < 0) - ) { - return invalidIsolatedDataResult(); - } - normalizedRevalidate = rawRevalidate.value as number | false; - } const normalized: Record = {}; - if (hasProps) defineDataProperty(normalized, "props", rawProps.value); if (normalizedRedirect) { defineDataProperty(normalized, "redirect", normalizedRedirect); + } else if (normalizedNotFound === true) { + defineDataProperty(normalized, "notFound", true); + } else { + let normalizedRevalidate: number | false | undefined; + if (hasRevalidate) { + if ( + rawRevalidate.value !== false && + (typeof rawRevalidate.value !== "number" || + !numberIsFinite(rawRevalidate.value) || + rawRevalidate.value < 0) + ) { + return invalidIsolatedDataResult(); + } + normalizedRevalidate = rawRevalidate.value as number | false; + } + if (hasProps) defineDataProperty(normalized, "props", rawProps.value); + if (normalizedNotFound !== undefined) { + defineDataProperty(normalized, "notFound", normalizedNotFound); + } + if (normalizedRevalidate !== undefined) { + defineDataProperty(normalized, "revalidate", normalizedRevalidate); + } } - if (normalizedNotFound !== undefined) { - defineDataProperty(normalized, "notFound", normalizedNotFound); + if (hasHeaders) { + defineDataProperty( + normalized, + "headers", + snapshotDataResponseHeaders(rawHeaders.value), + ); } - if (normalizedRevalidate !== undefined) { - defineDataProperty(normalized, "revalidate", normalizedRevalidate); + if (hasCookies) { + defineDataProperty( + normalized, + "cookies", + snapshotDataResponseCookies(rawCookies.value), + ); } const budget: DataSnapshotBudget = { nodes: 0, utf8Bytes: 0 }; return snapshotStructuredDataRecord(normalized, budget) as SerializedDataResult; diff --git a/src/security/sandbox/worker-types.ts b/src/security/sandbox/worker-types.ts index 7d81aaebda..24b90bd682 100644 --- a/src/security/sandbox/worker-types.ts +++ b/src/security/sandbox/worker-types.ts @@ -85,6 +85,18 @@ export interface SerializedDataResult { redirect?: { destination: string; permanent?: boolean }; notFound?: boolean; revalidate?: number | false; + headers?: Record; + cookies?: Array<{ + name: string; + value: string; + domain?: string; + path?: string; + expires?: string; + maxAge?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "lax" | "strict" | "none"; + }>; } // --------------------------------------------------------------------------- diff --git a/src/server/handlers/request/ssr/ssr-response-builder.test.ts b/src/server/handlers/request/ssr/ssr-response-builder.test.ts index c19971b64a..953a156b9c 100644 --- a/src/server/handlers/request/ssr/ssr-response-builder.test.ts +++ b/src/server/handlers/request/ssr/ssr-response-builder.test.ts @@ -109,6 +109,28 @@ describe("server/handlers/request/ssr/ssr-response-builder", () => { assertEquals(body, "

Hello

"); }); + it("appends page headers and distinct Set-Cookie values after framework headers", async () => { + const req = new Request("http://localhost/"); + const ctx = makeCtx(); + const result = makeResult({ + cacheStrategy: "no-cache", + headers: { "x-page-state": "fresh" }, + cookies: [ + { name: "session", value: "abc", path: "/", httpOnly: true }, + { name: "theme", value: "dark", sameSite: "lax" }, + ], + }); + const response = await buildSSRResponse(req, ctx, result, new ResponseBuilder()); + const setCookies = response.headers.getSetCookie(); + + assertEquals(response.headers.get("x-page-state"), "fresh"); + assertEquals(setCookies, [ + "session=abc; Path=/; HttpOnly", + "theme=dark; SameSite=Lax", + ]); + assertEquals(response.headers.get("cache-control"), "no-cache, no-store, must-revalidate"); + }); + it("returns correct status code from result", async () => { const req = new Request("http://localhost/not-found"); const ctx = makeCtx(); diff --git a/src/server/handlers/request/ssr/ssr-response-builder.ts b/src/server/handlers/request/ssr/ssr-response-builder.ts index 84f11c2575..56bfa9dc55 100644 --- a/src/server/handlers/request/ssr/ssr-response-builder.ts +++ b/src/server/handlers/request/ssr/ssr-response-builder.ts @@ -15,6 +15,7 @@ import { ErrorPages } from "../../../utils/error-html.ts"; import type { ResponseBuilder } from "#veryfront/security/http/response/builder.ts"; import { addNonceToHtmlTags } from "#veryfront/html/nonce-injection.ts"; import { serverLogger } from "#veryfront/utils"; +import { appendDataResponseMetadata } from "#veryfront/data/response-metadata.ts"; const logger = serverLogger.component("ssr-response-builder"); @@ -45,16 +46,17 @@ export async function buildSSRResponse( // Streaming response path if (result.isStreaming && result.stream) { - const response = builder + const responseBuilder = builder .withCORS(req, ctx.securityConfig?.cors) .withSecurity(ctx.securityConfig ?? undefined, req) .withClientHints() - .withCache(result.cacheStrategy) - .withContentType( - getContentType(".html"), - result.stream, - result.status, - ); + .withCache(result.cacheStrategy); + appendDataResponseMetadata(responseBuilder.headers, result); + const response = responseBuilder.withContentType( + getContentType(".html"), + result.stream, + result.status, + ); if (!isHeadRequest) return response; @@ -64,11 +66,12 @@ export async function buildSSRResponse( // ETag match → 304 Not Modified (production only) if (!isDev && !builder.nonce && result.etag && hasMatchingEtag(req, result.etag)) { - return builder + const responseBuilder = builder .withCORS(req, ctx.securityConfig?.cors) .withSecurity(ctx.securityConfig ?? undefined, req) - .withCache(result.cacheStrategy) - .notModified(result.etag); + .withCache(result.cacheStrategy); + appendDataResponseMetadata(responseBuilder.headers, result); + return responseBuilder.notModified(result.etag); } // Buffered response path @@ -95,6 +98,7 @@ export async function buildSSRResponse( if (!result.isStreaming) response = response.withClientHints(); if (result.etag) response = response.withETag(result.etag); + appendDataResponseMetadata(response.headers, result); const finalResponse = response.withContentType( getContentType(".html"), diff --git a/src/server/handlers/request/ssr/ssr.handler.test.ts b/src/server/handlers/request/ssr/ssr.handler.test.ts index 1b47e07d31..50cefec8a4 100644 --- a/src/server/handlers/request/ssr/ssr.handler.test.ts +++ b/src/server/handlers/request/ssr/ssr.handler.test.ts @@ -1,8 +1,15 @@ import { RENDER_ERROR } from "#veryfront/errors"; import "#veryfront/schemas/_test-setup.ts"; +import * as React from "react"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { SSRHandler } from "./ssr.handler.ts"; +import { __setComponentSourceLoaderForTests } from "./error-page-fallback.ts"; +import { + __injectProjectReactForTests, + __injectReactDOMServerForTests, + resetReactCache, +} from "#veryfront/react/compat/ssr-adapter/server-loader.ts"; import type { HandlerContext } from "../../types.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { SSRRenderOptions } from "../../../services/rendering/ssr.service.ts"; @@ -241,7 +248,13 @@ describe("server/handlers/request/ssr/ssr.handler", () => { html: "not found", isStreaming: false, cacheStrategy: "no-cache" as const, - failure: { kind: "not-found" } as const, + failure: { + kind: "not-found", + headers: { "x-missing-reason": "gone" }, + cookies: [{ name: "visited-missing", value: "1", path: "/" }], + } as const, + headers: { "x-missing-reason": "gone" }, + cookies: [{ name: "visited-missing", value: "1", path: "/" }], slug: "missing-page", }), }); @@ -254,6 +267,8 @@ describe("server/handlers/request/ssr/ssr.handler", () => { // The handler's handleNotFound tries fallback pages, but they won't exist in mock; // it eventually builds a 404 response. assertEquals(result.response!.status, 404); + assertEquals(result.response!.headers.get("x-missing-reason"), "gone"); + assertEquals(result.response!.headers.getSetCookie(), ["visited-missing=1; Path=/"]); }); it("returns redirect responses for redirect error type", async () => { @@ -278,6 +293,36 @@ describe("server/handlers/request/ssr/ssr.handler", () => { assertEquals(result.response!.body, null); }); + it("applies response metadata to redirects", async () => { + const mockService = createMockSSRService({ + renderPage: () => + Promise.resolve({ + status: 302, + isStreaming: false, + cacheStrategy: "no-cache" as const, + failure: { + kind: "redirect", + location: "/account", + permanent: false, + } as const, + headers: { "x-auth-result": "signed-in" }, + cookies: [{ name: "session", value: "abc", path: "/", httpOnly: true }], + slug: "sign-in", + } as any), + }); + const result = await new SSRHandler(mockService).handle( + new Request("http://localhost/sign-in"), + makeCtx(), + ); + + assertEquals(result.response!.status, 302); + assertEquals(result.response!.headers.get("location"), "/account"); + assertEquals(result.response!.headers.get("x-auth-result"), "signed-in"); + assertEquals(result.response!.headers.getSetCookie(), [ + "session=abc; Path=/; HttpOnly", + ]); + }); + it("returns 500 for server-error type", async () => { const mockService = createMockSSRService({ renderPage: () => @@ -631,6 +676,73 @@ describe("server/handlers/request/ssr/ssr.handler", () => { assertEquals(result.response!.status, 500); }); + it("preserves response metadata on a custom server-error page", async () => { + const adapter = createMockAdapter(); + adapter.fs.stat = (path: string) => { + if (path.endsWith("/pages")) { + return Promise.resolve({ + isFile: false, + isDirectory: true, + isSymlink: false, + size: 0, + mtime: null, + }); + } + if (path.endsWith("/pages/500.tsx")) { + return Promise.resolve({ + isFile: true, + isDirectory: false, + isSymlink: false, + size: 1, + mtime: null, + }); + } + return Promise.reject(new Error("not found")); + }; + adapter.fs.readFile = () => Promise.resolve("export default function ErrorPage() {}"); + __setComponentSourceLoaderForTests(() => Promise.resolve(() => null)); + __injectProjectReactForTests(React); + __injectReactDOMServerForTests({ + renderToString: () => "", + renderToStaticMarkup: () => "", + }); + try { + const mockService = createMockSSRService({ + renderPage: () => + Promise.resolve({ + status: 500, + html: "dev overlay", + isStreaming: false, + cacheStrategy: "no-cache" as const, + failure: { + kind: "server-error" as const, + exposure: "generic" as const, + error: new Error("Oops"), + }, + headers: { "x-error-state": "reported" }, + cookies: [{ name: "error-seen", value: "1", path: "/" }], + slug: "page", + }), + }); + const handler = new SSRHandler(mockService); + const result = await handler.handle( + new Request("http://localhost/page"), + makeCtx({ + adapter, + isLocalProject: true, + projectId: "metadata-error-page", + }), + ); + + assertEquals(result.response!.status, 500); + assertEquals(result.response!.headers.get("x-error-state"), "reported"); + assertStringIncludes(result.response!.headers.get("set-cookie") ?? "", "error-seen=1"); + } finally { + __setComponentSourceLoaderForTests(null); + resetReactCache(); + } + }); + it("returns runtime error type with dev overlay content", async () => { const mockService = createMockSSRService({ renderPage: () => diff --git a/src/server/handlers/request/ssr/ssr.handler.ts b/src/server/handlers/request/ssr/ssr.handler.ts index 34406f2e48..f6f3c2e232 100644 --- a/src/server/handlers/request/ssr/ssr.handler.ts +++ b/src/server/handlers/request/ssr/ssr.handler.ts @@ -48,6 +48,7 @@ import { PROJECT_EXECUTION_UNAVAILABLE, } from "#veryfront/errors"; import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { appendDataResponseMetadata } from "#veryfront/data/response-metadata.ts"; const logger = serverLogger.component("ssr"); @@ -303,6 +304,7 @@ export class SSRHandler extends BaseHandler { slug, nonce, dependencySnapshot, + rendered, ); case "overloaded": case "runtime": @@ -339,7 +341,7 @@ export class SSRHandler extends BaseHandler { location: string, nonce: string, ): HandlerResult { - const response = this.createSnapshotResponseBuilder( + const builder = this.createSnapshotResponseBuilder( ctx, nonce, result.dependencyPinningCacheKey, @@ -347,8 +349,9 @@ export class SSRHandler extends BaseHandler { .withCORS(req, ctx.securityConfig?.cors) .withSecurity(ctx.securityConfig ?? undefined, req) .withCache(result.cacheStrategy) - .withHeaders({ Location: location }) - .build(null, result.status); + .withHeaders({ Location: location }); + appendDataResponseMetadata(builder.headers, result); + const response = builder.build(null, result.status); return this.respond(response); } @@ -359,6 +362,7 @@ export class SSRHandler extends BaseHandler { slug: string, nonce: string, dependencySnapshot: DependencyPinningSnapshot, + result: SSRRenderResult, ): Promise { const builder = this.createSnapshotResponseBuilder( ctx, @@ -373,15 +377,21 @@ export class SSRHandler extends BaseHandler { builder, dependencySnapshot, ); - if (notFoundResponse) return this.respond(notFoundResponse); + if (notFoundResponse) { + appendDataResponseMetadata(notFoundResponse.headers, result); + return this.respond(notFoundResponse); + } const customResponse = await tryErrorPageFallback(req, ctx, builder, { statusCode: 404, pathname: slug || "/", }, dependencySnapshot); - if (customResponse) return this.respond(customResponse); + if (customResponse) { + appendDataResponseMetadata(customResponse.headers, result); + return this.respond(customResponse); + } - const result: SSRRenderResult = { + const fallbackResult: SSRRenderResult = { status: 404, html: ErrorPages.notFound(slug || "/"), htmlProvenance: "framework", @@ -390,9 +400,12 @@ export class SSRHandler extends BaseHandler { failure: { kind: "not-found" }, slug, dependencyPinningCacheKey: dependencySnapshot.cacheKey, + ...(result.headers ? { headers: result.headers } : {}), + ...(result.cookies ? { cookies: result.cookies } : {}), }; - return this.buildResponse(req, ctx, result, nonce); + const response = await buildSSRResponse(req, ctx, fallbackResult, builder); + return this.respond(response); } private async tryCustomErrorFallback( @@ -414,7 +427,9 @@ export class SSRHandler extends BaseHandler { pathname: result.slug || "/", }, dependencySnapshot); - return customResponse ? this.respond(customResponse) : null; + if (!customResponse) return null; + appendDataResponseMetadata(customResponse.headers, result); + return this.respond(customResponse); } private async buildResponse( diff --git a/src/server/services/rendering/ssr.service.test.ts b/src/server/services/rendering/ssr.service.test.ts index caff9a2d12..13e25f7ce5 100644 --- a/src/server/services/rendering/ssr.service.test.ts +++ b/src/server/services/rendering/ssr.service.test.ts @@ -9,6 +9,10 @@ import type { RendererAdapter } from "../../shared/renderer-factory.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { SERVICE_OVERLOADED, VeryfrontError } from "#veryfront/errors/index.ts"; import { notFound, redirect } from "#veryfront/data/helpers.ts"; +import { + attachDataResponseMetadata, + wrapDataResponseMetadataError, +} from "#veryfront/data/response-metadata.ts"; import { type ApplicationErrorContext, setApplicationErrorReporter, @@ -364,6 +368,33 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(result.cacheStrategy, "short"); }); + it("forces no-cache and suppresses etags when a render sets cookies", async () => { + const adapter = createMockRendererAdapter({ + renderPage: () => + Promise.resolve({ + html: "rendered", + stream: undefined, + ssrHash: "hash123", + frontmatter: {}, + headers: { "x-page-state": "fresh" }, + cookies: [{ name: "session", value: "abc", path: "/" }], + } as any), + }); + const service = new SSRService({ + rendererProvider: createMockRendererProvider(adapter), + }); + + const result = await service.renderPage( + makeCtx(), + makeRenderOptions({ useNoCache: false }), + ); + + assertEquals(result.headers, { "x-page-state": "fresh" }); + assertEquals(result.cookies, [{ name: "session", value: "abc", path: "/" }]); + assertEquals(result.cacheStrategy, "no-cache"); + assertEquals(result.etag, undefined); + }); + it("requests buffered delivery when the response is cacheable", async () => { let delivery: unknown; const adapter = createMockRendererAdapter({ @@ -462,12 +493,18 @@ describe("server/services/rendering/ssr.service", () => { it("handles file-not-found error as not-found result", async () => { const adapter = createMockRendererAdapter({ renderPage: () => { - throw new VeryfrontError("Not found", { - slug: "file-not-found", - category: "ROUTE", - status: 404, - title: "File not found", - }); + throw attachDataResponseMetadata( + new VeryfrontError("Not found", { + slug: "file-not-found", + category: "ROUTE", + status: 404, + title: "File not found", + }), + { + headers: { "x-missing-reason": "gone" }, + cookies: [{ name: "visited-missing", value: "1", path: "/" }], + }, + ); }, }); const service = new SSRService({ @@ -479,6 +516,8 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(result.failure?.kind, "not-found"); assertEquals(result.isStreaming, false); assertEquals(result.cacheStrategy, "no-cache"); + assertEquals(result.headers, { "x-missing-reason": "gone" }); + assertEquals(result.cookies, [{ name: "visited-missing", value: "1", path: "/" }]); }); it("handles api-client-error 404 for undeployed project", async () => { @@ -507,18 +546,24 @@ describe("server/services/rendering/ssr.service", () => { it("maps render redirects to redirect results", async () => { const adapter = createMockRendererAdapter({ renderPage: () => { - throw new VeryfrontError("Redirect to /login", { - slug: "render-error", - category: "RUNTIME", - status: 500, - title: "Component render failed", - context: { - redirect: { - destination: "/login", - permanent: false, + throw wrapDataResponseMetadataError( + new VeryfrontError("Redirect to /login", { + slug: "render-error", + category: "RUNTIME", + status: 500, + title: "Component render failed", + context: { + redirect: { + destination: "/login", + permanent: false, + }, }, + }), + { + headers: { "x-auth-result": "required" }, + cookies: [{ name: "return-to", value: "/private", path: "/" }], }, - }); + ); }, }); const service = new SSRService({ @@ -530,6 +575,12 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(result.failure?.kind, "redirect"); assertEquals(redirectLocationOf(result), "/login"); assertEquals(result.cacheStrategy, "no-cache"); + assertEquals(result.headers, { "x-auth-result": "required" }); + assertEquals(result.cookies, [{ + name: "return-to", + value: "/private", + path: "/", + }]); }); it("maps a thrown notFound() control result to a 404", async () => { @@ -565,6 +616,51 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(result.cacheStrategy, "no-cache"); }); + it("applies buffered control metadata after loader metadata", async () => { + for ( + const [control, expectedStatus, expectedKind] of [ + [ + notFound({ + headers: { "x-state": "control" }, + cookies: [{ name: "control-seen", value: "1", path: "/" }], + }), + 404, + "not-found", + ], + [ + redirect("/login", false, { + headers: { "x-state": "control" }, + cookies: [{ name: "control-seen", value: "1", path: "/" }], + }), + 302, + "redirect", + ], + ] as const + ) { + const adapter = createMockRendererAdapter({ + renderPage: () => { + throw wrapDataResponseMetadataError(control, { + headers: { "x-state": "loader" }, + cookies: [{ name: "loader-seen", value: "1", path: "/" }], + }); + }, + }); + const service = new SSRService({ + rendererProvider: createMockRendererProvider(adapter), + }); + + const result = await service.renderPage(makeCtx(), makeRenderOptions()); + + assertEquals(result.status, expectedStatus); + assertEquals(result.failure?.kind, expectedKind); + assertEquals(result.headers, { "x-state": "control" }); + assertEquals(result.cookies, [ + { name: "loader-seen", value: "1", path: "/" }, + { name: "control-seen", value: "1", path: "/" }, + ]); + } + }); + it("maps a notFound() reported after the streaming shell to a 404 before responding", async () => { const adapter = createMockRendererAdapter({ renderPage: () => @@ -573,6 +669,8 @@ describe("server/services/rendering/ssr.service", () => { stream: createReactReadyStream(notFound()), ssrHash: undefined, frontmatter: {}, + headers: { "x-data-state": "missing" }, + cookies: [{ name: "missing-seen", value: "1", path: "/" }], }), }); const service = new SSRService({ @@ -588,6 +686,8 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(result.failure?.kind, "not-found"); assertEquals(result.isStreaming, false); assertEquals(result.cacheStrategy, "no-cache"); + assertEquals(result.headers, { "x-data-state": "missing" }); + assertEquals(result.cookies, [{ name: "missing-seen", value: "1", path: "/" }]); }); it("maps a redirect() reported after the streaming shell to a redirect before responding", async () => { @@ -595,9 +695,16 @@ describe("server/services/rendering/ssr.service", () => { renderPage: () => Promise.resolve({ html: "", - stream: createReactReadyStream(redirect("/login")), + stream: createReactReadyStream( + redirect("/login", false, { + headers: { "x-data-state": "redirect-control" }, + cookies: [{ name: "control-seen", value: "1", path: "/" }], + }), + ), ssrHash: undefined, frontmatter: {}, + headers: { "x-data-state": "redirected" }, + cookies: [{ name: "redirect-seen", value: "1", path: "/" }], }), }); const service = new SSRService({ @@ -614,6 +721,11 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(redirectLocationOf(result), "/login"); assertEquals(result.isStreaming, false); assertEquals(result.cacheStrategy, "no-cache"); + assertEquals(result.headers, { "x-data-state": "redirect-control" }); + assertEquals(result.cookies, [ + { name: "redirect-seen", value: "1", path: "/" }, + { name: "control-seen", value: "1", path: "/" }, + ]); }); it("maps a permanent thrown redirect() to a 301", async () => { @@ -713,6 +825,39 @@ describe("server/services/rendering/ssr.service", () => { } }); + it("preserves attached response metadata on non-control render failures", async () => { + for ( + const [ctx, expectedKind] of [ + [makeCtx(), "runtime"], + [ + makeCtx({ + isLocalProject: false, + allowHostProjectCodeExecution: true, + }), + "server-error", + ], + ] as const + ) { + const adapter = createMockRendererAdapter({ + renderPage: () => { + throw attachDataResponseMetadata(new Error("Render failed after data"), { + headers: { "x-error-state": "reported" }, + cookies: [{ name: "error-seen", value: "1", path: "/" }], + }); + }, + }); + const service = new SSRService({ + rendererProvider: createMockRendererProvider(adapter), + }); + + const result = await service.renderPage(ctx, makeRenderOptions()); + + assertEquals(result.failure?.kind, expectedKind); + assertEquals(result.headers, { "x-error-state": "reported" }); + assertEquals(result.cookies, [{ name: "error-seen", value: "1", path: "/" }]); + } + }); + it("captures app-router error-boundary failures before returning boundary HTML", async () => { const captured: Array<{ error: unknown; context: ApplicationErrorContext }> = []; setApplicationErrorReporter({ @@ -722,9 +867,16 @@ describe("server/services/rendering/ssr.service", () => { }, flush: () => Promise.resolve(true), }); - const renderError = Object.assign(new Error("App router render failed"), { + const boundaryError = Object.assign(new Error("App router render failed"), { errorBoundaryHtml: "Error boundary", }); + const renderError = wrapDataResponseMetadataError( + boundaryError, + { + headers: { "x-error-state": "reported" }, + cookies: [{ name: "error-seen", value: "1", path: "/" }], + }, + ); const adapter = createMockRendererAdapter({ renderPage: () => { throw renderError; @@ -738,7 +890,9 @@ describe("server/services/rendering/ssr.service", () => { const result = await service.renderPage(makeCtx(), makeRenderOptions()); assertEquals(result.status, 500); assertEquals(result.failure?.kind, "app-router-error-boundary"); - assertEquals(result.html, renderError.errorBoundaryHtml); + assertEquals(result.html, boundaryError.errorBoundaryHtml); + assertEquals(result.headers, { "x-error-state": "reported" }); + assertEquals(result.cookies, [{ name: "error-seen", value: "1", path: "/" }]); assertEquals(captured.length, 1); assertEquals((captured[0]?.error as Error).message, "App router render failed"); assertEquals(captured[0]?.context, { diff --git a/src/server/services/rendering/ssr.service.ts b/src/server/services/rendering/ssr.service.ts index de5e4658ce..caaa491dd4 100644 --- a/src/server/services/rendering/ssr.service.ts +++ b/src/server/services/rendering/ssr.service.ts @@ -30,6 +30,12 @@ import { import type { CacheRepository } from "#veryfront/repositories/types.ts"; import type { DependencyPinningSourceInput } from "#veryfront/transforms/esm/package-registry.ts"; import { isHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; +import type { DataResponseMetadata, ResponseCookie } from "#veryfront/data/types.ts"; +import { + getAttachedDataResponseMetadata, + mergeDataResponseMetadata, + unwrapDataResponseMetadataError, +} from "#veryfront/data/response-metadata.ts"; const logger = serverLogger.component("ssr-service"); @@ -82,6 +88,10 @@ export interface SSRRenderResult { slug: string; /** Dependency snapshot identity rendered into this document. */ dependencyPinningCacheKey?: string; + /** Validated application headers appended after framework-owned headers. */ + headers?: Record; + /** Distinct cookies emitted as separate Set-Cookie response fields. */ + cookies?: ResponseCookie[]; } export interface SSRRenderOptions { @@ -124,6 +134,8 @@ function buildRedirectResult( cacheStrategy: "no-cache", failure: redirect, slug, + ...(redirect.headers ? { headers: redirect.headers } : {}), + ...(redirect.cookies ? { cookies: redirect.cookies } : {}), }; } @@ -131,15 +143,20 @@ function buildRedirectResult( * Build the 404 result shared by the thrown-control-result and file-not-found * paths. `slug` is escaped by `ErrorPages.notFound`. */ -function buildNotFoundResult(slug: string): SSRRenderResult { +function buildNotFoundResult( + notFound: Extract, + slug: string, +): SSRRenderResult { return { status: HTTP_NOT_FOUND, html: ErrorPages.notFound(slug || "/"), htmlProvenance: "framework", isStreaming: false, cacheStrategy: "no-cache", - failure: { kind: "not-found" }, + failure: notFound, slug, + ...(notFound.headers ? { headers: notFound.headers } : {}), + ...(notFound.cookies ? { cookies: notFound.cookies } : {}), }; } @@ -303,8 +320,15 @@ export class SSRService implements SSRServiceLike { } const isStreaming = !!result.stream && !result.html; - const cacheStrategy = useNoCache ? "no-cache" : "short"; - const etag = isStreaming ? undefined : computeSSRETag(result.ssrHash, result.html); + const responseMetadata: DataResponseMetadata = { + ...(result.headers ? { headers: result.headers } : {}), + ...(result.cookies ? { cookies: result.cookies } : {}), + }; + const setsCookies = (responseMetadata.cookies?.length ?? 0) > 0; + const cacheStrategy = useNoCache || setsCookies ? "no-cache" : "short"; + const etag = isStreaming || setsCookies + ? undefined + : computeSSRETag(result.ssrHash, result.html); if (isStreaming) { const allReady = getAllReady(result.stream); @@ -313,7 +337,14 @@ export class SSRService implements SSRServiceLike { await allReady; } catch (error) { if (findSSRControlOutcome(error)) { - return this.handleRenderError(error, ctx, slug, request, nonce); + return this.handleRenderError( + error, + ctx, + slug, + request, + nonce, + responseMetadata, + ); } } } @@ -328,6 +359,7 @@ export class SSRService implements SSRServiceLike { cacheStrategy, slug, dependencyPinningCacheKey: options.dependencyPinningCacheKey, + ...responseMetadata, }; } catch (error) { if (hasRenderSession(renderSessionId)) { @@ -343,8 +375,20 @@ export class SSRService implements SSRServiceLike { slug: string, request: Request, nonce?: string, + inheritedResponseMetadata: DataResponseMetadata = {}, ): SSRRenderResult { - const outcome = resolveSSRFailure(error, { isLocalProject: Boolean(ctx.isLocalProject) }); + const attachedResponseMetadata = error instanceof Error + ? getAttachedDataResponseMetadata(error) + : {}; + const responseMetadata = mergeDataResponseMetadata([ + inheritedResponseMetadata, + attachedResponseMetadata, + ]); + const classifiedError = error instanceof Error ? unwrapDataResponseMetadataError(error) : error; + const outcome = resolveSSRFailure(classifiedError, { + isLocalProject: Boolean(ctx.isLocalProject), + }); + const requestLocalMetadata = classifiedError === error ? {} : attachedResponseMetadata; switch (outcome.kind) { case "app-router-error-boundary": @@ -359,6 +403,7 @@ export class SSRService implements SSRServiceLike { cacheStrategy: "no-cache", failure: outcome, slug, + ...responseMetadata, }; case "redirect": logger.debug("SSR redirect", { @@ -367,10 +412,24 @@ export class SSRService implements SSRServiceLike { permanent: outcome.permanent, projectSlug: ctx.projectSlug, }); - return buildRedirectResult(outcome, slug); + return buildRedirectResult({ + ...outcome, + ...mergeDataResponseMetadata([ + inheritedResponseMetadata, + requestLocalMetadata, + outcome, + ]), + }, slug); case "not-found": logger.debug("SSR notFound", { slug }); - return buildNotFoundResult(slug); + return buildNotFoundResult({ + ...outcome, + ...mergeDataResponseMetadata([ + inheritedResponseMetadata, + requestLocalMetadata, + outcome, + ]), + }, slug); case "undeployed": logger.debug("Project not deployed", { projectSlug: ctx.projectSlug, @@ -384,6 +443,7 @@ export class SSRService implements SSRServiceLike { cacheStrategy: "no-cache", failure: outcome, slug, + ...responseMetadata, }; case "overloaded": return { @@ -394,6 +454,7 @@ export class SSRService implements SSRServiceLike { cacheStrategy: "no-cache", failure: outcome, slug, + ...responseMetadata, }; case "runtime": captureApplicationError(outcome.error, { @@ -434,6 +495,7 @@ export class SSRService implements SSRServiceLike { cacheStrategy: "no-cache", failure: outcome, slug, + ...responseMetadata, }; } case "server-error": @@ -457,6 +519,7 @@ export class SSRService implements SSRServiceLike { cacheStrategy: "no-cache", failure: outcome, slug, + ...responseMetadata, }; } } diff --git a/src/types/index.ts b/src/types/index.ts index c41ad0c1c1..79b21c8da1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ */ import type * as React from "react"; +import type { ResponseCookie } from "#veryfront/data/types.ts"; export type { BundleResult, @@ -192,6 +193,10 @@ export interface RenderResult { type: "mdx" | "component"; }; ssrHash?: string; + /** Validated application headers appended after framework-owned headers. */ + headers?: Record; + /** Distinct cookies serialized as separate Set-Cookie response fields. */ + cookies?: ResponseCookie[]; } export type { diff --git a/tests/integration/data/fetching-edge-cases.test.ts b/tests/integration/data/fetching-edge-cases.test.ts index 4977961a82..d742102b10 100644 --- a/tests/integration/data/fetching-edge-cases.test.ts +++ b/tests/integration/data/fetching-edge-cases.test.ts @@ -366,7 +366,7 @@ describe("DataFetcher - Edge Cases and Error Handling", () => { assertEquals(result.revalidate, 0); }); - it("should handle revalidate with negative number", async () => { + it("rejects a negative revalidate value", async () => { const fetcher = new DataFetcher(); const page: PageWithData = { default: () => null, @@ -376,13 +376,16 @@ describe("DataFetcher - Edge Cases and Error Handling", () => { }), }; - const result = await fetcher.fetchData( - page, - makeContext("http://localhost/test"), - "production", + await assertRejects( + () => + fetcher.fetchData( + page, + makeContext("http://localhost/test"), + "production", + ), + TypeError, + "getStaticData must return a valid data result object", ); - - assertEquals(result.revalidate, -100); }); it("should handle very large revalidate values", async () => {