Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8dc27fa
fix(module-server): type .json.js modules as JSON, not JavaScript
mattboon Jul 21, 2026
2ffa965
fix(transforms): match server-only hooks on their exported name
mattboon Jul 21, 2026
c55e15a
fix(hydration): never let a 404 outrank a real module error
mattboon Jul 21, 2026
12bfab0
fix(transforms): leave node builtins with a real polyfill alone
mattboon Jul 21, 2026
c214569
fix(module-loader): stop a dynamic import cycle from recursing forever
mattboon Jul 21, 2026
c8e7d52
fix(transforms): cover legacy assertions, strip attributes via lexer
mattboon Jul 21, 2026
b3aa17f
fix(data): brand control results and normalise them in the worker
mattboon Jul 21, 2026
7f34ad8
fix(transforms): resolve MDX imports like every other local import
mattboon Jul 21, 2026
9a19aa6
fix(api): read the request body with ctx.body(), keep ctx.json() writing
mattboon Jul 21, 2026
4656844
fix(middleware): describe the default export that was rejected
mattboon Jul 21, 2026
9345f00
fix(api): stop an @/ alias from loading files outside the project
mattboon Jul 21, 2026
01a558e
fix(module-loader): keep index paths that carry a source extension
mattboon Jul 21, 2026
0d4de0c
fix(transforms,ssr): scope asset rejection to project files
mattboon Jul 21, 2026
bd5881d
test(module-loader): stop esbuild in afterAll instead of opting out o…
mattboon Jul 22, 2026
b5bc891
test(import-rewriter): narrow the rejected error before reading .message
mattboon Jul 22, 2026
0a68737
fix(transforms,routing): stop cold-cache redis 500s, fix optional-cat…
mattboon Jul 22, 2026
6c0779b
chore: bump version to 0.1.1102
mattboon Jul 22, 2026
2c557d5
test: regression coverage for the cold-cache/redis, optional-catch-al…
mattboon Jul 22, 2026
9d87e94
fix(api): clone request body eagerly so ctx.body() is order-independent
mattboon Jul 22, 2026
f06c2b1
fix(ssr-vf-modules): fail closed on un-transformable relative dep (dr…
mattboon Jul 22, 2026
16237f1
fix(browser-strip): drop module-scope server state left by emptied hooks
mattboon Jul 22, 2026
a021af3
fix(module-loader): alias cycle targets so a .ts dynamic-import cycle…
mattboon Jul 22, 2026
a3015c1
docs(api-routes): use ctx.body() to read the request body, not ctx.js…
mattboon Jul 22, 2026
6847270
chore: bump version to 0.1.1103
mattboon Jul 22, 2026
088a488
Merge remote-tracking branch 'origin/main' into fix/reland-stack-fixes
mattboon Jul 22, 2026
c48399b
fix(browser-strip): scope module-scope pruning to the stripped hook's…
mattboon Jul 22, 2026
e598fce
fix(bare-strategy): only keep server-only npm: specifiers external
mattboon Jul 22, 2026
654ce73
test: lock in browser-strip closure forms + npm: rewrite edges
mattboon Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.1102",
"version": "0.1.1103",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": {
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference/veryfront/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ export function getServerData(ctx: DataContext) {
| `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L19) |
| `mergeConfigs` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L20) |
| `mergeConfigs` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L21) |
| `notFound` | Return a 404 result from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L9) |
| `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L45) |
| `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#L48) |
| `parseJsonBody` | Parse and validate a JSON request body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L12) |
| `parseQueryParams` | Parse and validate query parameters from a request URL. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/parsers.ts#L81) |
| `redirect` | Return a redirect result from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L4) |
| `redirect` | Redirect the request from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L34) |
| `sanitizeData` | Sanitize data to prevent XSS and prototype pollution attacks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/sanitizers.ts#L2) |
| `serverError` | Create a 500 Internal Server Error response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) |
| `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L331) |
Expand Down
13 changes: 8 additions & 5 deletions docs/guides/api-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export function GET() {
}
```

Use `pages/api/**` in the pages router. Export named HTTP method handlers or a `default` fallback handler. Each handler receives an `APIContext` as `ctx`; use `ctx.request` for the raw request, `ctx.params` for route params, and `ctx.query` for query parameters.

The `ctx.json` helper is intentionally overloaded by arity: `ctx.json()` reads the request body as JSON, while `ctx.json(data, init?)` returns a JSON `Response`. Use `ctx.request` directly when you need lower-level request APIs such as streaming, form data, or text parsing.
Use `pages/api/**` in the pages router. Export named HTTP method handlers or a `default` fallback handler. Each handler receives an `APIContext` as `ctx`; use `ctx.request` for the raw request, `ctx.params` for route params, `ctx.query` for query parameters, and `ctx.json(data)` or `Response.json(data)` to return JSON. To read a posted JSON body, use `ctx.body()`, which parses it once and answers a 400 if it is malformed.

```ts
// pages/api/hello.ts
Expand All @@ -36,6 +34,11 @@ import type { APIContext } from "veryfront";
export function GET(ctx: APIContext) {
return ctx.json({ message: "Hello, world!" });
}

export async function POST(ctx: APIContext) {
const { name } = await ctx.body<{ name: string }>();
return ctx.json({ message: `Hello, ${name}!` });
}
```

## Basic route
Expand Down Expand Up @@ -99,13 +102,13 @@ export async function GET(ctx: APIContext) {
}

export async function POST(ctx: APIContext) {
const body = await ctx.json();
const body = await ctx.body<Record<string, unknown>>();
const user = { id: "user_456", ...body };
return ctx.json(user, { status: 201 });
}

export async function DELETE(ctx: APIContext) {
const { id } = await ctx.json() as { id?: string };
const { id } = await ctx.body<{ id?: string }>();
if (!id) return ctx.json({ error: "Missing id" }, { status: 400 });
return new Response(null, { status: 204 });
}
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/data-fetching.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@ export async function getServerData({ params }: DataContext) {
redirect("/new-url", true); // 301 permanent redirect
```

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
import { type DataContext, notFound } from "veryfront";

const posts = [{ slug: "hello", title: "Hello" }];

function requirePost(slug: string) {
const post = posts.find((item) => item.slug === slug);
if (!post) throw notFound();

return post;
}

export function getServerData({ params }: DataContext) {
return { props: { post: requirePost(String(params.slug)) } };
}
```

Only the objects `notFound()` and `redirect()` produce are read as control flow. Every other thrown value is an error, including an object that happens to carry a `notFound` property, such as a parsed error body from an upstream API.

## Client-side fetching

For data that loads after the page renders, fetch in a client component:
Expand Down
44 changes: 44 additions & 0 deletions src/data/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,49 @@ describe("helpers.ts", () => {
it("rejects notFound: false", () => {
assertEquals(isDataControlResult({ notFound: false }), false);
});

// A loader that does `throw await res.json()` against an upstream returning
// `{ notFound: true, message: "record locked" }` must reach the error
// handler. Reading it as a 404 renders the wrong page, records a circuit
// breaker success, skips the log, and caches the bogus 404.
it("rejects an unbranded object that only looks like notFound()", () => {
assertEquals(isDataControlResult({ notFound: true }), false);
assertEquals(
isDataControlResult({ notFound: true, message: "record locked", requestId: "abc" }),
false,
);
});

it("rejects an unbranded object that only looks like redirect()", () => {
assertEquals(isDataControlResult({ redirect: { destination: "/login" } }), false);
assertEquals(
isDataControlResult({ redirect: { destination: "/login", permanent: true } }),
false,
);
});

it("recognises a control result rebuilt from the same public brand", () => {
// Project code runs against its own copy of the helpers. The brand is a
// registered symbol so it matches across module instances and realms.
const rebuilt = { notFound: true };
Object.defineProperty(rebuilt, Symbol.for("veryfront.dataControlResult"), { value: true });

assertEquals(isDataControlResult(rebuilt), true);
});
});

describe("returned-result contract", () => {
it("keeps the brand off the serialized shape", () => {
assertEquals(JSON.stringify(notFound()), '{"notFound":true}');
assertEquals(
JSON.stringify(redirect("/login")),
'{"redirect":{"destination":"/login","permanent":false}}',
);
});

it("keeps the brand off enumerable keys", () => {
assertEquals(Object.keys(notFound()), ["notFound"]);
assertEquals(Object.keys(redirect("/login")), ["redirect"]);
});
});
});
60 changes: 48 additions & 12 deletions src/data/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
import type { DataResult } from "./types.ts";

/** Return a redirect result from a data loader. */
/**
* Brand marking an object as produced by {@link notFound} or {@link redirect}.
*
* A registered symbol, so a result built by one copy of this module is
* recognised by another. Project code and the framework do not always share a
* module instance, and isolated data fetching crosses a realm boundary.
*
* Symbols are dropped by `structuredClone`, so the brand does not survive
* `postMessage`. Worker-side code normalises a thrown control result before it
* is posted back, while the object is still in-realm.
*/
const DATA_CONTROL_RESULT = Symbol.for("veryfront.dataControlResult");

/**
* Mark a result as framework-produced control flow.
*
* The brand is non-enumerable, so it stays out of `Object.keys`,
* `JSON.stringify`, and the `DataResult` schema. A returned control result
* behaves exactly as it did before the brand existed.
*/
function brandDataControlResult(result: DataResult): DataResult {
Object.defineProperty(result, DATA_CONTROL_RESULT, { value: true });
return result;
}

/**
* Redirect the request from a data loader.
*
* Return it or throw it. `throw redirect("/login")` behaves exactly like
* `return redirect("/login")`.
*/
export function redirect(destination: string, permanent = false): DataResult {
return { redirect: { destination, permanent } };
return brandDataControlResult({ redirect: { destination, permanent } });
}

/** Return a 404 result from a data loader. */
/**
* Render the 404 page from a data loader.
*
* Return it or throw it. `throw notFound()` behaves exactly like
* `return notFound()`, which is useful deep inside a helper that has no clean
* way to return to the loader.
*/
export function notFound(): DataResult {
return { notFound: true };
return brandDataControlResult({ notFound: true });
}

/**
Expand All @@ -18,19 +54,19 @@ export function notFound(): DataResult {
* naturally and is what people coming from other frameworks reach for. Thrown,
* the plain object is not an `Error`, so the SSR error handler stringified it
* to `[object Object]` and returned a 500 instead of the intended 404 or
* redirect. Recognising the shape lets a thrown result behave like a returned
* redirect. Recognising the brand lets a thrown result behave like a returned
* one.
*
* The check is on the brand, never on the shape. A loader that does
* `throw await response.json()` against an upstream answering
* `{ notFound: true, message: "record locked" }` is reporting a failure, and
* reading that as a 404 would render the wrong page, log nothing, and cache a
* 404 the site never asked for.
*/
export function isDataControlResult(value: unknown): value is DataResult {
if (value === null || typeof value !== "object") return false;
if (value instanceof Error) return false;

const candidate = value as { notFound?: unknown; redirect?: unknown };

if (candidate.notFound === true) return true;

const destination = (candidate.redirect as { destination?: unknown } | undefined)?.destination;
return typeof destination === "string";
return (value as Record<symbol, unknown>)[DATA_CONTROL_RESULT] === true;
}

/**
Expand Down
118 changes: 117 additions & 1 deletion src/data/server-data-fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import "#veryfront/schemas/_test-setup.ts";
import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts";
import { afterEach, describe, it } from "#veryfront/testing/bdd.ts";
import { ServerDataFetcher } from "./server-data-fetcher.ts";
import type { DataContext, PageWithData } from "./types.ts";
import type { DataContext, DataResult, PageWithData } from "./types.ts";
import { notFound, redirect } from "./helpers.ts";
import { __resetPoolForTests } from "#veryfront/security/sandbox/worker-pool.ts";
import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts";
import { join } from "node:path";

describe("ServerDataFetcher", () => {
function createContext(overrides: Partial<DataContext> = {}): DataContext {
Expand Down Expand Up @@ -445,6 +447,120 @@ describe("ServerDataFetcher", () => {
}
});

// Worker isolation is the configuration operators are told to use for
// untrusted project code, so the in-process path alone is not enough. A
// control result thrown inside the worker is a plain object, and the worker
// error path serialized it with String(), producing "[object Object]" and a
// 500 on the host.
describe("under worker isolation", () => {
let projectDir: string | null = null;

afterEach(async () => {
try {
Deno.env.delete("WORKER_ISOLATION_ENABLED");
} catch { /* ok */ }
try {
Deno.env.delete("WORKER_ISOLATION_DATA");
} catch { /* ok */ }
__resetPoolForTests();

if (projectDir) {
await Deno.remove(projectDir, { recursive: true }).catch(() => {});
projectDir = null;
}
});

async function writeIsolatedPage(source: string): Promise<
{ modulePath: string; projectDir: string }
> {
const dir = await Deno.realPath(await Deno.makeTempDir({ prefix: "vf-isolated-data-" }));
projectDir = dir;
const modulePath = join(dir, "page.ts");
await Deno.writeTextFile(modulePath, source);

Deno.env.set("WORKER_ISOLATION_ENABLED", "1");
Deno.env.set("WORKER_ISOLATION_DATA", "1");
__resetPoolForTests();

return { modulePath, projectDir: dir };
}

// The worker cannot import the framework helpers: its read permission is
// scoped to the project directory. `notFound()` brands its result with a
// registered symbol precisely so a result built anywhere is recognised
// everywhere, so the fixture rebuilds the same public brand.
const BRAND_SOURCE =
`Object.defineProperty(result, Symbol.for("veryfront.dataControlResult"), { value: true });`;

function isolatedFetch(
modulePath: string,
dir: string,
): Promise<DataResult> {
const fetcher = new ServerDataFetcher();
const pageModule: PageWithData = {
default: () => null,
getServerData: () => ({ props: {} }),
};

return runWithExactSourceIntegrationPolicy(
{ schemaVersion: 1, mode: "unrestricted" },
() =>
fetcher.fetch(pageModule, createContext(), {
modulePath,
projectDir: dir,
}),
);
}

it("treats a thrown notFound() as a 404 result", async () => {
const { modulePath, projectDir: dir } = await writeIsolatedPage(
`export function getServerData() {
const result = { notFound: true };
${BRAND_SOURCE}
throw result;
}
export default function Page() { return null; }`,
);

const result = await isolatedFetch(modulePath, dir);

assertEquals(result.notFound, true);
assertEquals(result.redirect, undefined);
});

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 } };
${BRAND_SOURCE}
throw result;
}
export default function Page() { return null; }`,
);

const result = await isolatedFetch(modulePath, dir);

assertEquals(result.redirect?.destination, "/login");
assertEquals(result.redirect?.permanent, true);
assertEquals(result.notFound, undefined);
});

it("still propagates a genuine Error thrown in the worker", async () => {
const { modulePath, projectDir: dir } = await writeIsolatedPage(
`export function getServerData() {
throw new Error("intentional test error from isolated getServerData");
}
export default function Page() { return null; }`,
);

await assertRejects(
() => isolatedFetch(modulePath, dir),
Error,
"intentional test error from isolated getServerData",
);
});
});

it("still opens the circuit breaker on repeated genuine errors", async () => {
const fetcher = new ServerDataFetcher();
const context = createContext({
Expand Down
Loading