From e33a02912d3e7ec21c92f44fc30dd8727e6e8244 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 13:23:19 +0800 Subject: [PATCH 001/127] refactor(cli): share platform request helper and trim auth slop - Route plain PlatformClient endpoints through one request, error and JSON helper - Drop runLogin, saveToken, ensureCloudflaredToken, the password prompt wrapper and unused type aliases - Remove unreachable trailing-slash, login-provider default and migration warning branches - Accept only the options object in the PlatformClient constructor and default openBrowser's launcher - Trim restating comments in the client, auth and GitHub OIDC modules Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/auth-link.ts | 7 +- packages/void/src/cli/auth.ts | 129 +- packages/void/src/cli/cf-access.ts | 5 - packages/void/src/cli/client.ts | 1203 +++++------------ packages/void/src/cli/db.ts | 3 - packages/void/src/cli/github-oidc.ts | 51 +- packages/void/src/cli/login.ts | 26 +- packages/void/src/cli/operator-client.ts | 4 +- packages/void/src/cli/platform-cmd.ts | 8 +- packages/void/src/cli/platform-lifecycle.ts | 5 +- packages/void/src/cli/prompt-password.ts | 12 - packages/void/src/plugin-ai-setup.ts | 2 +- packages/void/test/unit/auth-link.test.ts | 5 +- packages/void/test/unit/auth-login.test.ts | 12 +- .../void/test/unit/auth-token-storage.test.ts | 32 +- packages/void/test/unit/cf-access.test.ts | 6 +- packages/void/test/unit/cli-outdated.test.ts | 24 +- .../test/unit/client-platform-version.test.ts | 7 +- .../void/test/unit/email-cli-domain.test.ts | 2 +- packages/void/test/unit/github-oidc.test.ts | 10 - packages/void/test/unit/init.test.ts | 12 +- packages/void/test/unit/login-command.test.ts | 33 +- .../unit/platform-permission-errors.test.ts | 3 +- .../test/unit/platform-setup-flow.test.ts | 1 - .../void/test/unit/prompt-password.test.ts | 84 -- 25 files changed, 432 insertions(+), 1254 deletions(-) delete mode 100644 packages/void/src/cli/prompt-password.ts delete mode 100644 packages/void/test/unit/prompt-password.test.ts diff --git a/packages/void/src/cli/auth-link.ts b/packages/void/src/cli/auth-link.ts index b2c1baf0f..1d111b46c 100644 --- a/packages/void/src/cli/auth-link.ts +++ b/packages/void/src/cli/auth-link.ts @@ -1,4 +1,3 @@ -import { execFile } from 'node:child_process'; import { log } from '@voidzero-dev/vite-plus-prompts'; import { getApiUrl, getToken, openBrowser, runPlatformLogin, savePlatformToken } from './auth.ts'; import { PlatformClient } from './client.ts'; @@ -16,13 +15,13 @@ export async function runAuthLink(root: string, connectionId?: string): Promise< throw new Error('auth: This platform must be upgraded before linking login methods.'); } const target = await selectPlatformLoginMethod(platform, connectionId); - const existing = await new PlatformClient(token, apiUrl).whoami(); + const existing = await new PlatformClient(token, { apiUrl }).whoami(); log.info(`Sign in again to ${existing.login} using an already linked login method.`); const source = await selectPlatformLoginMethod(platform); const freshToken = await runPlatformLogin(apiUrl, source.id, (url) => log.info(`Sign in: ${url}`), ); - const client = new PlatformClient(freshToken, apiUrl); + const client = new PlatformClient(freshToken, { apiUrl }); const fresh = await client.whoami(); if (fresh.id !== existing.id) { throw new Error( @@ -34,5 +33,5 @@ export async function runAuthLink(root: string, connectionId?: string): Promise< log.info( `Authenticate with ${target.label}, then review and confirm the identity in your browser.\n${launch.url}`, ); - openBrowser(launch.url, execFile); + openBrowser(launch.url); } diff --git a/packages/void/src/cli/auth.ts b/packages/void/src/cli/auth.ts index d9f46e4d5..da16d22d5 100644 --- a/packages/void/src/cli/auth.ts +++ b/packages/void/src/cli/auth.ts @@ -1,10 +1,11 @@ +import { execFile } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { browserPageLead, browserPageStatus, renderBrowserPage } from '@void/platform/browser-page'; import { join } from 'pathslash'; import { cfAccessHeaders } from '../shared/cf-access.ts'; -import { ensureCloudflaredToken } from './cf-access.ts'; +import { ensurePlatformAccessToken } from './cf-access.ts'; import { deleteKeyringPassword, readKeyringPassword, writeKeyringPassword } from './keyring.ts'; import { getActivePlatformConnection, @@ -18,11 +19,6 @@ const CONFIG_DIR = join(homedir(), '.void'); const CONFIG_PATH = join(CONFIG_DIR, 'config.json'); const KEYRING_SERVICE = 'dev.void.cli'; -// --------------------------------------------------------------------------- -// Keyring helpers — try/catch around `new Entry` for environments where the -// native addon fails at runtime (CI, Docker, SSH, etc.) -// --------------------------------------------------------------------------- - function keyringGet(account: string): string | null { return readKeyringPassword(KEYRING_SERVICE, account); } @@ -35,14 +31,9 @@ function keyringDelete(account: string): boolean { return deleteKeyringPassword(KEYRING_SERVICE, account); } -// --------------------------------------------------------------------------- -// Config file — non-sensitive fields only (api). -// Origin-scoped file credentials remain readable when a keychain is unavailable. -// --------------------------------------------------------------------------- - +// Legacy origin-scoped tokens, moved to the keychain when read. type Config = { tokens?: Record; - api?: string; }; function readConfig(): Config { @@ -161,26 +152,14 @@ export function getTokenSource(): 'ci' | 'manual' { return process.env.VOID_TOKEN ? 'ci' : 'manual'; } -export function saveToken( - token: string, - root: string = process.cwd(), - dependencies: { keyringSet?: (account: string, password: string) => boolean } = {}, -): void { - savePlatformToken(token, getApiUrl(root), dependencies); -} - export function readPlatformToken(apiUrl: string): string | null { return readOriginScopedToken(apiUrl, readConfig()); } -export function savePlatformToken( - token: string, - origin: string, - dependencies: { keyringSet?: (account: string, password: string) => boolean } = {}, -): void { +export function savePlatformToken(token: string, origin: string): void { const apiUrl = normalizePlatformApiUrl(origin); const account = platformCredentialAccount(apiUrl); - if ((dependencies.keyringSet ?? keyringSet)(account, token)) { + if (keyringSet(account, token)) { return; } throw new Error( @@ -189,7 +168,7 @@ export function savePlatformToken( } export function removeToken(root: string = process.cwd()): boolean { - const apiUrl = normalizePlatformApiUrl(getApiUrl(root)); + const apiUrl = getApiUrl(root); const account = platformCredentialAccount(apiUrl); let removed = false; @@ -235,9 +214,8 @@ async function exchangeLoginCode( state: string, codeVerifier: string, ): Promise { - const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; - ensureCloudflaredToken(apiUrl); - const res = await fetch(new URL('auth/cli/token', base), { + ensurePlatformAccessToken(apiUrl); + const res = await fetch(`${apiUrl}/auth/cli/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...cfAccessHeaders(process.env, apiUrl) }, body: JSON.stringify({ @@ -315,53 +293,35 @@ function page(title: string, message: string): string { }); } -export function openBrowser( - url: string, - execFile: typeof import('node:child_process').execFile, -): void { +export function openBrowser(url: string, run: typeof execFile = execFile): void { // Launch via argv (execFile), never a shell: the URL is passed as a single // argument so shell metacharacters in it can't be interpreted as commands. const ignore = (): void => {}; if (process.platform === 'win32') { // Avoid cmd.exe interpreting URL query separators such as & as shell commands. - execFile('rundll32.exe', ['url.dll,FileProtocolHandler', url], ignore); + run('rundll32.exe', ['url.dll,FileProtocolHandler', url], ignore); return; } if (process.platform === 'darwin') { - execFile('open', [url], ignore); + run('open', [url], ignore); return; } // Linux — including WSL2 where `xdg-open` typically has no usable handler. // Try `wslview` (wslu), then fall back to the Windows URL handler. if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { - execFile('wslview', [url], (error) => { + run('wslview', [url], (error) => { if (error) { - execFile('rundll32.exe', ['url.dll,FileProtocolHandler', url], ignore); + run('rundll32.exe', ['url.dll,FileProtocolHandler', url], ignore); } }); return; } - execFile('xdg-open', [url], ignore); + run('xdg-open', [url], ignore); } /** - * Start a local HTTP callback server, open the browser to a caller-built - * authorize URL, and wait for the redirect back to `http://localhost:PORT/callback`. - * - * The server validates the `state` it appended to the callback URL against the - * incoming request, then runs the caller's `finalize` hook INSIDE the request - * handler (so the browser only ever sees the success page after the underlying - * exchange/link actually succeeded). On finalize success it renders the success - * HTML and resolves with finalize's return value; on finalize failure it - * renders the same "could not complete" failure page the login flow uses and - * rejects with the thrown error. The server is always closed before settling. - * - * `onUrl` is invoked with the authorize URL once the local server is listening, - * so callers can show the URL to the user as a manual fallback when the browser - * fails to open (common on WSL2, headless SSH, Docker, etc.). To match the - * login flow it fires immediately before the browser is opened. - * - * `execFile` is injectable so tests can avoid launching a real browser. + * Serve `http://localhost:PORT/callback`, open the browser at the caller's + * authorize URL, and settle with `finalize`'s result. The server closes before settling. */ export async function awaitBrowserCallback(options: { /** Caller-generated; appended to the callback URL and validated on the incoming request. */ @@ -369,34 +329,26 @@ export async function awaitBrowserCallback(options: { /** Given `http://localhost:PORT/callback?state=…`, return the URL to open. */ buildAuthUrl: (callback: URL) => Promise | string; /** - * Runs INSIDE the request handler, after state/error/validate checks pass and - * BEFORE the success page is rendered. Resolve to settle the outer promise - * with this value (success page shown); throw to render the failure page and - * reject the outer promise with the thrown error. + * Runs inside the request handler, so the browser sees the success page only + * after it resolves; a throw renders the failure page and rejects. */ finalize: (params: URLSearchParams) => Promise; /** Name the completed action in the browser. Defaults to sign-in. */ successTitle?: string; /** Map an `?error=…` query to a user message (return null to ignore). */ describeError?: (params: URLSearchParams) => string | null; - /** - * Reject an otherwise-valid callback request (renders a 400 page) WITHOUT - * settling, so the server keeps listening for a subsequent valid callback. - * Used by login to reject `?token=` and missing-`code` requests. Return a - * message to reject, or null to accept. - */ + /** Return a message to answer 400 and keep listening for a valid callback, or null to accept. */ validate?: (params: URLSearchParams) => string | null; - /** Optional, invoked with the authorize URL before the browser is opened. */ + /** Invoked with the authorize URL before the browser opens, for a manual fallback. */ onUrl?: (url: string) => void; /** Default 5 minutes. */ timeoutMs?: number; - /** Injectable for tests; defaults to the real `node:child_process` `execFile`. */ - execFile?: typeof import('node:child_process').execFile; + /** Injectable for tests; defaults to `node:child_process` `execFile`. */ + execFile?: typeof execFile; }): Promise { const http = await import('node:http'); const { state, buildAuthUrl, finalize, describeError, validate, onUrl } = options; const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000; - const execFile = options.execFile ?? (await import('node:child_process')).execFile; return new Promise((resolve, reject) => { let settled = false; @@ -441,9 +393,6 @@ export async function awaitBrowserCallback(options: { return; } - // Reject-but-keep-listening: invalid request shapes (e.g. a stray - // `?token=`, or a redirect that arrived without a code). The server - // stays open so the genuine callback can still complete. const invalidMessage = validate?.(url.searchParams); if (invalidMessage) { res.writeHead(400, { 'Content-Type': 'text/html' }); @@ -451,10 +400,7 @@ export async function awaitBrowserCallback(options: { return; } - // Finalize INSIDE the request path so the browser only sees the success - // page once the underlying exchange/link succeeds. A finalize failure - // (expired code, API error, 409 conflict, etc.) renders the failure page - // and rejects. Guard against the timeout racing the in-flight finalize. + // The timeout may settle while finalize is in flight. let result: T; try { result = await finalize(url.searchParams); @@ -504,7 +450,7 @@ export async function awaitBrowserCallback(options: { } onUrl?.(authUrl); - openBrowser(authUrl, execFile); + openBrowser(authUrl, options.execFile); }); const timeout = setTimeout(() => { @@ -514,43 +460,22 @@ export async function awaitBrowserCallback(options: { }); } -/** - * Start a local HTTP server, open the browser to the platform login page, - * and wait for the OAuth callback with a short-lived code. The CLI exchanges - * the code itself so the localhost callback never accepts bearer tokens. - * - * `onUrl` is invoked with the login URL once the local server is listening, - * so callers can show the URL to the user as a manual fallback when the - * browser fails to open (common on WSL2, headless SSH, Docker, etc.). - */ -export async function runLogin( - root: string = process.cwd(), - provider = 'github', - onUrl?: (url: string) => void, -): Promise { - return runPlatformLogin(getApiUrl(root), provider, onUrl); -} - +/** The CLI exchanges the code itself, so the localhost callback never accepts bearer tokens. */ export async function runPlatformLogin( origin: string, provider = 'github', onUrl?: (url: string) => void, ): Promise { - const { execFile } = await import('node:child_process'); - const apiUrl = normalizePlatformApiUrl(origin); const state = randomBytes(32).toString('hex'); const codeVerifier = createCodeVerifier(); const codeChallenge = createCodeChallenge(codeVerifier); - // The exchange runs INSIDE the request handler (via finalize), so an exchange - // failure renders the failure page. return awaitBrowserCallback({ state, - execFile, onUrl, buildAuthUrl: (callback) => { - const loginUrl = new URL('auth/login', apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`); + const loginUrl = new URL(`${apiUrl}/auth/login`); loginUrl.searchParams.set('redirect', callback.href); loginUrl.searchParams.set('provider', provider); loginUrl.searchParams.set('code_challenge', codeChallenge); @@ -561,8 +486,6 @@ export async function runPlatformLogin( p.get('error') === 'signup_restricted' ? 'Signup restricted: your email is not on the approved list for this environment.' : null, - // Keep listening but reject these request shapes: a stray `?token=` is never - // honored, and a callback without a `code` is ignored until the real one arrives. validate: (p) => { if (p.has('token')) { return 'Invalid login callback. Please try again.'; diff --git a/packages/void/src/cli/cf-access.ts b/packages/void/src/cli/cf-access.ts index b9dfb0e29..fa0769eea 100644 --- a/packages/void/src/cli/cf-access.ts +++ b/packages/void/src/cli/cf-access.ts @@ -131,11 +131,6 @@ export function ensurePlatformAccessToken( writeKeyringPassword(ACCESS_KEYRING_SERVICE, account, token); } -/** Restore only credentials scoped to this explicitly selected platform origin. */ -export function ensureCloudflaredToken(apiUrl: string): void { - ensurePlatformAccessToken(apiUrl); -} - function runCloudflared( args: Array, opts?: { inherit?: boolean; timeoutMs?: number }, diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index affbae116..7625e9a82 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -3,11 +3,7 @@ import { join } from 'pathslash'; import { cfAccessHeaders } from '../shared/cf-access.ts'; import { getPackageDir } from './agents.ts'; import { getApiUrl } from './auth.ts'; -import { - ensureCloudflaredToken, - ensurePlatformAccessToken, - isCloudflareAccessChallenge, -} from './cf-access.ts'; +import { ensurePlatformAccessToken, isCloudflareAccessChallenge } from './cf-access.ts'; import type { CliLogger } from './log-file.ts'; import { readPlatformDiscoveryResponse } from './platform-discovery.ts'; import { @@ -19,20 +15,8 @@ import { import { normalizePlatformApiUrl } from './platform-registry.ts'; import type { DeployAssetUploadRequest, DeployEvent, DeployUploadPayload } from './types.ts'; -/** - * Current CLI version. Sent as `X-Void-CLI-Version` on every platform - * request so the platform's CLI-version gate can refuse stale CLIs - * cleanly (HTTP 426 + `cli_outdated`) instead of letting a wire - * mismatch surface as an opaque parse error. - * - * Resolved once per process. Returns `null` when package.json can't be - * read or has no `version` string — the ctor then OMITS the header - * entirely. We deliberately do NOT send a placeholder string like - * "unknown": the platform treats present-but-unparseable headers as a - * stale-CLI signal and would 426 the request, bricking the CLI on any - * packaging error. Missing-header is the documented pass-through path. - */ -export function readCliVersion(): string | null { +// Null omits `X-Void-CLI-Version`: the platform answers an unparseable version with 426. +function readCliVersion(): string | null { try { const pkgPath = join(getPackageDir(), 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown }; @@ -307,12 +291,8 @@ export type CreateProjectResponse = { warnings?: Array; zero_trust?: ProjectZeroTrustState; /** - * Present exactly when the platform scheduled the project owner's own - * address to be registered as an outbound email destination. The - * registration runs after the response is sent, so this says nothing about - * its outcome — Cloudflare may mail a verification link, or adopt an - * address the owner already verified. `void email destinations` shows the - * resulting state. + * Set when the platform scheduled the owner's address as an email destination. + * Registration runs after the response, so this says nothing about its outcome. */ owner_email_destination?: { address: string; @@ -436,19 +416,9 @@ export type EmailDestinationResponse = { status: 'pending' | 'verified' | 'failed'; verified_at: string | null; created_at: string; - /** - * Set when the platform (not Cloudflare) mailed the confirmation link — - * on the first add AND on every repeat add while the row is pending, since - * a repeat POST is how that link gets re-sent. - */ + /** Set when the platform, not Cloudflare, mailed the confirmation link; a repeat add re-sends it. */ platformVerificationPending?: boolean; - /** - * `false` when the call mailed nothing and the row waits on a Cloudflare - * link sent EARLIER: a repeat add of a CF-pending address, or a first add - * (201) of an address Cloudflare already held pending — the owner's own, - * from another project or a hand add. CF refused the create and mails its - * link on create only, so a bare 201 must not read as "a link went out". - */ + /** `false` when this call mailed nothing because an earlier Cloudflare link is still pending. */ verificationSent?: boolean; }; @@ -514,9 +484,6 @@ export type EmailOperationResponse = { error?: string; }; -export type EmailDomainStatusResponse = EmailDomainResponse; -export type EmailDomainSyncResponse = EmailDomainResponse; - export type RemoveEmailDomainResponse = { operationId: string; operationStatus?: EmailOperationResponse['status']; @@ -578,48 +545,11 @@ export type GithubConnection = { updated_at: string; }; -/** - * Asset declaration shape for `POST /:id/deploy/upload-urls`. Each entry - * carries both hashes the platform needs: - * - `blake3` is the storage key (`assets/${projectId}/${blake3}`) - * - `md5` is the wire-level upload-integrity primitive — the platform - * bakes it into the presigned PUT URL as a SIGNED `Content-Md5` - * header so R2 enforces integrity at write time. - */ -export type UploadUrlAssetRequest = DeployAssetUploadRequest; - -/** - * Manifest-only (direct-to-R2) deploy payload. Sent as JSON to - * `POST /:id/deploy` with `Content-Type: application/json`. Mirrors the - * `handleManifestOnlyDeploy` server contract documented in design doc 0068. - * - * Asset bytes are NOT carried in this payload — they were uploaded directly - * to R2 via presigned PUT URLs before this call. The server verifies each - * declared asset key exists in R2 (parallel HEAD) before flipping routing. - */ export type ManifestOnlyDeployPayload = DeployUploadPayload; -/** - * Retry contract for the deploy POST's transport phase. - * - * - Only `fetch` rejections (`TypeError` — no HTTP response) are retried. - * A Response of any status means the server answered; surface it - * verbatim rather than replay a request it may have accepted. - * - A headers timeout is retried too. Every attempt sends the same - * idempotency key, so once an earlier request has created its deployment, - * a receipt-aware platform answers the retry with that deployment's - * receipt and the CLI follows it to the real result. - * - A rejected fetch does NOT prove the request never arrived — the - * connection may have dropped after delivery. Arbitration is left to - * the server's precheck, the only atomic (project-locked) verdict: if - * an earlier attempt or a concurrent deploy is still in flight, the - * replay is rejected with 409 and surfaced as a deploy conflict - * naming the deployment id. Receipt-aware platforms additionally bind - * the stable invocation key to the accepted artifact, so an accepted - * request can be recovered without creating another execution. Older - * platforms retain their concurrency guard. No client-side deployment - * lookup may authorize replay or identify an unrelated conflicting row. - */ +// Only fetch rejections (TypeError, including a headers timeout) are retried; any +// HTTP response is final. Every attempt sends the same idempotency key, so the +// platform answers a replay with the earlier deployment's receipt or a 409. const DEPLOY_POST_MAX_RETRIES = 3; const DEPLOY_POST_BACKOFF_MS = [1000, 5000, 10000]; /** @@ -700,27 +630,21 @@ export class PlatformClient { */ constructor( token: string, - options?: - | string - | { apiUrl?: string; root?: string; cliLog?: CliLogger; checkPlatformVersion?: boolean }, + options?: { + apiUrl?: string; + root?: string; + cliLog?: CliLogger; + checkPlatformVersion?: boolean; + }, ) { - if (typeof options === 'string') { - this.baseUrl = normalizePlatformApiUrl(options); - this.checkPlatformVersion = true; - } else { - this.baseUrl = normalizePlatformApiUrl(options?.apiUrl ?? getApiUrl(options?.root)); - this.cliLog = options?.cliLog; - this.checkPlatformVersion = options?.checkPlatformVersion !== false; - } + this.baseUrl = normalizePlatformApiUrl(options?.apiUrl ?? getApiUrl(options?.root)); + this.cliLog = options?.cliLog; + this.checkPlatformVersion = options?.checkPlatformVersion !== false; this.apiUrl = this.baseUrl; - ensureCloudflaredToken(this.baseUrl); + ensurePlatformAccessToken(this.baseUrl); this.accessHeaders = cfAccessHeaders(process.env, this.baseUrl); this.authHeaders = { Authorization: `Bearer ${token}`, - // Conditional spread: omit the header entirely if package.json was - // unreadable. The platform's gate treats missing as pass-through; - // sending a placeholder like "unknown" would trip the fail-closed - // 426 path and brick the CLI permanently. ...(VOID_CLI_VERSION !== null && { 'X-Void-CLI-Version': VOID_CLI_VERSION }), }; } @@ -783,7 +707,7 @@ export class PlatformClient { // so keep checking for a newer environment value when no service pair was // captured and let current credentials override the fallback below. if (!this.accessHeaders['CF-Access-Client-Id']) { - ensureCloudflaredToken(this.baseUrl); + ensurePlatformAccessToken(this.baseUrl); } const send = () => { const currentAccessHeaders = cfAccessHeaders(process.env, this.baseUrl); @@ -824,18 +748,43 @@ export class PlatformClient { return send().response; } + /** Sends an authenticated request and throws `PlatformApiError` unless it succeeds. */ + private async call( + url: string | URL, + action: string, + options: { method?: string; body?: unknown; permission?: ProjectPermission } = {}, + ): Promise { + const { method, body, permission } = options; + const res = await this.request(url, { + ...(method !== undefined && { method }), + headers: + body === undefined + ? { ...this.authHeaders } + : { 'Content-Type': 'application/json', ...this.authHeaders }, + ...(body !== undefined && { body: JSON.stringify(body) }), + }); + if (!res.ok) { + await throwPlatformApiError(action, res, permission); + } + return res; + } + + private async json( + url: string | URL, + action: string, + options?: { method?: string; body?: unknown; permission?: ProjectPermission }, + ): Promise { + return (await this.call(url, action, options)).json() as Promise; + } + async startIdentityLink( connectionId: string, ): Promise<{ id: string; url: string; expiresAt: string }> { - const response = await this.request(this.baseUrl + '/auth/link', { - method: 'POST', - headers: { ...this.authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify({ connectionId }), - }); - if (!response.ok) { - await throwPlatformApiError('Failed to link login method', response); - } - const result = (await response.json()) as { id: string; url: string; expiresAt: string }; + const result = await this.json<{ id: string; url: string; expiresAt: string }>( + `${this.baseUrl}/auth/link`, + 'Failed to link login method', + { method: 'POST', body: { connectionId } }, + ); const url = new URL(result.url); if ( url.origin !== this.baseUrl || @@ -849,42 +798,16 @@ export class PlatformClient { } async whoami(): Promise<{ id: string; login: string; plan: string }> { - const res = await this.request(`${this.baseUrl}/me`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch user info', res); - } - - return res.json(); + return this.json(`${this.baseUrl}/me`, 'Failed to fetch user info'); } async listProjects(): Promise> { - const res = await this.request(`${this.baseUrl}/projects`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list projects', res); - } - - return res.json(); + return this.json(`${this.baseUrl}/projects`, 'Failed to list projects'); } /** A lightweight project list for interactive linking. */ async listProjectChoices(): Promise> { - const res = await this.request(`${this.baseUrl}/projects/choices`, { - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to list project choices', res); - } - return res.json(); + return this.json(`${this.baseUrl}/projects/choices`, 'Failed to list project choices'); } /** Resolve one known project without loading account-wide deployment and traffic summaries. */ @@ -903,96 +826,80 @@ export class PlatformClient { /** Resolve a slug through the current platform contract. */ async findProjectBySlug(slug: string): Promise { - const url = new URL(`${this.baseUrl}/projects/resolve`); - url.searchParams.set('slug', slug); - const res = await this.request(url.toString(), { headers: { ...this.authHeaders } }); - if (!res.ok) { - await throwPlatformApiError('Failed to resolve project', res); - } - const body = (await res.json()) as { project: ProjectResponse | null }; + const body = await this.json<{ project: ProjectResponse | null }>( + `${this.baseUrl}/projects/resolve?${new URLSearchParams({ slug })}`, + 'Failed to resolve project', + ); return body.project; } async listProjectTokens(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/tokens`, { - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to list project tokens', res, 'owner'); - } - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/tokens`, + 'Failed to list project tokens', + { + permission: 'owner', + }, + ); } async getProjectZeroTrust(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/zero-trust`, { - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to inspect project Zero Trust', res, 'read'); - } - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/zero-trust`, + 'Failed to inspect project Zero Trust', + { permission: 'read' }, + ); } async setProjectZeroTrust( projectId: string, protectedProject: boolean, ): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/zero-trust`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...this.authHeaders }, - body: JSON.stringify({ protected: protectedProject }), - }); - if (!res.ok) { - await throwPlatformApiError('Failed to update project Zero Trust', res, 'settings'); - } - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/zero-trust`, + 'Failed to update project Zero Trust', + { method: 'PUT', body: { protected: protectedProject }, permission: 'settings' }, + ); } async reconcileProjectZeroTrust(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/zero-trust/reconcile`, { - method: 'POST', - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to reconcile project Zero Trust', res, 'settings'); - } - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/zero-trust/reconcile`, + 'Failed to reconcile project Zero Trust', + { method: 'POST', permission: 'settings' }, + ); } async createProjectToken( projectId: string, input: { name?: string; expiresInDays?: number }, ): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/tokens`, { - method: 'POST', - headers: { ...this.authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - if (!res.ok) { - await throwPlatformApiError('Failed to create project token', res, 'owner'); - } - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/tokens`, + 'Failed to create project token', + { + method: 'POST', + body: input, + permission: 'owner', + }, + ); } async listProjectMembers(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/members`, { - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to list project members', res, 'read'); - } - const body = (await res.json()) as ProjectMemberListResponse; + const body = await this.json( + `${this.baseUrl}/projects/${projectId}/members`, + 'Failed to list project members', + { permission: 'read' }, + ); return { members: body.members, ...(body.warnings ? { warnings: body.warnings } : {}) }; } async listProjectInvitations(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/invitations`, { - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to list project invitations', res, 'members'); - } - const body = (await res.json()) as { invitations: Array }; + const body = await this.json<{ invitations: Array }>( + `${this.baseUrl}/projects/${projectId}/invitations`, + 'Failed to list project invitations', + { permission: 'members' }, + ); return body.invitations; } @@ -1005,15 +912,11 @@ export class PlatformClient { refreshed: boolean; delivery: ProjectInvitationDelivery; }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/invitations`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.authHeaders }, - body: JSON.stringify({ email, role }), - }); - if (!res.ok) { - await throwPlatformApiError('Failed to invite project member', res, 'members'); - } - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/invitations`, + 'Failed to invite project member', + { method: 'POST', body: { email, role }, permission: 'members' }, + ); } async renewProjectToken( @@ -1021,18 +924,11 @@ export class PlatformClient { credentialId: string, expiresInDays?: number, ): Promise { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/tokens/${encodeURIComponent(credentialId)}/renew`, - { - method: 'POST', - headers: { ...this.authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(expiresInDays ? { expiresInDays } : {}), - }, + 'Failed to renew project token', + { method: 'POST', body: expiresInDays ? { expiresInDays } : {}, permission: 'owner' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to renew project token', res, 'owner'); - } - return res.json(); } async updateProjectMemberRole( @@ -1040,69 +936,48 @@ export class PlatformClient { userId: string, role: Exclude, ): Promise<{ member: ProjectMemberResponse }> { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/members/${encodeURIComponent(userId)}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...this.authHeaders }, - body: JSON.stringify({ role }), - }, + 'Failed to change project member role', + { method: 'PATCH', body: { role }, permission: 'members' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to change project member role', res, 'members'); - } - return res.json(); } async revokeProjectToken(projectId: string, credentialId: string): Promise<{ ok: boolean }> { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/tokens/${encodeURIComponent(credentialId)}`, - { method: 'DELETE', headers: { ...this.authHeaders } }, + 'Failed to revoke project token', + { method: 'DELETE', permission: 'owner' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to revoke project token', res, 'owner'); - } - return res.json(); } async removeProjectMember(projectId: string, userId: string): Promise { - const res = await this.request( + await this.call( `${this.baseUrl}/projects/${projectId}/members/${encodeURIComponent(userId)}`, - { method: 'DELETE', headers: { ...this.authHeaders } }, + 'Failed to remove project member', + { method: 'DELETE', permission: 'members' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to remove project member', res, 'members'); - } } async revokeProjectInvitation(projectId: string, invitationId: string): Promise { - const res = await this.request( + await this.call( `${this.baseUrl}/projects/${projectId}/invitations/${encodeURIComponent(invitationId)}`, - { method: 'DELETE', headers: { ...this.authHeaders } }, + 'Failed to revoke project invitation', + { method: 'DELETE', permission: 'members' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to revoke project invitation', res, 'members'); - } } async leaveProject(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/leave`, { + await this.call(`${this.baseUrl}/projects/${projectId}/leave`, 'Failed to leave project', { method: 'POST', - headers: { ...this.authHeaders }, }); - if (!res.ok) { - await throwPlatformApiError('Failed to leave project', res); - } } async listPendingProjectInvitations(): Promise> { - const res = await this.request(`${this.baseUrl}/me/project-invitations`, { - headers: { ...this.authHeaders }, - }); - if (!res.ok) { - await throwPlatformApiError('Failed to list pending project invitations', res); - } - const body = (await res.json()) as { invitations: Array }; + const body = await this.json<{ invitations: Array }>( + `${this.baseUrl}/me/project-invitations`, + 'Failed to list pending project invitations', + ); return body.invitations; } @@ -1110,27 +985,21 @@ export class PlatformClient { invitationId: string, action: 'accept' | 'decline', ): Promise { - const res = await this.request( + await this.call( `${this.baseUrl}/project-invitations/${encodeURIComponent(invitationId)}/${action}`, - { method: 'POST', headers: { ...this.authHeaders } }, + `Failed to ${action} project invitation`, + { method: 'POST' }, ); - if (!res.ok) { - await throwPlatformApiError(`Failed to ${action} project invitation`, res); - } } async listDeployments(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/deployments`, { - headers: { - ...this.authHeaders, + return this.json( + `${this.baseUrl}/projects/${projectId}/deployments`, + 'Failed to list deployments', + { + permission: 'read', }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list deployments', res, 'read'); - } - - return res.json(); + ); } async rollback( @@ -1149,58 +1018,29 @@ export class PlatformClient { if (options?.allowDestructive && options.allowDestructive.length > 0) { body.allowDestructive = options.allowDestructive; } - const res = await this.request(`${this.baseUrl}/projects/${projectId}/rollback`, { + return this.json(`${this.baseUrl}/projects/${projectId}/rollback`, 'Rollback failed', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify(body), + body, + permission: 'deploy', }); - - if (!res.ok) { - await throwPlatformApiError('Rollback failed', res, 'deploy'); - } - - return res.json(); } async cancelDeployment( projectId: string, deploymentId: string, ): Promise<{ ok: boolean; deploymentId: string; status: string }> { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/deployments/${deploymentId}/cancel`, - { - method: 'POST', - headers: { - ...this.authHeaders, - }, - }, + 'Cancel deployment failed', + { method: 'POST', permission: 'deploy' }, ); - - if (!res.ok) { - await throwPlatformApiError('Cancel deployment failed', res, 'deploy'); - } - - return res.json(); } async createProject(slug: string): Promise { - const res = await this.request(`${this.baseUrl}/projects`, { + return this.json(`${this.baseUrl}/projects`, 'Failed to create project', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ slug }), + body: { slug }, }); - - if (!res.ok) { - await throwPlatformApiError('Failed to create project', res); - } - - return res.json(); } async preflight( @@ -1243,15 +1083,9 @@ export class PlatformClient { } } - /** - * Request presigned R2 PUT URLs for a batch of assets. Each entry carries - * both `blake3` (storage key) and `md5` (wire-level integrity primitive) - * so the platform can sign a PUT URL that R2 enforces against the bytes - * the CLI is about to upload. See design doc 0068 for the full protocol. - */ async requestUploadUrls( projectId: string, - assets: Array, + assets: Array, ): Promise }>> { const url = `${this.baseUrl}/projects/${projectId}/deploy/upload-urls`; this.cliLog?.info('upload_urls_post', { url, assetCount: assets.length }); @@ -1283,12 +1117,6 @@ export class PlatformClient { return json.urls; } - /** - * Manifest-only (direct-to-R2) deploy. POSTs a JSON body containing the - * deploy manifest, asset manifest (no asset bytes), and any worker module - * bytes (base64-encoded). Streams an NDJSON response that the caller can - * apply with the existing `applyDeployEvent` machinery. - */ async *finalizeDeploy( projectId: string, payload: ManifestOnlyDeployPayload, @@ -1305,10 +1133,6 @@ export class PlatformClient { }); } - /** - * POST + NDJSON-stream-reader used by `finalizeDeploy()`. Captures - * `cf-ray` + `cf-cache-status` for postmortem correlation. - */ private async *streamDeployRequest( url: string, projectId: string, @@ -1317,9 +1141,6 @@ export class PlatformClient { const cliLog = this.cliLog; cliLog?.info('deploy_post', { url }); - // `fetch` reports transport failures as TypeError ('fetch failed') — - // the only retryable case per the DEPLOY_POST_* contract above. - // Anything else (e.g. AbortError) is not transient and fails fast. let res: Response; let attempt = 0; while (true) { @@ -1342,9 +1163,6 @@ export class PlatformClient { } } - // `cf-ray` and `cf-cache-status` are the correlation keys that platform - // engineers need to look up server-side tail logs. Capture them whenever - // the response makes it back. const cfRay = res.headers.get('cf-ray'); const cfCacheStatus = res.headers.get('cf-cache-status'); cliLog?.info('deploy_response', { @@ -1357,10 +1175,7 @@ export class PlatformClient { if (!res.ok) { const body = await res.text(); if (res.status === 409) { - // The precheck's authoritative verdict — a deployment is already - // in flight (possibly the earlier attempt of this same deploy, - // or a concurrent one). Surface it as a structured error event - // naming the deployment rather than a bare HTTP failure. + // A deployment is in flight: possibly an earlier attempt of this deploy. const parsed = parsePlatformErrorBody(body); if (parsed?.deployment) { cliLog?.warn('deploy_conflict', { @@ -1641,52 +1456,24 @@ export class PlatformClient { } async listSecrets(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/secrets`, { - headers: { - ...this.authHeaders, - }, + return this.json(`${this.baseUrl}/projects/${projectId}/secrets`, 'Failed to list secrets', { + permission: 'configure', }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list secrets', res, 'configure'); - } - - return res.json(); } async setSecret(projectId: string, name: string, value: string): Promise { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/secrets/${encodeURIComponent(name)}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ value }), - }, + 'Failed to set secret', + { method: 'PUT', body: { value }, permission: 'configure' }, ); - - if (!res.ok) { - await throwPlatformApiError('Failed to set secret', res, 'configure'); - } - - return res.json(); } async deleteProject(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}`, { + return this.json(`${this.baseUrl}/projects/${projectId}`, 'Failed to delete project', { method: 'DELETE', - headers: { - ...this.authHeaders, - }, + permission: 'owner', }); - - if (!res.ok) { - await throwPlatformApiError('Failed to delete project', res, 'owner'); - } - - return res.json(); } async getMigrationStatus( @@ -1697,17 +1484,7 @@ export class PlatformClient { if (dialect) { url.searchParams.set('dialect', dialect); } - const res = await this.request(url, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to get migration status', res, 'read'); - } - - return res.json(); + return this.json(url, 'Failed to get migration status', { permission: 'read' }); } async applyRemoteMigrations( @@ -1718,217 +1495,108 @@ export class PlatformClient { applied: Array; skipped: Array; error?: string; - migration_protocol?: 'stable' | 'legacy'; - migration_stability?: 'stable' | 'legacy'; - warnings?: Array; }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/migrations/apply`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ migrations }), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to apply remote migrations', res, 'configure'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/migrations/apply`, + 'Failed to apply remote migrations', + { method: 'POST', body: { migrations }, permission: 'configure' }, + ); } async renameMigrations( projectId: string, renames: Array<{ from: string; to: string }>, ): Promise<{ ok: boolean }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/migrations/rename`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ renames }), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to rename remote migrations', res, 'configure'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/migrations/rename`, + 'Failed to rename remote migrations', + { method: 'POST', body: { renames }, permission: 'configure' }, + ); } async addDomain(projectId: string, hostname: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/domains`, { + return this.json(`${this.baseUrl}/projects/${projectId}/domains`, 'Failed to add domain', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ hostname }), + body: { hostname }, + permission: 'settings', }); - - if (!res.ok) { - await throwPlatformApiError('Failed to add domain', res, 'settings'); - } - - return res.json(); } async listDomains(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/domains`, { - headers: { - ...this.authHeaders, - }, + return this.json(`${this.baseUrl}/projects/${projectId}/domains`, 'Failed to list domains', { + permission: 'read', }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list domains', res, 'read'); - } - - return res.json(); - } + } async getDomainStatus(projectId: string, domainId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/domains/${domainId}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to get domain status', res, 'read'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/domains/${domainId}`, + 'Failed to get domain status', + { permission: 'read' }, + ); } async removeDomain(projectId: string, domainId: string): Promise<{ ok: boolean }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/domains/${domainId}`, { - method: 'DELETE', - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to remove domain', res, 'settings'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/domains/${domainId}`, + 'Failed to remove domain', + { method: 'DELETE', permission: 'settings' }, + ); } async deleteSecret(projectId: string, name: string): Promise { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/secrets/${encodeURIComponent(name)}`, - { - method: 'DELETE', - headers: { - ...this.authHeaders, - }, - }, + 'Failed to delete secret', + { method: 'DELETE', permission: 'configure' }, ); - - if (!res.ok) { - await throwPlatformApiError('Failed to delete secret', res, 'configure'); - } - - return res.json(); } async getResources(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/resources`, { - headers: { - ...this.authHeaders, - }, + return this.json(`${this.baseUrl}/projects/${projectId}/resources`, 'Failed to get resources', { + permission: 'read', }); - - if (!res.ok) { - await throwPlatformApiError('Failed to get resources', res, 'read'); - } - - return res.json(); } async createPrerenderToken(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/prerender-token`, { - method: 'POST', - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to create prerender token', res, 'deploy'); - } - - const body = (await res.json()) as { token: string }; + const body = await this.json<{ token: string }>( + `${this.baseUrl}/projects/${projectId}/prerender-token`, + 'Failed to create prerender token', + { method: 'POST', permission: 'deploy' }, + ); return body.token; } async provisionDatabase(projectId: string, connectionString: string): Promise<{ ok: boolean }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/database`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ connectionString }), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to provision database', res, 'configure'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/database`, + 'Failed to provision database', + { method: 'POST', body: { connectionString }, permission: 'configure' }, + ); } async setDatabaseUrl(projectId: string, connectionString: string): Promise<{ ok: boolean }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/database-url`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ connectionString }), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to update database URL', res, 'configure'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/database-url`, + 'Failed to update database URL', + { method: 'PUT', body: { connectionString }, permission: 'configure' }, + ); } async getDatabaseUrl(projectId: string): Promise<{ url: string }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/database-url`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch database URL', res, 'owner'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/database-url`, + 'Failed to fetch database URL', + { permission: 'owner' }, + ); } async purgeCache(projectId: string): Promise<{ purged: number }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/revalidate`, { + return this.json(`${this.baseUrl}/projects/${projectId}/revalidate`, 'Cache purge failed', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ all: true }), + body: { all: true }, + permission: 'deploy', }); - - if (!res.ok) { - await throwPlatformApiError('Cache purge failed', res, 'deploy'); - } - - return res.json(); } async getLogs( @@ -1948,18 +1616,11 @@ export class PlatformClient { if (level && level !== 'all') { params.set('level', level); } - - const res = await this.request(`${this.baseUrl}/projects/${projectId}/logs?${params}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch logs', res, 'read'); - } - - const json = (await res.json()) as { data: Array }; + const json = await this.json<{ data: Array }>( + `${this.baseUrl}/projects/${projectId}/logs?${params}`, + 'Failed to fetch logs', + { permission: 'read' }, + ); return json.data; } @@ -1976,81 +1637,39 @@ export class PlatformClient { if (status && status !== 'all') { params.set('status', status); } - - const res = await this.request(`${this.baseUrl}/projects/${projectId}/requests?${params}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch requests', res, 'read'); - } - - const json = (await res.json()) as { data: Array }; + const json = await this.json<{ data: Array }>( + `${this.baseUrl}/projects/${projectId}/requests?${params}`, + 'Failed to fetch requests', + { permission: 'read' }, + ); return json.data; } async listBuilds(projectId: string, limit = 20): Promise> { const params = new URLSearchParams({ limit: String(limit) }); - const res = await this.request(`${this.baseUrl}/projects/${projectId}/builds?${params}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list builds', res, 'read'); - } - - const json = (await res.json()) as { builds: Array }; + const json = await this.json<{ builds: Array }>( + `${this.baseUrl}/projects/${projectId}/builds?${params}`, + 'Failed to list builds', + { permission: 'read' }, + ); return json.builds; } async getBuildLogs(projectId: string, buildId: string, since = 0): Promise { const params = new URLSearchParams({ since: String(since) }); - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/builds/${buildId}/logs?${params}`, - { - headers: { - ...this.authHeaders, - }, - }, + 'Failed to fetch build logs', + { permission: 'read' }, ); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch build logs', res, 'read'); - } - - return res.json(); } async getGithubInstallUrl(): Promise<{ url: string }> { - const res = await this.request(`${this.baseUrl}/github/install-url`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to get GitHub install URL', res); - } - - return res.json(); + return this.json(`${this.baseUrl}/github/install-url`, 'Failed to get GitHub install URL'); } async listGithubInstallations(): Promise> { - const res = await this.request(`${this.baseUrl}/github/installations`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list GitHub installations', res); - } - - return res.json(); + return this.json(`${this.baseUrl}/github/installations`, 'Failed to list GitHub installations'); } async connectGithub( @@ -2065,28 +1684,16 @@ export class PlatformClient { codeVerifier?: string; }, ): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/github`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to connect GitHub repository', res, 'settings'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/github`, + 'Failed to connect GitHub repository', + { method: 'POST', body, permission: 'settings' }, + ); } /** - * Start the per-repo connect-authorization round-trip used by a non-owner org - * member. The callback redirects back to `redirect` - * with either `?connect_grant=` (proof of per-repo access) or - * `?error=repo_not_accessible`. Mirrors {@link getGithubLinkUrl} but carries - * the installation + repo the grant is scoped to. + * Start the per-repo connect authorization for a non-owner org member. The + * callback carries `?connect_grant=` or `?error=repo_not_accessible`. */ async getGithubConnectAuthorizeUrl( redirect: string, @@ -2101,31 +1708,18 @@ export class PlatformClient { installationId, repoFullName, }); - const res = await this.request(`${this.baseUrl}/github/connect-authorize-url?${params}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to start GitHub connect authorization', res); - } - - return res.json(); + return this.json( + `${this.baseUrl}/github/connect-authorize-url?${params}`, + 'Failed to start GitHub connect authorization', + ); } async getGithubConnection(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/github`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to get GitHub connection', res, 'read'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/github`, + 'Failed to get GitHub connection', + { permission: 'read' }, + ); } async updateGithubConnection( @@ -2136,35 +1730,19 @@ export class PlatformClient { workflowPath?: string; }, ): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/github`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to update GitHub connection', res, 'settings'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/github`, + 'Failed to update GitHub connection', + { method: 'PATCH', body, permission: 'settings' }, + ); } async deleteGithubConnection(projectId: string): Promise<{ ok: boolean }> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/github`, { - method: 'DELETE', - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to disconnect GitHub repository', res, 'settings'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/github`, + 'Failed to disconnect GitHub repository', + { method: 'DELETE', permission: 'settings' }, + ); } async getGithubLinkUrl(redirect: string, codeChallenge: string): Promise<{ url: string }> { @@ -2173,37 +1751,17 @@ export class PlatformClient { code_challenge: codeChallenge, code_challenge_method: 'S256', }); - const res = await this.request(`${this.baseUrl}/github/link-url?${params}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to start GitHub link', res); - } - - return res.json(); + return this.json(`${this.baseUrl}/github/link-url?${params}`, 'Failed to start GitHub link'); } async completeGithubLink( linkCode: string, codeVerifier: string, ): Promise<{ status: 'linked' | 'already_linked' }> { - const res = await this.request(`${this.baseUrl}/github/link/complete`, { + return this.json(`${this.baseUrl}/github/link/complete`, 'Failed to complete GitHub link', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ link_code: linkCode, code_verifier: codeVerifier }), + body: { link_code: linkCode, code_verifier: codeVerifier }, }); - - if (!res.ok) { - await throwPlatformApiError('Failed to complete GitHub link', res); - } - - return res.json(); } async getGithubJoinUrl(redirect: string, codeChallenge: string): Promise<{ url: string }> { @@ -2212,51 +1770,25 @@ export class PlatformClient { code_challenge: codeChallenge, code_challenge_method: 'S256', }); - const res = await this.request(`${this.baseUrl}/github/join-url?${params}`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to start GitHub join', res); - } - - return res.json(); + return this.json(`${this.baseUrl}/github/join-url?${params}`, 'Failed to start GitHub join'); } async completeGithubJoin( joinCode: string, codeVerifier: string, ): Promise<{ status: 'joined'; count: number }> { - const res = await this.request(`${this.baseUrl}/github/join/complete`, { + return this.json(`${this.baseUrl}/github/join/complete`, 'Failed to complete GitHub join', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ join_code: joinCode, code_verifier: codeVerifier }), + body: { join_code: joinCode, code_verifier: codeVerifier }, }); - - if (!res.ok) { - await throwPlatformApiError('Failed to complete GitHub join', res); - } - - return res.json(); } async getEmailUsage(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/usage`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch email usage', res, 'read'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/email/usage`, + 'Failed to fetch email usage', + { permission: 'read' }, + ); } async getEmailLogs(projectId: string, limit?: number): Promise { @@ -2264,115 +1796,63 @@ export class PlatformClient { if (limit !== undefined) { url.searchParams.set('limit', String(limit)); } - - const res = await this.request(url, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to fetch email logs', res, 'read'); - } - - return res.json(); + return this.json(url, 'Failed to fetch email logs', { permission: 'read' }); } async getEmailOperation(projectId: string, operationId: string): Promise { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${encodeURIComponent(projectId)}/email/operations/${encodeURIComponent(operationId)}`, - { headers: { ...this.authHeaders } }, + 'Failed to fetch email operation', + { permission: 'read' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to fetch email operation', res, 'read'); - } - return res.json(); } async listEmailDestinations(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/destinations`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list email destinations', res, 'read'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/email/destinations`, + 'Failed to list email destinations', + { permission: 'read' }, + ); } async addEmailDestination( projectId: string, address: string, ): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/destinations`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ address }), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to add email destination', res, 'settings'); - } - - // 201 registered the address just now; 200 found it already on the - // project's list. The body is the same shape either way, so the status - // is the "created" signal — whether a link went out is the body's - // `verificationSent` / `platformVerificationPending`, not the status. + const res = await this.call( + `${this.baseUrl}/projects/${projectId}/email/destinations`, + 'Failed to add email destination', + { method: 'POST', body: { address }, permission: 'settings' }, + ); + // 201 registered the address now; 200 found it already listed. Whether a + // link went out is the body's `verificationSent`, not the status. const body = (await res.json()) as EmailDestinationResponse; return { ...body, created: res.status === 201 }; } async removeEmailDestination(projectId: string, destinationId: string): Promise<{ ok: boolean }> { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/email/destinations/${destinationId}`, - { - method: 'DELETE', - headers: { - ...this.authHeaders, - }, - }, + 'Failed to remove email destination', + { method: 'DELETE', permission: 'settings' }, ); - - if (!res.ok) { - await throwPlatformApiError('Failed to remove email destination', res, 'settings'); - } - - return res.json(); } /** Installation email settings the CLI reads before starting a credential flow. */ async getEmailSettings(projectId: string): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/settings`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to read email settings', res, 'read'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/email/settings`, + 'Failed to read email settings', + { permission: 'read' }, + ); } async listEmailDomains(projectId: string): Promise> { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/domains`, { - headers: { - ...this.authHeaders, - }, - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to list email domains', res, 'read'); - } - - return res.json(); + return this.json( + `${this.baseUrl}/projects/${projectId}/email/domains`, + 'Failed to list email domains', + { permission: 'read' }, + ); } /** Submit a scoped token once; later operations address the domain without it. */ @@ -2380,41 +1860,27 @@ export class PlatformClient { projectId: string, input: { domain: string; apiToken?: string; attemptId?: string }, ): Promise { - const res = await this.request(`${this.baseUrl}/projects/${projectId}/email/domains`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...this.authHeaders, - }, - body: JSON.stringify({ - domain: input.domain, - ...(input.apiToken !== undefined ? { apiToken: input.apiToken } : {}), - ...(input.attemptId !== undefined ? { attemptId: input.attemptId } : {}), - }), - }); - - if (!res.ok) { - await throwPlatformApiError('Failed to add email domain', res, 'settings'); - } - - return res.json(); - } - - async getEmailDomain(projectId: string, domain: string): Promise { - const res = await this.request( - `${this.baseUrl}/projects/${projectId}/email/domains/${encodeURIComponent(domain)}`, + return this.json( + `${this.baseUrl}/projects/${projectId}/email/domains`, + 'Failed to add email domain', { - headers: { - ...this.authHeaders, + method: 'POST', + body: { + domain: input.domain, + ...(input.apiToken !== undefined ? { apiToken: input.apiToken } : {}), + ...(input.attemptId !== undefined ? { attemptId: input.attemptId } : {}), }, + permission: 'settings', }, ); + } - if (!res.ok) { - await throwPlatformApiError('Failed to fetch email domain status', res, 'read'); - } - - return res.json(); + async getEmailDomain(projectId: string, domain: string): Promise { + return this.json( + `${this.baseUrl}/projects/${projectId}/email/domains/${encodeURIComponent(domain)}`, + 'Failed to fetch email domain status', + { permission: 'read' }, + ); } /** Rotates the shared zone connection secret. */ @@ -2423,54 +1889,27 @@ export class PlatformClient { domain: string, attemptId: string, ): Promise { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/email/domains/${encodeURIComponent(domain)}/rotate-secret`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...this.authHeaders }, - body: JSON.stringify({ attemptId }), - }, + 'Failed to rotate email connection secret', + { method: 'POST', body: { attemptId }, permission: 'settings' }, ); - if (!res.ok) { - await throwPlatformApiError('Failed to rotate email connection secret', res, 'settings'); - } - return res.json(); } - async syncEmailDomain(projectId: string, domain: string): Promise { - const res = await this.request( + async syncEmailDomain(projectId: string, domain: string): Promise { + return this.json( `${this.baseUrl}/projects/${projectId}/email/domains/${encodeURIComponent(domain)}/sync`, - { - method: 'POST', - headers: { - ...this.authHeaders, - }, - }, + 'Failed to sync email domain', + { method: 'POST', permission: 'settings' }, ); - - if (!res.ok) { - await throwPlatformApiError('Failed to sync email domain', res, 'settings'); - } - - return res.json(); } async removeEmailDomain(projectId: string, domain: string): Promise { - const res = await this.request( + return this.json( `${this.baseUrl}/projects/${projectId}/email/domains/${encodeURIComponent(domain)}`, - { - method: 'DELETE', - headers: { - ...this.authHeaders, - }, - }, + 'Failed to remove email domain', + { method: 'DELETE', permission: 'settings' }, ); - - if (!res.ok) { - await throwPlatformApiError('Failed to remove email domain', res, 'settings'); - } - - return res.json(); } } diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 3cee30a1a..8d1b934c3 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -1262,9 +1262,6 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole log.step(name); } } - for (const warning of result.warnings ?? []) { - log.warn(warning); - } if (result.applied.length > 0) { printSuccess( `Applied ${formatCount(result.applied.length, 'migration')} to remote database.`, diff --git a/packages/void/src/cli/github-oidc.ts b/packages/void/src/cli/github-oidc.ts index b4a105d1d..36c8b3af1 100644 --- a/packages/void/src/cli/github-oidc.ts +++ b/packages/void/src/cli/github-oidc.ts @@ -1,11 +1,5 @@ /** - * GitHub Actions OIDC → Void deploy-token exchange. - * - * When `void deploy` runs inside GitHub Actions with no explicit `VOID_TOKEN`, - * the CLI mints a short-lived GitHub OIDC token (audience `void`) and exchanges - * it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped - * Void deploy token. Doing the exchange in-process lets the scaffolded workflow - * collapse to a single `void deploy` step — no curl/jq token-exchange step. + * GitHub Actions OIDC → Void deploy-token exchange for `void deploy` without `VOID_TOKEN`. * * Server contract (platform `POST /auth/github-oidc` handler): * request { oidcToken: string, project: string } (project = slug) @@ -13,15 +7,10 @@ */ import { cfAccessHeaders } from '../shared/cf-access.ts'; -import { ensureCloudflaredToken } from './cf-access.ts'; +import { ensurePlatformAccessToken } from './cf-access.ts'; const OIDC_AUDIENCE = 'void'; -/** - * True when the process is running inside GitHub Actions with the OIDC token - * request endpoint available — i.e. the workflow granted - * `permissions: id-token: write`, so GitHub injected the request URL + token. - */ export function isGithubActionsOidcAvailable(env: NodeJS.ProcessEnv = process.env): boolean { return ( env.GITHUB_ACTIONS === 'true' && @@ -32,19 +21,6 @@ export function isGithubActionsOidcAvailable(env: NodeJS.ProcessEnv = process.en ); } -/** - * Mint a GitHub Actions OIDC token (audience `void`) and exchange it for a - * short-lived, project-scoped Void deploy token. - * - * Two hops: - * 1. GET $ACTIONS_ID_TOKEN_REQUEST_URL&audience=void (Bearer request token) - * → `.value` is the OIDC JWT. - * 2. POST $apiUrl/auth/github-oidc { oidcToken, project } - * → `.token` is the Void deploy token. - * - * Throws a clear, actionable error (including the HTTP status and server error - * body when available) on any non-OK response or missing field. - */ export async function exchangeGithubOidcForDeployToken( apiUrl: string, project: string, @@ -62,10 +38,7 @@ export async function exchangeGithubOidcForDeployToken( return exchangeOidcTokenAtApi(apiUrl, project, oidcToken); } -/** - * GET the GitHub Actions OIDC token for audience `void`. GitHub's request URL - * already carries a query string, so `audience` is appended alongside it. - */ +/** GitHub's request URL already has a query string, so `audience` is added to it. */ async function requestGithubOidcToken(requestUrl: string, requestToken: string): Promise { const url = new URL(requestUrl); url.searchParams.set('audience', OIDC_AUDIENCE); @@ -108,16 +81,8 @@ async function exchangeOidcTokenAtApi( project: string, oidcToken: string, ): Promise { - const base = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; - const url = `${base}/auth/github-oidc`; - - // This POST hits the Void API (which may be the Access-gated staging host) - // before any PlatformClient exists, so it must carry CF Access credentials - // itself — exactly like every other CLI → Void-API call (PlatformClient - // ctor, exchangeLoginCode). `ensureCloudflaredToken` no-ops for prod/ungated - // hosts and when the service-token pair or CF_ACCESS_TOKEN is already set; - // `cfAccessHeaders` returns {} when no CF Access env is present (prod path). - ensureCloudflaredToken(apiUrl); + const url = `${apiUrl}/auth/github-oidc`; + ensurePlatformAccessToken(apiUrl); let res: Response; try { @@ -153,11 +118,7 @@ async function exchangeOidcTokenAtApi( return token; } -/** - * Emit a GitHub Actions `::add-mask::` workflow command so `value` is redacted - * from workflow logs. No-op when not running inside GitHub Actions. GitHub reads - * workflow commands from stdout. - */ +/** Redact `value` from GitHub Actions logs; GitHub reads workflow commands from stdout. */ export function maskGithubActionsSecret(value: string, env: NodeJS.ProcessEnv = process.env): void { if (env.GITHUB_ACTIONS !== 'true' || !value) { return; diff --git a/packages/void/src/cli/login.ts b/packages/void/src/cli/login.ts index 80669d68e..55859aa8a 100644 --- a/packages/void/src/cli/login.ts +++ b/packages/void/src/cli/login.ts @@ -5,10 +5,8 @@ import { getApiUrl, getTokenSource, removeToken, - runLogin, runPlatformLogin, savePlatformToken, - saveToken, } from './auth.ts'; import { isExpiredTokenError, PlatformClient } from './client.ts'; import { printSuccess, createSpinner, pc } from './output.ts'; @@ -20,19 +18,19 @@ export type LoginPlatform = { }; export async function selectPlatformLoginMethod( - platform?: LoginPlatform, + platform: LoginPlatform, requestedId?: string, ): Promise<{ id: string; label: string }> { - const providers = platform?.authenticationProviders ?? ['github', 'google']; + const providers = platform.authenticationProviders; const methods = - platform?.authentication?.methods ?? + platform.authentication?.methods ?? providers.map((id) => ({ id, label: id === 'github' ? 'GitHub' : id === 'google' ? 'Google' : id, })); if (providers.length === 0) { throw new Error( - platform?.authentication?.setupPending + platform.authentication?.setupPending ? 'auth: The platform administrator must finish initial setup.' : 'auth: This platform has no login providers.', ); @@ -66,11 +64,9 @@ export async function promptForLoginToken( root: string = process.cwd(), platform?: LoginPlatform, ): Promise { + const apiUrl = platform?.apiUrl ?? getApiUrl(root); const discovery = - platform ?? - (await import('./platform-cmd.ts').then(({ discoverPlatform }) => - discoverPlatform(getApiUrl(root)), - )); + platform ?? (await (await import('./platform-cmd.ts')).discoverPlatform(apiUrl)); const { id: provider, label: providerName } = await selectPlatformLoginMethod(discovery); const s = createSpinner(); s.start(`Opening browser for ${providerName} login...`); @@ -81,14 +77,8 @@ export async function promptForLoginToken( `Waiting for ${providerName} login. If your browser didn't open, visit:\n${pc.blue(url)}`, ); }; - const token = platform - ? await runPlatformLogin(platform.apiUrl, provider, onUrl) - : await runLogin(root, provider, onUrl); - if (platform) { - savePlatformToken(token, platform.apiUrl); - } else { - saveToken(token, root); - } + const token = await runPlatformLogin(apiUrl, provider, onUrl); + savePlatformToken(token, apiUrl); s.stop('Logged in!'); return token; } catch (err) { diff --git a/packages/void/src/cli/operator-client.ts b/packages/void/src/cli/operator-client.ts index 1154e7be1..0739ec70a 100644 --- a/packages/void/src/cli/operator-client.ts +++ b/packages/void/src/cli/operator-client.ts @@ -1,5 +1,5 @@ import { cfAccessHeaders } from '../shared/cf-access.ts'; -import { ensureCloudflaredToken } from './cf-access.ts'; +import { ensurePlatformAccessToken } from './cf-access.ts'; import { normalizePlatformApiUrl } from './platform-registry.ts'; export const OPERATOR_READ_TIMEOUT_MS: number = 30_000; @@ -67,7 +67,7 @@ export class OperatorClient { ); } const url = new URL(`${this.origin}/operator/v1${path}`); - ensureCloudflaredToken(this.origin); + ensurePlatformAccessToken(this.origin); for (const [name, value] of Object.entries(options.query ?? {})) { url.searchParams.set(name, value); } diff --git a/packages/void/src/cli/platform-cmd.ts b/packages/void/src/cli/platform-cmd.ts index ae832fc54..326c516c2 100644 --- a/packages/void/src/cli/platform-cmd.ts +++ b/packages/void/src/cli/platform-cmd.ts @@ -1,11 +1,7 @@ import type { PlatformDiscoveryDocument, PlatformInstallationManifest } from '@void/platform'; import { isCancel, log, select, text } from '@voidzero-dev/vite-plus-prompts'; import { cfAccessHeaders } from '../shared/cf-access.ts'; -import { - ensureCloudflaredToken, - ensurePlatformAccessToken, - isCloudflareAccessChallenge, -} from './cf-access.ts'; +import { ensurePlatformAccessToken, isCloudflareAccessChallenge } from './cf-access.ts'; import { printSuccess, pc } from './output.ts'; import type { PlatformArgs } from './platform-args.ts'; import { PlatformDisabledError, readPlatformDiscoveryResponse } from './platform-discovery.ts'; @@ -33,7 +29,7 @@ export async function discoverPlatform( fetcher: typeof fetch = fetch, ): Promise { const apiUrl = normalizePlatformInputUrl(input); - ensureCloudflaredToken(apiUrl); + ensurePlatformAccessToken(apiUrl); const request = () => fetcher(`${apiUrl}/.well-known/void-platform`, { headers: { Accept: 'application/json', ...cfAccessHeaders(process.env, apiUrl) }, diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index dd7f386fd..ba1481b84 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -85,7 +85,6 @@ import { platformSetupPages, platformSetupFieldHelp, } from './platform-setup.ts'; -import { passwordWithPlaceholder } from './prompt-password.ts'; export function resolvePlatformInstallDatabaseUrl( checkpoint: Record | null, @@ -1494,9 +1493,7 @@ async function promptSecret( validate: (input: string | undefined) => (input ? validate?.(input) : 'A value is required.'), }; const value = masked - ? placeholder - ? await passwordWithPlaceholder({ ...options, placeholder }) - : await password(options) + ? await password({ ...options, placeholder }) : await text({ ...options, placeholder }); if (isCancel(value) || typeof value === 'symbol') { throw new Error('platform: Cancelled.'); diff --git a/packages/void/src/cli/prompt-password.ts b/packages/void/src/cli/prompt-password.ts deleted file mode 100644 index 1f42dc9cb..000000000 --- a/packages/void/src/cli/prompt-password.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - password, - type CANCEL_SYMBOL, - type PasswordOptions, -} from '@voidzero-dev/vite-plus-prompts'; - -/** A visual example for an empty secret field; never a default value or unmasked input. */ -export function passwordWithPlaceholder( - options: PasswordOptions & { placeholder: string }, -): Promise { - return password(options); -} diff --git a/packages/void/src/plugin-ai-setup.ts b/packages/void/src/plugin-ai-setup.ts index ad2f1c792..a229c64a6 100644 --- a/packages/void/src/plugin-ai-setup.ts +++ b/packages/void/src/plugin-ai-setup.ts @@ -98,7 +98,7 @@ export async function runAiSetupPrompt( const { attachSelectedPlatform, writeProjectConfig } = await import('./cli/project.ts'); const { inferDefaultSlug } = await import('./cli/resolve-project.ts'); - const client = new PlatformClient(token, apiUrl); + const client = new PlatformClient(token, { apiUrl }); let projects: Array; try { diff --git a/packages/void/test/unit/auth-link.test.ts b/packages/void/test/unit/auth-link.test.ts index d1b6b70bd..8cc82288c 100644 --- a/packages/void/test/unit/auth-link.test.ts +++ b/packages/void/test/unit/auth-link.test.ts @@ -54,10 +54,7 @@ describe('ordinary account linking CLI', () => { expect.any(Function), ); expect(state.link).toHaveBeenCalledWith('company'); - expect(state.open).toHaveBeenCalledWith( - 'https://api.example.com/auth/link?id=proof', - expect.any(Function), - ); + expect(state.open).toHaveBeenCalledWith('https://api.example.com/auth/link?id=proof'); }); it('refuses linking or saving a login into a different account', async () => { state.who diff --git a/packages/void/test/unit/auth-login.test.ts b/packages/void/test/unit/auth-login.test.ts index 62c81e39c..956d0b5da 100644 --- a/packages/void/test/unit/auth-login.test.ts +++ b/packages/void/test/unit/auth-login.test.ts @@ -13,7 +13,7 @@ import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; let registry: string; import { createHash } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { runLogin, runPlatformLogin } from '../../src/cli/auth.ts'; +import { runPlatformLogin } from '../../src/cli/auth.ts'; const mockExecFile = vi.fn(); const exchangeRequests: Array<{ @@ -88,7 +88,7 @@ afterEach(() => { delete process.env.VOID_ACCESS_ORIGIN; }); -describe('runLogin', () => { +describe('runPlatformLogin', () => { it('keeps explicit platform login and code exchange on the selected origin', async () => { let ready!: (url: string) => void; const urlReady = new Promise((resolve) => { @@ -109,7 +109,7 @@ describe('runLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runLogin('/project', 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); const loginUrl = new URL(await loginUrlPromise); const redirect = loginUrl.searchParams.get('redirect'); @@ -167,7 +167,7 @@ describe('runLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runLogin('/project', 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); const loginUrl = new URL(await loginUrlPromise); const redirect = loginUrl.searchParams.get('redirect'); @@ -205,7 +205,7 @@ describe('runLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runLogin('/project', 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); // Attach the rejection assertion up front so the exchange failure doesn't // surface as an unhandled rejection. const rejection = expect(loginPromise).rejects.toThrow(/Failed to exchange login code/); @@ -238,7 +238,7 @@ describe('runLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runLogin('/project', 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); const loginUrl = new URL(await loginUrlPromise); const redirect = loginUrl.searchParams.get('redirect'); expect(redirect).toBeTruthy(); diff --git a/packages/void/test/unit/auth-token-storage.test.ts b/packages/void/test/unit/auth-token-storage.test.ts index 40e30bc02..01b7ad893 100644 --- a/packages/void/test/unit/auth-token-storage.test.ts +++ b/packages/void/test/unit/auth-token-storage.test.ts @@ -1,7 +1,14 @@ -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { savePlatformToken } from '../../src/cli/auth.ts'; import { platformCredentialAccount } from '../../src/cli/platform-registry.ts'; +const keyringWrite = vi.hoisted(() => vi.fn(() => true)); +vi.mock('../../src/cli/keyring.ts', () => ({ + readKeyringPassword: () => null, + writeKeyringPassword: keyringWrite, + deleteKeyringPassword: () => false, +})); + afterEach(() => { delete process.env.VOID_API_URL; }); @@ -9,22 +16,19 @@ afterEach(() => { describe('auth token storage', () => { it('stores explicit connection credentials under that origin despite an environment override', () => { process.env.VOID_API_URL = 'https://other.example.com'; - let account: string | undefined; - savePlatformToken('secret-token', 'https://platform.example.com', { - keyringSet: (value) => { - account = value; - return true; - }, - }); - expect(account).toBe(platformCredentialAccount('https://platform.example.com')); + savePlatformToken('secret-token', 'https://platform.example.com'); + expect(keyringWrite).toHaveBeenCalledWith( + 'dev.void.cli', + platformCredentialAccount('https://platform.example.com'), + 'secret-token', + ); }); it('refuses to fall back to a plaintext config file when the keychain is unavailable', () => { process.env.VOID_API_URL = 'https://platform.example.com'; + keyringWrite.mockReturnValueOnce(false); - expect(() => - savePlatformToken('secret-token', 'https://platform.example.com', { - keyringSet: () => false, - }), - ).toThrow('Refusing to store your platform token in plaintext'); + expect(() => savePlatformToken('secret-token', 'https://platform.example.com')).toThrow( + 'Refusing to store your platform token in plaintext', + ); }); }); diff --git a/packages/void/test/unit/cf-access.test.ts b/packages/void/test/unit/cf-access.test.ts index 39997f5d1..4360f57c1 100644 --- a/packages/void/test/unit/cf-access.test.ts +++ b/packages/void/test/unit/cf-access.test.ts @@ -345,7 +345,7 @@ describe('origin-scoped self-hosted Access login', () => { }); }); -describe('ensureCloudflaredToken', () => { +describe('ensurePlatformAccessToken defaults', () => { it.each([ 'https://api.void.cloud', 'https://api.staging.void.cloud', @@ -357,8 +357,8 @@ describe('ensureCloudflaredToken', () => { vi.stubEnv('CF_ACCESS_TOKEN', 'unscoped-token'); vi.stubEnv('VOID_ACCESS_ORIGIN', ''); vi.stubEnv('VOID_ACCESS_CREDENTIALS', ''); - const { ensureCloudflaredToken } = await import('../../src/cli/cf-access.ts'); - ensureCloudflaredToken(origin); + const { ensurePlatformAccessToken } = await import('../../src/cli/cf-access.ts'); + ensurePlatformAccessToken(origin); expect(spawnSyncMock).not.toHaveBeenCalled(); expect(cfAccessHeaders(process.env, origin)).toEqual({}); vi.unstubAllEnvs(); diff --git a/packages/void/test/unit/cli-outdated.test.ts b/packages/void/test/unit/cli-outdated.test.ts index 260b58a5f..7740a9bb7 100644 --- a/packages/void/test/unit/cli-outdated.test.ts +++ b/packages/void/test/unit/cli-outdated.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { isCliOutdatedError, PlatformApiError, readCliVersion } from '../../src/cli/client.ts'; +import { isCliOutdatedError, PlatformApiError } from '../../src/cli/client.ts'; import { formatStreamDeployError } from '../../src/cli/deploy-errors.ts'; const OUTDATED_BODY = JSON.stringify({ @@ -68,25 +68,3 @@ describe('formatStreamDeployError + cli_outdated', () => { expect(formatted.message).toContain('Detailed log: /tmp/log.txt'); }); }); - -describe('readCliVersion', () => { - // The CLI must NEVER emit the literal string "unknown" as its version - // header — the platform's gate rejects unparseable headers as - // cli_outdated, which would brick the CLI on any packaging error. - // Failure path returns null and the ctor omits the header entirely. - it('returns a parseable semver triplet in the test environment', () => { - const v = readCliVersion(); - // Test runs from a real package root, so version must be readable. - expect(v).not.toBeNull(); - expect(v).toMatch(/^\d+\.\d+\.\d+/); - // Critical regression guard: never the literal string "unknown". - expect(v).not.toBe('unknown'); - }); - - it('return type allows null (caller must handle missing version)', () => { - // This is a type-level assertion — if the return type ever drifts - // back to `string`, this assignment will fail typecheck. - const v: string | null = readCliVersion(); - expect(v === null || typeof v === 'string').toBe(true); - }); -}); diff --git a/packages/void/test/unit/client-platform-version.test.ts b/packages/void/test/unit/client-platform-version.test.ts index 0748212f2..cf7078397 100644 --- a/packages/void/test/unit/client-platform-version.test.ts +++ b/packages/void/test/unit/client-platform-version.test.ts @@ -9,9 +9,10 @@ import { // Access challenge fails the way it does for a CLI that has none. vi.mock('../../src/cli/cf-access.ts', async (importOriginal) => ({ ...(await importOriginal()), - ensureCloudflaredToken: vi.fn(), - ensurePlatformAccessToken: vi.fn(() => { - throw new Error('platform: Cloudflare Access authentication is required.'); + ensurePlatformAccessToken: vi.fn((_apiUrl: string, challenged?: boolean) => { + if (challenged) { + throw new Error('platform: Cloudflare Access authentication is required.'); + } }), })); diff --git a/packages/void/test/unit/email-cli-domain.test.ts b/packages/void/test/unit/email-cli-domain.test.ts index 9f21f7568..35353335f 100644 --- a/packages/void/test/unit/email-cli-domain.test.ts +++ b/packages/void/test/unit/email-cli-domain.test.ts @@ -207,7 +207,7 @@ describe('PlatformClient.getEmailSettings', () => { function clientWith(status: number, body: string) { const fetchSpy = vi.fn(async () => new Response(body, { status })); vi.stubGlobal('fetch', withPlatformDiscovery(fetchSpy)); - return new PlatformClient('jwt-dummy', 'https://api.example.test'); + return new PlatformClient('jwt-dummy', { apiUrl: 'https://api.example.test' }); } it('reads the settings the platform reports', async () => { diff --git a/packages/void/test/unit/github-oidc.test.ts b/packages/void/test/unit/github-oidc.test.ts index 5e6d978d4..b1fb698c8 100644 --- a/packages/void/test/unit/github-oidc.test.ts +++ b/packages/void/test/unit/github-oidc.test.ts @@ -217,16 +217,6 @@ describe('exchangeGithubOidcForDeployToken', () => { } }); - it('strips a trailing slash from the API URL', async () => { - fetchMock - .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) - .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); - - await exchangeGithubOidcForDeployToken('https://api.test/', 'my-app', oidcEnv()); - const [postUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; - expect(postUrl).toBe('https://api.test/auth/github-oidc'); - }); - it('throws before any fetch when the request vars are absent', async () => { await expect( exchangeGithubOidcForDeployToken( diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 66c53f7e5..9d128d845 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -207,8 +207,8 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ vi.mock('../../src/cli/auth', () => ({ getApiUrl: (...args: Array) => mockGetApiUrl(...args), getToken: (...args: Array) => mockGetToken(...args), - runLogin: (...args: Array) => mockRunLogin(...args), - saveToken: (...args: Array) => mockSaveToken(...args), + runPlatformLogin: (...args: Array) => mockRunLogin(...args), + savePlatformToken: (...args: Array) => mockSaveToken(...args), isStagingMode: (...args: Array) => mockIsStagingMode(...args), getSiteDomain: () => 'void.app', })); @@ -2920,8 +2920,12 @@ export default config; expect(mockConfirm.mock.calls[1]?.[0]).toMatchObject({ message: 'Log in now and link or create a Void project?', }); - expect(mockRunLogin).toHaveBeenCalledWith(root, 'github', expect.any(Function)); - expect(mockSaveToken).toHaveBeenCalledWith('token_123', root); + expect(mockRunLogin).toHaveBeenCalledWith( + 'https://api.void.cloud', + 'github', + expect.any(Function), + ); + expect(mockSaveToken).toHaveBeenCalledWith('token_123', 'https://api.void.cloud'); expect(mockListProjects).toHaveBeenCalledOnce(); expect(mockCreateProject).toHaveBeenCalledWith('my-live-app'); expect(JSON.parse(readFileSync(join(root, '.void', 'project.json'), 'utf-8'))).toEqual({ diff --git a/packages/void/test/unit/login-command.test.ts b/packages/void/test/unit/login-command.test.ts index 50bfadb13..07e61812e 100644 --- a/packages/void/test/unit/login-command.test.ts +++ b/packages/void/test/unit/login-command.test.ts @@ -17,10 +17,8 @@ const mockGetToken = vi.fn(); const mockGetApiUrl = vi.fn(); const mockGetTokenSource = vi.fn(); const mockRemoveToken = vi.fn(); -const mockRunLogin = vi.fn(); const mockRunPlatformLogin = vi.fn(); const mockSavePlatformToken = vi.fn(); -const mockSaveToken = vi.fn(); const mockWhoami = vi.fn(); const mockIsExpiredTokenError = vi.fn(); const mockDiscoverPlatform = vi.fn(); @@ -52,10 +50,8 @@ vi.mock('../../src/cli/auth', () => ({ getApiUrl: (...args: Array) => mockGetApiUrl(...args), getTokenSource: (...args: Array) => mockGetTokenSource(...args), removeToken: (...args: Array) => mockRemoveToken(...args), - runLogin: (...args: Array) => mockRunLogin(...args), runPlatformLogin: (...args: Array) => mockRunPlatformLogin(...args), savePlatformToken: (...args: Array) => mockSavePlatformToken(...args), - saveToken: (...args: Array) => mockSaveToken(...args), isStagingMode: () => false, })); @@ -88,10 +84,8 @@ beforeEach(() => { mockGetApiUrl.mockReset().mockReturnValue('https://api.example.test'); mockGetTokenSource.mockReset(); mockRemoveToken.mockReset(); - mockRunLogin.mockReset(); mockRunPlatformLogin.mockReset().mockResolvedValue('platform-token'); mockSavePlatformToken.mockReset(); - mockSaveToken.mockReset(); mockWhoami.mockReset(); mockIsExpiredTokenError.mockReset(); mockDiscoverPlatform.mockReset().mockResolvedValue({ @@ -100,7 +94,6 @@ beforeEach(() => { }); mockGetTokenSource.mockReturnValue('manual'); - mockRunLogin.mockResolvedValue('fresh-token'); mockSelect.mockResolvedValue('github'); mockRemoveToken.mockReturnValue(true); mockIsExpiredTokenError.mockReturnValue(false); @@ -115,7 +108,6 @@ describe('runLoginCommand', () => { authenticationProviders: ['github'], }); expect(mockSelect).not.toHaveBeenCalled(); - expect(mockRunLogin).not.toHaveBeenCalled(); expect(mockRunPlatformLogin).toHaveBeenCalledWith( 'https://company.example.com', 'github', @@ -125,7 +117,6 @@ describe('runLoginCommand', () => { 'platform-token', 'https://company.example.com', ); - expect(mockSaveToken).not.toHaveBeenCalled(); }); it('keeps the logout-first flow when the stored token is still valid', async () => { @@ -137,7 +128,7 @@ describe('runLoginCommand', () => { expect(mockWhoami).toHaveBeenCalledTimes(1); expect(mockWarn).toHaveBeenCalledWith('Already logged in.'); expect(mockOutro).toHaveBeenCalledWith('To re-login, run `void account logout` first.'); - expect(mockRunLogin).not.toHaveBeenCalled(); + expect(mockRunPlatformLogin).not.toHaveBeenCalled(); expect(mockRemoveToken).not.toHaveBeenCalled(); }); @@ -151,8 +142,15 @@ describe('runLoginCommand', () => { expect(mockRemoveToken).toHaveBeenCalledTimes(1); expect(mockWarn).toHaveBeenCalledWith('Stored auth token expired. Starting a new login.'); expect(mockDiscoverPlatform).toHaveBeenCalledWith('https://api.example.test'); - expect(mockRunLogin).toHaveBeenCalledWith(root, 'github', expect.any(Function)); - expect(mockSaveToken).toHaveBeenCalledWith('fresh-token', root); + expect(mockRunPlatformLogin).toHaveBeenCalledWith( + 'https://api.example.test', + 'github', + expect.any(Function), + ); + expect(mockSavePlatformToken).toHaveBeenCalledWith( + 'platform-token', + 'https://api.example.test', + ); expect(mockInfo).toHaveBeenCalledWith('Logged in!'); }); @@ -169,8 +167,15 @@ describe('runLoginCommand', () => { '`VOID_TOKEN` is invalid or expired. Continuing with a new login.', ); expect(mockDiscoverPlatform).toHaveBeenCalledWith('https://api.example.test'); - expect(mockRunLogin).toHaveBeenCalledWith(root, 'github', expect.any(Function)); - expect(mockSaveToken).toHaveBeenCalledWith('fresh-token', root); + expect(mockRunPlatformLogin).toHaveBeenCalledWith( + 'https://api.example.test', + 'github', + expect.any(Function), + ); + expect(mockSavePlatformToken).toHaveBeenCalledWith( + 'platform-token', + 'https://api.example.test', + ); expect(mockOutro).toHaveBeenCalledWith( `\n${pc.green('✓ Token saved. Unset `VOID_TOKEN` to use it.')}`, { withGuide: false }, diff --git a/packages/void/test/unit/platform-permission-errors.test.ts b/packages/void/test/unit/platform-permission-errors.test.ts index 027ebaaa0..b275f5857 100644 --- a/packages/void/test/unit/platform-permission-errors.test.ts +++ b/packages/void/test/unit/platform-permission-errors.test.ts @@ -4,12 +4,11 @@ import { formatStreamDeployError } from '../../src/cli/deploy-errors.ts'; import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; vi.mock('../../src/cli/cf-access.ts', () => ({ - ensureCloudflaredToken: vi.fn(), ensurePlatformAccessToken: vi.fn(), isCloudflareAccessChallenge: () => false, })); afterEach(() => vi.unstubAllGlobals()); -const client = () => new PlatformClient('test-token', 'https://api.example.test'); +const client = () => new PlatformClient('test-token', { apiUrl: 'https://api.example.test' }); const project = 'project_1'; const collaborator = 'collaborator, admin, or owner'; const admin = 'admin or owner'; diff --git a/packages/void/test/unit/platform-setup-flow.test.ts b/packages/void/test/unit/platform-setup-flow.test.ts index f7e610e79..a2802fff2 100644 --- a/packages/void/test/unit/platform-setup-flow.test.ts +++ b/packages/void/test/unit/platform-setup-flow.test.ts @@ -72,7 +72,6 @@ vi.mock('../../src/cli/auth.ts', async (original) => ({ ...(await original()), openBrowser: mocks.open, })); -vi.mock('../../src/cli/prompt-password.ts', () => ({ passwordWithPlaceholder: mocks.password })); vi.mock('../../src/cli/output.ts', async (original) => ({ ...(await original()), createSpinner: mocks.spinner, diff --git a/packages/void/test/unit/prompt-password.test.ts b/packages/void/test/unit/prompt-password.test.ts deleted file mode 100644 index e71b55518..000000000 --- a/packages/void/test/unit/prompt-password.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { PassThrough, Writable } from 'node:stream'; -import { stripVTControlCharacters } from 'node:util'; -import { isCancel } from '@voidzero-dev/vite-plus-prompts'; -import { afterEach, expect, it, vi } from 'vite-plus/test'; -import { passwordWithPlaceholder } from '../../src/cli/prompt-password.ts'; - -let cleanup: (() => Promise) | undefined; -afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - vi.unstubAllEnvs(); -}); - -function openPrompt(validate?: (value: string | undefined) => string | undefined) { - const input = Object.assign(new PassThrough(), { isRaw: false, setRawMode: vi.fn() }); - const writes: Array = []; - const output = Object.assign( - new Writable({ - write(chunk, _encoding, callback) { - writes.push(chunk.toString()); - callback(); - }, - }), - { columns: 80, rows: 24 }, - ); - const controller = new AbortController(); - const result = passwordWithPlaceholder({ - message: 'PostgreSQL DATABASE_URL:', - placeholder: 'postgresql://user:pw@…/database', - input, - output, - signal: controller.signal, - validate: validate ?? ((value) => (value ? undefined : 'A value is required.')), - }); - cleanup = async () => { - controller.abort(); - await result; - input.destroy(); - output.destroy(); - }; - return { - result, - writes, - key: (key: string) => input.emit('data', Buffer.from(key)), - rendered: () => stripVTControlCharacters(writes.join('')), - }; -} - -it('shows a placeholder without accepting it as a default', async () => { - const prompt = openPrompt(); - expect(prompt.rendered()).toContain('postgresql://user:pw@…/database'); - prompt.key('\r'); - await vi.waitFor(() => expect(prompt.rendered()).toContain('A value is required.')); - prompt.key('real-secret'); - prompt.key('\r'); - expect(await prompt.result).toBe('real-secret'); - expect(prompt.rendered()).not.toContain('real-secret'); -}); - -it.each(['', '1'])( - 'never echoes the database URL on submission (ACCESSIBLE=%s)', - async (accessible) => { - vi.stubEnv('ACCESSIBLE', accessible); - const prompt = openPrompt(); - const secret = 'postgresql://owner:secret@database.example/db'; - prompt.writes.length = 0; - prompt.key(secret); - expect(prompt.rendered()).not.toContain('postgresql://'); - prompt.key('\r'); - expect(await prompt.result).toBe(secret); - expect(prompt.rendered()).not.toContain(secret); - expect(prompt.rendered()).not.toContain('owner:secret'); - }, -); - -it('keeps rejected and cancelled values masked', async () => { - const prompt = openPrompt(() => 'Invalid URL.'); - prompt.key('invalid-secret'); - prompt.key('\r'); - await vi.waitFor(() => expect(prompt.rendered()).toContain('Invalid URL.')); - prompt.key('\x03'); - expect(isCancel(await prompt.result)).toBe(true); - expect(prompt.rendered()).not.toContain('invalid-secret'); -}); From f82a2e15239e789969abfc26a43828d692eab1ea Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 14:08:22 +0800 Subject: [PATCH 002/127] test(void): drop duplicate client and auth tests - Remove error, echo and retry cases already pinned by the permission table or sibling tests - Share GitHub CLI fixtures and the browser-callback driver in test/helpers - Use vi.stubEnv instead of hand-written env save/restore - Trim history and restating comments, and review labels in test names Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/test/helpers/github-cli.ts | 73 +++++ packages/void/test/unit/auth-callback.test.ts | 22 +- packages/void/test/unit/auth-legacy.test.ts | 24 +- packages/void/test/unit/auth-router.test.ts | 2 - packages/void/test/unit/build-client.test.ts | 19 +- packages/void/test/unit/cli-outdated.test.ts | 5 - .../test/unit/client-platform-version.test.ts | 6 +- .../void/test/unit/deploy-project.test.ts | 119 ++------ .../void/test/unit/direct-r2-client.test.ts | 259 +++++------------- .../void/test/unit/get-database-url.test.ts | 53 +--- packages/void/test/unit/github-client.test.ts | 189 +------------ .../void/test/unit/github-connect-cmd.test.ts | 114 ++------ .../test/unit/github-disconnect-cmd.test.ts | 31 +-- .../void/test/unit/github-install-cmd.test.ts | 49 +--- .../void/test/unit/github-join-cmd.test.ts | 55 +--- .../void/test/unit/github-link-cmd.test.ts | 55 +--- packages/void/test/unit/github-oidc.test.ts | 138 +++------- .../void/test/unit/github-update-cmd.test.ts | 79 +----- packages/void/test/unit/login-command.test.ts | 7 +- packages/void/test/unit/logs-filter.test.ts | 68 ++--- .../test/unit/project-team-command.test.ts | 29 +- packages/void/test/unit/remote/client.test.ts | 66 ----- .../test/unit/stream-deploy-errors.test.ts | 11 - 23 files changed, 291 insertions(+), 1182 deletions(-) create mode 100644 packages/void/test/helpers/github-cli.ts diff --git a/packages/void/test/helpers/github-cli.ts b/packages/void/test/helpers/github-cli.ts new file mode 100644 index 000000000..431de7179 --- /dev/null +++ b/packages/void/test/helpers/github-cli.ts @@ -0,0 +1,73 @@ +import { vi, type Mock, type MockInstance } from 'vite-plus/test'; +import type { GithubConnection, ProjectResponse } from '../../src/cli/client.ts'; + +export const projectId = 'proj_test'; +export const slug = 'my-app'; + +const realFetch = globalThis.fetch.bind(globalThis); + +export function makeProject(): ProjectResponse { + return { + id: projectId, + user_id: 'user_1', + slug, + type: 'void', + created_at: '2024-01-01T00:00:00Z', + custom_domain: null, + role: 'owner', + }; +} + +export function makeConnection(overrides: Partial = {}): GithubConnection { + return { + id: 'conn_1', + project_id: projectId, + installation_id: '123456', + repo_full_name: 'octocat/hello-world', + branch: 'main', + build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...overrides, + }; +} + +/** Makes `process.exit` throw so a test can assert the exit without stopping the runner. */ +export function throwOnProcessExit(): MockInstance { + return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); +} + +export interface BrowserCallback { + authUrl: Mock<(redirect: string) => Promise<{ url: string }>>; + /** Settles with the command, after the simulated redirect has finished. */ + settle: (run: Promise) => Promise; +} + +/** + * Fakes the platform endpoint that returns a GitHub authorize URL. When the + * command requests it, `query` turns the command's localhost callback URL into + * the redirect a browser would send back. + */ +export function browserCallback(query?: (callbackUrl: URL) => URL): BrowserCallback { + let resolveCallback!: (url: URL) => void; + const callbackReady = new Promise((resolve) => { + resolveCallback = resolve; + }); + const authUrl = vi.fn(async (redirect: string) => { + resolveCallback(new URL(redirect)); + return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; + }); + // Error paths close the callback server mid-response, so the redirect may reject. + const fired = query + ? callbackReady.then(async (callbackUrl) => { + await realFetch(query(callbackUrl)).catch(() => {}); + }) + : Promise.resolve(); + return { + authUrl, + settle: (run) => run.finally(() => fired.catch(() => {})), + }; +} diff --git a/packages/void/test/unit/auth-callback.test.ts b/packages/void/test/unit/auth-callback.test.ts index 23b8dd644..513d2ef2b 100644 --- a/packages/void/test/unit/auth-callback.test.ts +++ b/packages/void/test/unit/auth-callback.test.ts @@ -86,7 +86,7 @@ describe('awaitBrowserCallback', () => { await expect(resultPromise).resolves.toEqual({ linked: 'abc' }); }); - it('renders the failure page and rejects when finalize throws (F3 regression pin)', async () => { + it('renders the failure page and rejects when finalize throws', async () => { const finalizeError = new Error('exchange blew up'); const finalize = vi.fn(async () => { throw finalizeError; @@ -141,26 +141,6 @@ describe('awaitBrowserCallback', () => { expect(params.get('link_code')).toBe('real'); }); - it('renders 403 and rejects when describeError returns a message', async () => { - const { resultPromise, callbackPromise, state } = startCallback({ - describeError: (p) => (p.get('error') ? 'GitHub authorization failed.' : null), - }); - const callback = await callbackPromise; - - // Attach the rejection assertion before triggering the request so the - // handler is in place when the promise settles (no unhandled rejection). - const rejection = expect(resultPromise).rejects.toThrow('GitHub authorization failed.'); - - const hit = new URL(callback); - hit.searchParams.set('state', state); - hit.searchParams.set('error', 'access_denied'); - - const res = await fetch(hit); - expect(res.status).toBe(403); - - await rejection; - }); - it('rejects after the timeout elapses', async () => { const resultPromise = awaitBrowserCallback({ state: 'timeout-state', diff --git a/packages/void/test/unit/auth-legacy.test.ts b/packages/void/test/unit/auth-legacy.test.ts index 5f3806c0c..8f31cfc86 100644 --- a/packages/void/test/unit/auth-legacy.test.ts +++ b/packages/void/test/unit/auth-legacy.test.ts @@ -92,12 +92,9 @@ describe('retired unscoped credentials', () => { expect(getApiUrl(state.home)).toBe('https://platform.example.test'); expect(getToken(state.home)).toBe('ci-token'); }); - it.each([ - { account: 'token', apiUrl: 'https://api.void.cloud' }, - { account: 'stagingToken', apiUrl: 'https://api.staging.void.cloud' }, - ])( + it.each([{ account: 'token' }, { account: 'stagingToken' }])( 'ignores the $account keychain destination until explicitly connected', - async ({ account, apiUrl }) => { + async ({ account }) => { const root = mkdtempSync(join(tmpdir(), 'void-auth-retired-project-')); directories.push(root); state.passwords.set(`dev.void.cli:${account}`, 'retired-token'); @@ -131,23 +128,6 @@ describe('retired unscoped credentials', () => { expect(state.passwords.size).toBe(0); }); - it.each([ - { account: 'token', apiUrl: 'https://api.void.cloud' }, - { account: 'stagingToken', apiUrl: 'https://api.staging.void.cloud' }, - ])('ignores token-only file destination $apiUrl', async ({ account, apiUrl }) => { - const root = mkdtempSync(join(tmpdir(), 'void-auth-retired-project-')); - directories.push(root); - mkdirSync(join(state.home, '.void')); - writeFileSync( - join(state.home, '.void', 'config.json'), - JSON.stringify({ [account]: 'retired-token' }), - ); - const { getAccountCredential, getApiUrl, getToken } = await import('../../src/cli/auth.ts'); - expect(() => getAccountCredential()).toThrow('void connect'); - expect(() => getApiUrl(root)).toThrow('void connect'); - expect(getToken(root)).toBeNull(); - }); - it('uses and removes only the modern credential for the selected origin', async () => { const root = mkdtempSync(join(tmpdir(), 'void-auth-retired-project-')); directories.push(root); diff --git a/packages/void/test/unit/auth-router.test.ts b/packages/void/test/unit/auth-router.test.ts index 5ae3cbc68..cc65f336d 100644 --- a/packages/void/test/unit/auth-router.test.ts +++ b/packages/void/test/unit/auth-router.test.ts @@ -94,8 +94,6 @@ describe('void auth destination routing', () => { it.each(['whoami', 'link', 'token'])('rejects the retired auth %s shortcut', (command) => { expect(() => parseAuthArgs([command])).toThrow('void auth '); - expect(mocks.account).not.toHaveBeenCalled(); - expect(mocks.cloudflare).not.toHaveBeenCalled(); }); it('validates explicit targets', () => { diff --git a/packages/void/test/unit/build-client.test.ts b/packages/void/test/unit/build-client.test.ts index 88cb0e8c1..171c81c2b 100644 --- a/packages/void/test/unit/build-client.test.ts +++ b/packages/void/test/unit/build-client.test.ts @@ -23,9 +23,8 @@ const projectId = 'proj_test'; function makeClient(): PlatformClient { return new PlatformClient(token, { apiUrl }); } -function jsonResponse(body: unknown, status = 200): Response { +function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { - status, headers: { 'content-type': 'application/json' }, }); } @@ -41,14 +40,6 @@ describe('PlatformClient.listBuilds', () => { expect((init as RequestInit).headers).toMatchObject({ Authorization: 'Bearer auth-token-x' }); expect(result.map((b) => b.id)).toEqual(['b1', 'b2']); }); - - it('maps a non-ok response to PlatformApiError', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'forbidden' }, 403)); - await expect(makeClient().listBuilds(projectId)).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 403, - }); - }); }); describe('PlatformClient.getBuildLogs', () => { @@ -71,12 +62,4 @@ describe('PlatformClient.getBuildLogs', () => { expect(result.terminal).toBe(false); expect(result.lines[0]!.line).toBe('a'); }); - - it('maps a non-ok response to PlatformApiError', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'build not found' }, 404)); - await expect(makeClient().getBuildLogs(projectId, 'nope', 0)).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 404, - }); - }); }); diff --git a/packages/void/test/unit/cli-outdated.test.ts b/packages/void/test/unit/cli-outdated.test.ts index 7740a9bb7..2a6fd0d7d 100644 --- a/packages/void/test/unit/cli-outdated.test.ts +++ b/packages/void/test/unit/cli-outdated.test.ts @@ -12,9 +12,6 @@ const OUTDATED_BODY = JSON.stringify({ describe('PlatformApiError + cli_outdated', () => { it('overrides the default message with the server-supplied upgrade text on 426', () => { const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY); - // Default would be `Deploy failed: 426 {...json...}` — readers shouldn't - // see raw JSON. Server message wins so the actionable upgrade line is - // up front. expect(err.message).not.toContain('426'); expect(err.message).not.toContain('{'); expect(err.message).toContain('npm i -g void@latest'); @@ -54,8 +51,6 @@ describe('formatStreamDeployError + cli_outdated', () => { it('surfaces the server message verbatim with no HTTP wrapper', () => { const err = new PlatformApiError('Deploy failed', 426, OUTDATED_BODY); const formatted = formatStreamDeployError(err, null, null); - // Should NOT contain the standard "Deploy failed (HTTP 426)" wrapper - // because that buries the actionable line. expect(formatted.message).not.toContain('Deploy failed (HTTP 426)'); expect(formatted.message).toContain('npm i -g void@latest'); }); diff --git a/packages/void/test/unit/client-platform-version.test.ts b/packages/void/test/unit/client-platform-version.test.ts index cf7078397..212c32549 100644 --- a/packages/void/test/unit/client-platform-version.test.ts +++ b/packages/void/test/unit/client-platform-version.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { MIN_PLATFORM_VERSION, PlatformClient } from '../../src/cli/client.ts'; +import { PlatformClient } from '../../src/cli/client.ts'; import { isPlatformDiscoveryRequest, platformDiscoveryResponse, @@ -53,10 +53,6 @@ function client(options: { checkPlatformVersion?: boolean } = {}): PlatformClien } describe('minimum platform version', () => { - it('declares 0.27.0 as the minimum', () => { - expect(MIN_PLATFORM_VERSION).toBe('0.27.0'); - }); - it.each(['0.27.0', '0.28.0', '1.0.0', 'hosted'])( 'accepts a platform reporting %s', async (version) => { diff --git a/packages/void/test/unit/deploy-project.test.ts b/packages/void/test/unit/deploy-project.test.ts index 0d0de7857..89872dbac 100644 --- a/packages/void/test/unit/deploy-project.test.ts +++ b/packages/void/test/unit/deploy-project.test.ts @@ -29,6 +29,13 @@ import { const tempDirs: Array = []; +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop()!; + rmSync(dir, { recursive: true, force: true }); + } +}); + describe('getRequestedProjectSlug', () => { afterEach(() => { delete process.env.VOID_PROJECT; @@ -72,10 +79,6 @@ describe('resolveProjectBySlug', () => { }); }); - it('returns null when slug is not found', () => { - expect(resolveProjectBySlug([], 'missing')).toBeNull(); - }); - it('returns null when slug does not match any project', () => { const projects = [ { id: 'proj_1', slug: 'app-one' }, @@ -86,13 +89,6 @@ describe('resolveProjectBySlug', () => { }); describe('inferDefaultSlug', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('derives slug from a scoped package name', () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -111,13 +107,6 @@ describe('inferDefaultSlug', () => { }); describe('resolveDistDir', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('returns dist/ for non-framework deploys', () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -166,13 +155,6 @@ describe('resolveDistDir', () => { }); describe('resolveDeployWranglerCompat', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('does not pin compatibility_date for non-Cloudflare targets', () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -215,13 +197,6 @@ describe('resolveDeployWranglerCompat', () => { }); describe('resolveDeployWorkerMain', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('uses the build output wrangler main for non-framework Void deploys', () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -254,13 +229,6 @@ describe('resolveDeployWorkerMain', () => { }); describe('ensurePreviouslyDeployedLiveBindingConfig', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('restores the active deployment live binding into wrangler config', async () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -312,13 +280,6 @@ describe('ensurePreviouslyDeployedLiveBindingConfig', () => { }); describe('resolveDeployLiveBindingConfig', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('does not restore or activate live bindings when current source no longer needs live', async () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -427,12 +388,6 @@ describe('resolveStaticBuildCommand', () => { }); }); -// The FULL (Class A / Void app) deploy used to hardcode `vite build`, ignoring -// `inference.build`. That made "two builds, one command" impossible: an SSG that -// emits the site first and then lets Void's own build package it (via -// `publicDir`) alongside the worker had no way to say so, and a repo with no -// root `index.html` died on `[UNRESOLVED_ENTRY] Cannot resolve entry module -// index.html`. The static and framework paths already honored the override. describe('resolveFullDeployBuildCommand', () => { const root = '/tmp/void-full-deploy-build-cmd'; @@ -443,46 +398,18 @@ describe('resolveFullDeployBuildCommand', () => { }); }); - it('prints exactly what it runs for a configured command', () => { - // No `formatProjectCommand`/`resolveProjectCommand` rewriting on the - // override path — the user's text is both shown and executed, so a shell - // chain like `a && b` cannot be mangled into an unrunnable single command. - const { label, command } = resolveFullDeployBuildCommand(root, 'pnpm run build:site'); - expect(label).toBe(command); - }); - - it('falls back to the built-in vite build when inference.build is unset', () => { - expect(resolveFullDeployBuildCommand(root)).toEqual({ - label: formatProjectCommand(root, 'vite build'), - command: resolveProjectCommand(root, 'vite build'), - }); - }); - - it('keeps the fallback label/command distinction (display vs. local bin)', () => { - // `formatProjectCommand` is the user-facing form (`vite` → `vp`, no absolute - // path); `resolveProjectCommand` expands to node_modules/.bin. Collapsing - // them would either print an absolute path or execute an unresolvable bin. - const { label, command } = resolveFullDeployBuildCommand(root); - expect(label).toBe(formatProjectCommand(root, 'vite build')); - expect(command).toBe(resolveProjectCommand(root, 'vite build')); - }); - - it('treats an empty inference.build as unset', () => { - expect(resolveFullDeployBuildCommand(root, '')).toEqual({ - label: formatProjectCommand(root, 'vite build'), - command: resolveProjectCommand(root, 'vite build'), - }); - }); + it.each([undefined, ''])( + 'falls back to the built-in vite build for inference.build %j', + (build) => { + expect(resolveFullDeployBuildCommand(root, build)).toEqual({ + label: formatProjectCommand(root, 'vite build'), + command: resolveProjectCommand(root, 'vite build'), + }); + }, + ); }); describe('resolveStaticDeployPreset', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('treats --dir as a static deploy override even in framework-shaped projects', () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -526,13 +453,6 @@ describe('resolveStaticDeployPreset', () => { }); describe('resolveWorkerDirName', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('returns preset worker subdir for react-router', () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); @@ -593,13 +513,6 @@ describe('resolveWorkerDirName', () => { }); describe('generateDeployWrapperIfNeeded', () => { - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop()!; - rmSync(dir, { recursive: true, force: true }); - } - }); - it('wraps class-a framework worker output when migrations are present', async () => { const root = mkdtempSync(join(tmpdir(), 'void-test-')); tempDirs.push(root); diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts index 404499c4f..affaa299d 100644 --- a/packages/void/test/unit/direct-r2-client.test.ts +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -118,19 +118,6 @@ describe('PlatformClient.requestUploadUrls', () => { aabbccdd: { url: 'https://r2/.../1', headers: { 'Content-Md5': 'abc==' } }, }); }); - - it('throws PlatformApiError on non-2xx', async () => { - fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); - const client = makeClient(); - await expect( - client.requestUploadUrls(projectId, [ - { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, - ]), - ).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 403, - }); - }); }); describe('PlatformClient.getMigrationStatus', () => { @@ -268,16 +255,6 @@ describe('PlatformClient.finalizeDeploy', () => { ]); }); - it('throws PlatformApiError on non-2xx', async () => { - fetchMock.mockResolvedValueOnce(new Response('busted', { status: 500 })); - const client = makeClient(); - const gen = client.finalizeDeploy(projectId, makePayload()); - await expect(gen.next()).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 500, - }); - }); - function makeCliLog() { return { info: vi.fn(), @@ -341,40 +318,6 @@ describe('PlatformClient.finalizeDeploy', () => { } }); - it('throws the original error after exhausting transport retries', async () => { - vi.useFakeTimers(); - fetchMock.mockImplementation(() => Promise.reject(new TypeError('fetch failed'))); - - const cliLog = makeCliLog(); - const client = new PlatformClient(token, { apiUrl, cliLog }); - try { - const consume = (async () => { - for await (const _event of client.finalizeDeploy(projectId, makePayload())) { - // unreachable — every attempt rejects - } - })(); - const assertion = expect(consume).rejects.toBeInstanceOf(TypeError); - await vi.runAllTimersAsync(); - await assertion; - - // 1 initial attempt + 3 retries — no dedupe GETs. - expect(deployPostCalls()).toHaveLength(4); - expect(deploymentListCalls()).toHaveLength(0); - const retries = cliLog.warn.mock.calls.filter(([msg]) => msg === 'deploy_fetch_retry'); - expect(retries.map(([, fields]) => fields?.delayMs)).toEqual([1000, 5000, 10000]); - expect(cliLog.error).toHaveBeenCalledWith( - 'deploy_fetch_rejected', - expect.any(TypeError), - expect.objectContaining({ - url: `${apiUrl}/projects/${projectId}/deploy`, - attempts: 4, - }), - ); - } finally { - vi.useRealTimers(); - } - }); - it('surfaces the server 409 as an error event when a deploy is already in progress', async () => { vi.useFakeTimers(); // The replay hits the authoritative concurrency guard — possibly the @@ -491,7 +434,7 @@ describe('PlatformClient.finalizeDeploy', () => { expect(cliLog.error).toHaveBeenCalledWith( 'deploy_fetch_rejected', expect.any(TypeError), - expect.objectContaining({ attempts: 4 }), + expect.objectContaining({ url: `${apiUrl}/projects/${projectId}/deploy`, attempts: 4 }), ); } finally { vi.useRealTimers(); @@ -1028,130 +971,71 @@ describe('PlatformClient.finalizeDeploy', () => { }); }); -// The deploy command keeps the CF Access service-token PAIR out of the untrusted -// build children (via `buildEnv()` on the spawn env) so they can't read the -// shared staging credential. That is only safe because the client captures the -// Access headers AT CONSTRUCTION and reuses them — proven here: delete the env -// vars after construction, then verify a later API call STILL sends -// CF-Access-Client-Id / CF-Access-Client-Secret. (deploy.ts no longer mutates -// process.env; this test deletes them itself to assert the capture invariant.) +// The deploy command keeps the CF Access service-token pair out of untrusted +// build children (via `buildEnv()` on the spawn env). That is only safe because +// the client captures the Access headers at construction and reuses them. describe('PlatformClient CF Access header capture (survives a later env scrub)', () => { it('keeps sending CF-Access headers after CF_ACCESS_* are deleted from process.env', async () => { vi.stubEnv('VOID_ACCESS_ORIGIN', 'https://api.staging.void.cloud'); - const savedId = process.env.CF_ACCESS_CLIENT_ID; - const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; - try { - process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; - process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; - - // Construct a known Access-gated staging client, which captures the - // credentials before the untrusted build environment is scrubbed. - const client = new PlatformClient(token, { apiUrl: 'https://api.staging.void.cloud' }); + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'svc-id.access'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'svc-secret'); + const client = new PlatformClient(token, { apiUrl: 'https://api.staging.void.cloud' }); - // Simulate deploy.ts's scrub (before any user build spawns). - delete process.env.CF_ACCESS_CLIENT_ID; - delete process.env.CF_ACCESS_CLIENT_SECRET; + vi.stubEnv('CF_ACCESS_CLIENT_ID', undefined); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', undefined); - fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); - await client.requestUploadUrls(projectId, [ - { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, - ]); + fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); + await client.requestUploadUrls(projectId, [ + { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, + ]); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - expect(headers['CF-Access-Client-Id']).toBe('svc-id.access'); - expect(headers['CF-Access-Client-Secret']).toBe('svc-secret'); - } finally { - if (savedId === undefined) { - delete process.env.CF_ACCESS_CLIENT_ID; - } else { - process.env.CF_ACCESS_CLIENT_ID = savedId; - } - if (savedSecret === undefined) { - delete process.env.CF_ACCESS_CLIENT_SECRET; - } else { - process.env.CF_ACCESS_CLIENT_SECRET = savedSecret; - } - } + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['CF-Access-Client-Id']).toBe('svc-id.access'); + expect(headers['CF-Access-Client-Secret']).toBe('svc-secret'); }); it('does not send staging Access credentials to a self-hosted platform', async () => { - const savedId = process.env.CF_ACCESS_CLIENT_ID; - const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; - try { - process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; - process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; - const client = makeClient(); - fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'svc-id.access'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'svc-secret'); + const client = makeClient(); + fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); - await client.requestUploadUrls(projectId, [ - { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, - ]); + await client.requestUploadUrls(projectId, [ + { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, + ]); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - expect(headers['CF-Access-Client-Id']).toBeUndefined(); - expect(headers['CF-Access-Client-Secret']).toBeUndefined(); - } finally { - if (savedId === undefined) { - delete process.env.CF_ACCESS_CLIENT_ID; - } else { - process.env.CF_ACCESS_CLIENT_ID = savedId; - } - if (savedSecret === undefined) { - delete process.env.CF_ACCESS_CLIENT_SECRET; - } else { - process.env.CF_ACCESS_CLIENT_SECRET = savedSecret; - } - } + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['CF-Access-Client-Id']).toBeUndefined(); + expect(headers['CF-Access-Client-Secret']).toBeUndefined(); }); it('uses a browser-renewed Access token acquired after construction', async () => { - const savedToken = process.env.CF_ACCESS_TOKEN; - const savedId = process.env.CF_ACCESS_CLIENT_ID; - const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; - const savedCredentials = process.env.VOID_ACCESS_CREDENTIALS; - const savedOrigin = process.env.VOID_ACCESS_ORIGIN; - try { - delete process.env.CF_ACCESS_CLIENT_ID; - delete process.env.CF_ACCESS_CLIENT_SECRET; - delete process.env.VOID_ACCESS_CREDENTIALS; - process.env.VOID_ACCESS_ORIGIN = 'https://api.staging.void.cloud'; - const tokenFor = (label: string) => - 'header.' + - Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label })).toString( - 'base64url', - ) + - '.signature'; - process.env.CF_ACCESS_TOKEN = tokenFor('initial'); - const client = new PlatformClient(token, { apiUrl: 'https://api.staging.void.cloud' }); - - const renewedToken = tokenFor('renewed'); - process.env.CF_ACCESS_TOKEN = renewedToken; - fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); - await client.requestUploadUrls(projectId, [ - { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, - ]); + vi.stubEnv('CF_ACCESS_CLIENT_ID', undefined); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', undefined); + vi.stubEnv('VOID_ACCESS_CREDENTIALS', undefined); + vi.stubEnv('VOID_ACCESS_ORIGIN', 'https://api.staging.void.cloud'); + const tokenFor = (label: string) => + 'header.' + + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label })).toString( + 'base64url', + ) + + '.signature'; + vi.stubEnv('CF_ACCESS_TOKEN', tokenFor('initial')); + const client = new PlatformClient(token, { apiUrl: 'https://api.staging.void.cloud' }); + + const renewedToken = tokenFor('renewed'); + vi.stubEnv('CF_ACCESS_TOKEN', renewedToken); + fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); + await client.requestUploadUrls(projectId, [ + { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, + ]); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - const headers = init.headers as Record; - expect(headers['cf-access-token']).toBe(renewedToken); - expect(headers['CF-Access-Client-Id']).toBeUndefined(); - } finally { - for (const [name, value] of [ - ['CF_ACCESS_TOKEN', savedToken], - ['CF_ACCESS_CLIENT_ID', savedId], - ['CF_ACCESS_CLIENT_SECRET', savedSecret], - ['VOID_ACCESS_CREDENTIALS', savedCredentials], - ['VOID_ACCESS_ORIGIN', savedOrigin], - ] as const) { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } - } - } + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['cf-access-token']).toBe(renewedToken); + expect(headers['CF-Access-Client-Id']).toBeUndefined(); }); }); @@ -1159,8 +1043,6 @@ describe('PlatformClient Cloudflare Access challenge renewal', () => { const origin = 'https://api.example.com'; const accessJwt = (seconds: number) => `e30.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + seconds })).toString('base64url')}.signature`; - let savedCredentials: string | undefined; - let savedOrigin: string | undefined; let stdinTty: boolean | undefined; let stdoutTty: boolean | undefined; @@ -1168,24 +1050,12 @@ describe('PlatformClient Cloudflare Access challenge renewal', () => { spawnSyncMock.mockReset(); accessKeyring.read.mockReset().mockReturnValue(null); accessKeyring.write.mockReset().mockReturnValue(true); - savedCredentials = process.env.VOID_ACCESS_CREDENTIALS; - savedOrigin = process.env.VOID_ACCESS_ORIGIN; stdinTty = process.stdin.isTTY; stdoutTty = process.stdout.isTTY; - delete process.env.VOID_ACCESS_ORIGIN; + vi.stubEnv('VOID_ACCESS_ORIGIN', undefined); }); afterEach(() => { - if (savedCredentials === undefined) { - delete process.env.VOID_ACCESS_CREDENTIALS; - } else { - process.env.VOID_ACCESS_CREDENTIALS = savedCredentials; - } - if (savedOrigin === undefined) { - delete process.env.VOID_ACCESS_ORIGIN; - } else { - process.env.VOID_ACCESS_ORIGIN = savedOrigin; - } Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: stdoutTty }); }); @@ -1193,9 +1063,7 @@ describe('PlatformClient Cloudflare Access challenge renewal', () => { it('renews a rejected human token and retries once at the original API endpoint', async () => { const stale = accessJwt(600); const fresh = accessJwt(900); - process.env.VOID_ACCESS_CREDENTIALS = JSON.stringify({ - [origin]: { CF_ACCESS_TOKEN: stale }, - }); + vi.stubEnv('VOID_ACCESS_CREDENTIALS', JSON.stringify({ [origin]: { CF_ACCESS_TOKEN: stale } })); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); spawnSyncMock.mockImplementation((_command, args: Array) => @@ -1229,9 +1097,10 @@ describe('PlatformClient Cloudflare Access challenge renewal', () => { }); it('does not retry ambiguous API errors', async () => { - process.env.VOID_ACCESS_CREDENTIALS = JSON.stringify({ - [origin]: { CF_ACCESS_TOKEN: accessJwt(600) }, - }); + vi.stubEnv( + 'VOID_ACCESS_CREDENTIALS', + JSON.stringify({ [origin]: { CF_ACCESS_TOKEN: accessJwt(600) } }), + ); fetchMock.mockResolvedValueOnce(new Response('application failure', { status: 500 })); await expect( @@ -1242,16 +1111,16 @@ describe('PlatformClient Cloudflare Access challenge renewal', () => { }); it('does not replace a rejected scoped service credential with a human login', async () => { - process.env.VOID_ACCESS_CREDENTIALS = JSON.stringify({ - [origin]: { - CF_ACCESS_CLIENT_ID: 'ci-client', - CF_ACCESS_CLIENT_SECRET: 'ci-secret', - }, - }); + vi.stubEnv( + 'VOID_ACCESS_CREDENTIALS', + JSON.stringify({ + [origin]: { CF_ACCESS_CLIENT_ID: 'ci-client', CF_ACCESS_CLIENT_SECRET: 'ci-secret' }, + }), + ); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false }); const client = new PlatformClient(token, { apiUrl: origin }); - delete process.env.VOID_ACCESS_CREDENTIALS; + vi.stubEnv('VOID_ACCESS_CREDENTIALS', undefined); fetchMock.mockResolvedValueOnce( new Response(null, { status: 302, diff --git a/packages/void/test/unit/get-database-url.test.ts b/packages/void/test/unit/get-database-url.test.ts index 8d0d47636..f2279dbe4 100644 --- a/packages/void/test/unit/get-database-url.test.ts +++ b/packages/void/test/unit/get-database-url.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { PlatformClient, PlatformApiError } from '../../src/cli/client.ts'; +import { PlatformClient } from '../../src/cli/client.ts'; import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); @@ -25,19 +25,18 @@ function makeClient(): PlatformClient { return new PlatformClient(token, { apiUrl }); } -function jsonResponse(body: unknown, status = 200): Response { +function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { - status, headers: { 'content-type': 'application/json' }, }); } describe('PlatformClient.getDatabaseUrl', () => { - it('GETs the correct URL with auth headers', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ url: 'postgres://user:pass@host/db' })); + it('GETs the database URL with auth headers and no body, and returns it', async () => { + const expected = 'postgres://user:pass@host:5432/mydb'; + fetchMock.mockResolvedValueOnce(jsonResponse({ url: expected })); - const client = makeClient(); - await client.getDatabaseUrl(projectId); + const result = await makeClient().getDatabaseUrl(projectId); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; @@ -45,45 +44,7 @@ describe('PlatformClient.getDatabaseUrl', () => { expect((init.method ?? 'GET').toUpperCase()).toBe('GET'); const headers = init.headers as Record; expect(headers.Authorization).toBe(`Bearer ${token}`); - }); - - it('returns the url from a successful response', async () => { - const expected = 'postgres://user:pass@host:5432/mydb'; - fetchMock.mockResolvedValueOnce(jsonResponse({ url: expected })); - - const client = makeClient(); - const result = await client.getDatabaseUrl(projectId); - - expect(result).toEqual({ url: expected }); - }); - - it('throws PlatformApiError on 404 (database_url_not_stored)', async () => { - fetchMock.mockResolvedValue( - jsonResponse({ error: 'database_url_not_stored', message: 'No database URL stored.' }, 404), - ); - - const client = makeClient(); - const err = await client.getDatabaseUrl(projectId).catch((e: unknown) => e); - expect(err).toBeInstanceOf(PlatformApiError); - expect((err as PlatformApiError).status).toBe(404); - }); - - it('throws PlatformApiError on 403 (ownership check failure)', async () => { - fetchMock.mockResolvedValue(jsonResponse({ error: 'forbidden' }, 403)); - - const client = makeClient(); - const err = await client.getDatabaseUrl(projectId).catch((e: unknown) => e); - expect(err).toBeInstanceOf(PlatformApiError); - expect((err as PlatformApiError).status).toBe(403); - }); - - it('does not send a request body', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ url: 'postgres://host/db' })); - - const client = makeClient(); - await client.getDatabaseUrl(projectId); - - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.body).toBeUndefined(); + expect(result).toEqual({ url: expected }); }); }); diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts index 2f18292e2..5d49dee6f 100644 --- a/packages/void/test/unit/github-client.test.ts +++ b/packages/void/test/unit/github-client.test.ts @@ -1,9 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { - PlatformClient, - type GithubConnection, - type GithubInstallation, -} from '../../src/cli/client.ts'; +import { PlatformClient, type GithubInstallation } from '../../src/cli/client.ts'; +import { makeConnection, projectId } from '../helpers/github-cli.ts'; import { withPlatformDiscovery } from '../helpers/platform-discovery.ts'; const fetchMock = vi.fn(); @@ -23,7 +20,6 @@ afterEach(() => { const apiUrl = 'https://api.test'; const token = 'auth-token-x'; -const projectId = 'proj_test'; function makeClient(): PlatformClient { return new PlatformClient(token, { apiUrl }); @@ -98,17 +94,7 @@ describe('PlatformClient.listGithubInstallations', () => { describe('PlatformClient.connectGithub', () => { it('POSTs the right URL with Content-Type and exact JSON body, returns parsed connection', async () => { - const connection: GithubConnection = { - id: 'conn_1', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; + const connection = makeConnection(); fetchMock.mockResolvedValueOnce(jsonResponse(connection)); @@ -135,85 +121,6 @@ describe('PlatformClient.connectGithub', () => { }); expect(result).toEqual(connection); }); - - it('omits optional fields from JSON body when not provided', async () => { - const connection: GithubConnection = { - id: 'conn_2', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce(jsonResponse(connection)); - - const client = makeClient(); - await client.connectGithub(projectId, { - installationId: '123456', - repoFullName: 'octocat/hello-world', - }); - - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toEqual({ - installationId: '123456', - repoFullName: 'octocat/hello-world', - }); - }); - - it('includes connectGrant + codeVerifier in the JSON body when provided', async () => { - const connection: GithubConnection = { - id: 'conn_3', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce(jsonResponse(connection)); - - const client = makeClient(); - const result = await client.connectGithub(projectId, { - installationId: '123456', - repoFullName: 'octocat/hello-world', - branch: 'main', - buildExecutor: 'container', - connectGrant: 'grant-abc', - codeVerifier: 'verifier-abc', - }); - - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toEqual({ - installationId: '123456', - repoFullName: 'octocat/hello-world', - branch: 'main', - buildExecutor: 'container', - connectGrant: 'grant-abc', - codeVerifier: 'verifier-abc', - }); - expect(result).toEqual(connection); - }); - - it('throws PlatformApiError on non-2xx', async () => { - fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 })); - const client = makeClient(); - await expect( - client.connectGithub(projectId, { - installationId: '123456', - repoFullName: 'octocat/hello-world', - }), - ).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 404, - }); - }); }); describe('PlatformClient.getGithubConnectAuthorizeUrl', () => { @@ -261,17 +168,7 @@ describe('PlatformClient.getGithubConnectAuthorizeUrl', () => { describe('PlatformClient.getGithubConnection', () => { it('GETs the right URL, sends auth headers, returns the parsed connection', async () => { - const connection: GithubConnection = { - id: 'conn_1', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; + const connection = makeConnection(); fetchMock.mockResolvedValueOnce(jsonResponse(connection)); @@ -286,31 +183,11 @@ describe('PlatformClient.getGithubConnection', () => { expect(headers.Authorization).toBe(`Bearer ${token}`); expect(result).toEqual(connection); }); - - it('throws PlatformApiError on non-2xx (e.g. 404 connection not found)', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'connection not found' }, 404)); - const client = makeClient(); - await expect(client.getGithubConnection(projectId)).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 404, - code: 'connection not found', - }); - }); }); describe('PlatformClient.updateGithubConnection', () => { it('PATCHes the right URL with Content-Type and exact JSON body, returns parsed connection', async () => { - const connection: GithubConnection = { - id: 'conn_1', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'release', - build_executor: 'github_actions', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', - }; + const connection = makeConnection({ branch: 'release', build_executor: 'github_actions' }); fetchMock.mockResolvedValueOnce(jsonResponse(connection)); @@ -333,39 +210,6 @@ describe('PlatformClient.updateGithubConnection', () => { }); expect(result).toEqual(connection); }); - - it('sends only the fields provided (executor only)', async () => { - const connection: GithubConnection = { - id: 'conn_2', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'github_actions', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', - }; - - fetchMock.mockResolvedValueOnce(jsonResponse(connection)); - - const client = makeClient(); - await client.updateGithubConnection(projectId, { buildExecutor: 'github_actions' }); - - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toEqual({ buildExecutor: 'github_actions' }); - }); - - it('throws PlatformApiError on non-2xx', async () => { - fetchMock.mockResolvedValueOnce(new Response('not found', { status: 404 })); - const client = makeClient(); - await expect( - client.updateGithubConnection(projectId, { branch: 'main' }), - ).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 404, - }); - }); }); describe('PlatformClient.deleteGithubConnection', () => { @@ -384,15 +228,6 @@ describe('PlatformClient.deleteGithubConnection', () => { expect(headers.Authorization).toBe(`Bearer ${token}`); expect(result).toEqual({ ok: true }); }); - - it('throws PlatformApiError on non-2xx', async () => { - fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); - const client = makeClient(); - await expect(client.deleteGithubConnection(projectId)).rejects.toMatchObject({ - name: 'PlatformApiError', - status: 403, - }); - }); }); describe('PlatformClient.getGithubLinkUrl', () => { @@ -451,13 +286,6 @@ describe('PlatformClient.completeGithubLink', () => { expect(result).toEqual({ status: 'linked' }); }); - it('returns already_linked status', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'already_linked' })); - const client = makeClient(); - const result = await client.completeGithubLink('link-code-2', 'verifier-2'); - expect(result).toEqual({ status: 'already_linked' }); - }); - it('throws PlatformApiError with status 409 and code on github_identity_conflict', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'github_identity_conflict' }, 409)); const client = makeClient(); @@ -526,13 +354,6 @@ describe('PlatformClient.completeGithubJoin', () => { expect(result).toEqual({ status: 'joined', count: 3 }); }); - it('returns a zero count', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'joined', count: 0 })); - const client = makeClient(); - const result = await client.completeGithubJoin('join-code-2', 'verifier-2'); - expect(result).toEqual({ status: 'joined', count: 0 }); - }); - it('throws PlatformApiError on non-2xx', async () => { fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })); const client = makeClient(); diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index 38e666d5e..6ebb90759 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -3,22 +3,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createCodeChallenge } from '../../src/cli/auth.ts'; import { PlatformApiError, - type GithubConnection, type GithubInstallation, type PlatformClient, - type ProjectResponse, } from '../../src/cli/client.ts'; import * as gitMetadata from '../../src/cli/git-metadata.ts'; import type { GithubArgs } from '../../src/cli/github-args.ts'; import { runConnect } from '../../src/cli/github-cmd.ts'; import * as output from '../../src/cli/output.ts'; +import { + browserCallback, + makeConnection, + makeProject, + projectId, + slug, + throwOnProcessExit, +} from '../helpers/github-cli.ts'; -// Mock node:child_process so openBrowser (called by awaitBrowserCallback) never -// launches a real browser; the localhost callback is driven directly by fetch. -const mockExecFile = vi.fn(); -vi.mock('node:child_process', () => ({ - execFile: (...args: Array) => mockExecFile(...args), -})); +// Keep openBrowser from launching a real browser. +vi.mock('node:child_process', () => ({ execFile: vi.fn() })); // Partial mock of @voidzero-dev/vite-plus-prompts: only `text` is replaced (for the repo // prompt); everything else — including `log`, which the spies below target — @@ -29,25 +31,9 @@ vi.mock('@voidzero-dev/vite-plus-prompts', async (importOriginal) => { return { ...actual, text: (...args: Array) => mockText(...args) }; }); -const originalFetch = globalThis.fetch.bind(globalThis); - -const projectId = 'proj_test'; -const slug = 'my-app'; const installationId = '123456'; const repoFullName = 'octocat/hello-world'; -function makeProject(): ProjectResponse { - return { - id: projectId, - user_id: 'user_1', - slug, - type: 'void', - created_at: '2024-01-01T00:00:00Z', - custom_domain: null, - role: 'owner', - }; -} - function makeInstallation(): GithubInstallation { return { id: 'install_1', @@ -63,21 +49,6 @@ function makeInstallation(): GithubInstallation { }; } -function makeConnection(overrides: Partial = {}): GithubConnection { - return { - id: 'conn_1', - project_id: projectId, - installation_id: installationId, - repo_full_name: repoFullName, - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - ...overrides, - }; -} - function authRequired(): PlatformApiError { return new PlatformApiError( 'Failed to connect GitHub repository', @@ -95,15 +66,12 @@ const baseArgs: Extract = { }; /** - * Drive runConnect against a fake client. `getGithubConnectAuthorizeUrl` (the - * round-trip URL builder) resolves the localhost callback URL the moment the - * command requests it, so the test can POST back to the callback with whatever - * query it wants (mirroring the link/join command tests). When `callbackQuery` - * is omitted the round-trip is never expected to start, so no callback fires. + * Runs runConnect against a fake client. `callbackQuery` and `joinCallbackQuery` + * build the browser redirects for the connect-authorize and join round-trips; + * when one is omitted, that round-trip sends no redirect. */ function driveConnect(opts: { connectGithub: ReturnType; - getGithubConnectAuthorizeUrl?: ReturnType; callbackQuery?: (callbackUrl: URL) => URL; listGithubInstallations?: ReturnType; getGithubJoinUrl?: ReturnType; @@ -118,33 +86,11 @@ function driveConnect(opts: { getGithubJoinUrl: ReturnType; completeGithubJoin: ReturnType; } { - let resolveCallback: ((url: URL) => void) | undefined; - const callbackReady = new Promise((resolve) => { - resolveCallback = resolve; - }); - - const getGithubConnectAuthorizeUrl = - opts.getGithubConnectAuthorizeUrl ?? - vi.fn(async (redirect: string) => { - // `redirect` is the localhost callback URL (with the command's state). - resolveCallback?.(new URL(redirect)); - return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; - }); - - let resolveJoinCallback: ((url: URL) => void) | undefined; - const joinCallbackReady = new Promise((resolve) => { - resolveJoinCallback = resolve; - }); - - const getGithubJoinUrl = - opts.getGithubJoinUrl ?? - vi.fn(async (redirect: string) => { - resolveJoinCallback?.(new URL(redirect)); - return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; - }); + const connect = browserCallback(opts.callbackQuery); + const join = browserCallback(opts.joinCallbackQuery); + const getGithubJoinUrl = opts.getGithubJoinUrl ?? join.authUrl; const completeGithubJoin = opts.completeGithubJoin ?? vi.fn(async () => ({ status: 'joined', count: 0 })); - const listGithubInstallations = opts.listGithubInstallations ?? vi.fn(async () => [makeInstallation()]); @@ -152,34 +98,17 @@ function driveConnect(opts: { listProjects: vi.fn(async () => [makeProject()]), listGithubInstallations, connectGithub: opts.connectGithub, - getGithubConnectAuthorizeUrl, + getGithubConnectAuthorizeUrl: connect.authUrl, getGithubJoinUrl, completeGithubJoin, } as unknown as PlatformClient; const run = runConnect('/project', client, { ...baseArgs, ...opts.args }); - // Fire the simulated browser redirect once the round-trip's callback URL is - // known. Swallow fetch errors: on the error paths the server is torn down - // mid-response (process.exit / throw), which can surface as ECONNRESET. - const fired = opts.callbackQuery - ? callbackReady.then(async (callbackUrl) => { - const hit = opts.callbackQuery!(callbackUrl); - await originalFetch(hit).catch(() => {}); - }) - : Promise.resolve(); - - const joinFired = opts.joinCallbackQuery - ? joinCallbackReady.then(async (callbackUrl) => { - const hit = opts.joinCallbackQuery!(callbackUrl); - await originalFetch(hit).catch(() => {}); - }) - : Promise.resolve(); - return { - promise: run.finally(() => fired.catch(() => {})).finally(() => joinFired.catch(() => {})), + promise: join.settle(connect.settle(run)), connectGithub: opts.connectGithub, - getGithubConnectAuthorizeUrl, + getGithubConnectAuthorizeUrl: connect.authUrl, listGithubInstallations, getGithubJoinUrl, completeGithubJoin, @@ -194,15 +123,12 @@ let infoSpy: ReturnType; const originalIsTTY = process.stdin.isTTY; beforeEach(() => { - mockExecFile.mockReset(); mockText.mockReset(); // A CI checkout must not supply a default repository for these prompt fixtures. vi.stubEnv('GITHUB_REPOSITORY', undefined); // Force the non-interactive (non-TTY) path so tests never hit clack prompts. (process.stdin as { isTTY?: boolean }).isTTY = false; - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); + exitSpy = throwOnProcessExit(); successSpy = vi.spyOn(output, 'printSuccess').mockImplementation(() => {}); errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); messageSpy = vi.spyOn(log, 'message').mockImplementation(() => {}); diff --git a/packages/void/test/unit/github-disconnect-cmd.test.ts b/packages/void/test/unit/github-disconnect-cmd.test.ts index 024597016..055e43977 100644 --- a/packages/void/test/unit/github-disconnect-cmd.test.ts +++ b/packages/void/test/unit/github-disconnect-cmd.test.ts @@ -1,12 +1,9 @@ import { confirm, log } from '@voidzero-dev/vite-plus-prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { - PlatformApiError, - type GithubConnection, - type PlatformClient, -} from '../../src/cli/client.ts'; +import { PlatformApiError, type PlatformClient } from '../../src/cli/client.ts'; import { runGithubDisconnect } from '../../src/cli/github-cmd.ts'; import * as output from '../../src/cli/output.ts'; +import { makeConnection, makeProject, projectId, slug } from '../helpers/github-cli.ts'; // `vi.spyOn(clack, 'confirm')` cannot intercept the handler's named `confirm` // import under this project's ESM Vitest config ("Module namespace is not @@ -18,30 +15,6 @@ vi.mock('@voidzero-dev/vite-plus-prompts', async (imp) => ({ confirm: vi.fn(), })); -const projectId = 'proj_1'; -const slug = 'my-app'; - -function makeProject() { - return { id: projectId, slug } as unknown as Awaited< - ReturnType - >[number]; -} - -function makeConnection(overrides: Partial = {}): GithubConnection { - return { - id: 'conn_1', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', - ...overrides, - }; -} - type FakeClient = Pick< PlatformClient, 'listProjects' | 'getGithubConnection' | 'deleteGithubConnection' diff --git a/packages/void/test/unit/github-install-cmd.test.ts b/packages/void/test/unit/github-install-cmd.test.ts index 7094af5ab..04c202520 100644 --- a/packages/void/test/unit/github-install-cmd.test.ts +++ b/packages/void/test/unit/github-install-cmd.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformApiError, type PlatformClient } from '../../src/cli/client.ts'; import { runInstall } from '../../src/cli/github-cmd.ts'; +import { throwOnProcessExit } from '../helpers/github-cli.ts'; -// Mock node:child_process so openBrowser never launches a real browser on the -// success path. +// Keep openBrowser from launching a real browser. const mockExecFile = vi.fn(); vi.mock('node:child_process', () => ({ execFile: (...args: Array) => mockExecFile(...args), @@ -21,11 +21,7 @@ function identityRequired(): PlatformApiError { beforeEach(() => { mockExecFile.mockReset(); - // process.exit -> throw so we can assert the error-path exit without killing - // the test runner. - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); + exitSpy = throwOnProcessExit(); }); afterEach(() => { @@ -70,20 +66,6 @@ describe('runInstall', () => { expect(exitSpy).not.toHaveBeenCalled(); }); - it('rethrows a non-403 error without auto-linking', async () => { - const err = new PlatformApiError('Failed to get GitHub install URL', 500, 'boom'); - const getGithubInstallUrl = vi.fn().mockRejectedValue(err); - const client = { getGithubInstallUrl } as unknown as PlatformClient; - const linkIdentity = vi.fn(async () => {}); - - await expect(runInstall(client, linkIdentity)).rejects.toBe(err); - - expect(linkIdentity).not.toHaveBeenCalled(); - expect(getGithubInstallUrl).toHaveBeenCalledTimes(1); - expect(mockExecFile).not.toHaveBeenCalled(); - expect(exitSpy).not.toHaveBeenCalled(); - }); - it('exits(1) with actionable guidance when the retry still fails after linking', async () => { const getGithubInstallUrl = vi .fn() @@ -100,10 +82,7 @@ describe('runInstall', () => { expect(exitSpy).toHaveBeenCalledWith(1); }); - it('rethrows a non-identity error from the post-link retry (F4)', async () => { - // After linking, the install URL fails again — but with a DIFFERENT error - // (a 500). The generic "try void github link" guidance must NOT swallow it; - // the real error has to surface through normal handling. + it('rethrows a non-identity error from the post-link retry', async () => { const retryErr = new PlatformApiError('Failed to get GitHub install URL', 500, 'boom'); const getGithubInstallUrl = vi .fn() @@ -119,24 +98,4 @@ describe('runInstall', () => { expect(mockExecFile).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); }); - - it('rethrows a 426 cli_outdated from the post-link retry (F4)', async () => { - const retryErr = new PlatformApiError( - 'Failed to get GitHub install URL', - 426, - JSON.stringify({ error: 'cli_outdated', message: 'Upgrade: npm i -g void@latest' }), - ); - const getGithubInstallUrl = vi - .fn() - .mockRejectedValueOnce(identityRequired()) - .mockRejectedValueOnce(retryErr); - const client = { getGithubInstallUrl } as unknown as PlatformClient; - const linkIdentity = vi.fn(async () => {}); - - await expect(runInstall(client, linkIdentity)).rejects.toBe(retryErr); - - expect(linkIdentity).toHaveBeenCalledTimes(1); - expect(getGithubInstallUrl).toHaveBeenCalledTimes(2); - expect(exitSpy).not.toHaveBeenCalled(); - }); }); diff --git a/packages/void/test/unit/github-join-cmd.test.ts b/packages/void/test/unit/github-join-cmd.test.ts index edd57584b..cbe782d0f 100644 --- a/packages/void/test/unit/github-join-cmd.test.ts +++ b/packages/void/test/unit/github-join-cmd.test.ts @@ -3,27 +3,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PlatformApiError, type PlatformClient } from '../../src/cli/client.ts'; import { runGithubJoin } from '../../src/cli/github-cmd.ts'; import * as output from '../../src/cli/output.ts'; +import { browserCallback, throwOnProcessExit } from '../helpers/github-cli.ts'; -// Mock node:child_process so openBrowser never launches a real browser; the -// localhost callback server is driven directly by fetch in each test. -const mockExecFile = vi.fn(); -vi.mock('node:child_process', () => ({ - execFile: (...args: Array) => mockExecFile(...args), -})); - -const originalFetch = globalThis.fetch.bind(globalThis); +// Keep openBrowser from launching a real browser. +vi.mock('node:child_process', () => ({ execFile: vi.fn() })); type FakeClient = Pick; let exitSpy: ReturnType; beforeEach(() => { - mockExecFile.mockReset(); - // process.exit -> throw so we can assert the error-path exit without killing - // the test runner. - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); + exitSpy = throwOnProcessExit(); }); afterEach(() => { @@ -31,46 +21,17 @@ afterEach(() => { vi.unstubAllGlobals(); }); -/** - * Run runGithubJoin against a fake client, intercepting the authorize URL the - * command requests (via the fake getGithubJoinUrl) so the test can POST back to - * the localhost callback with whatever query it wants. - * - * `completeGithubJoin` and the callback query are configurable per test. - */ +/** Runs runGithubJoin and sends the callback redirect that `callbackQuery` builds. */ function drive(opts: { completeGithubJoin: FakeClient['completeGithubJoin']; callbackQuery: (callbackUrl: URL) => URL; }): Promise { - let resolveCallback!: (url: URL) => void; - const callbackReady = new Promise((resolve) => { - resolveCallback = resolve; - }); - + const callback = browserCallback(opts.callbackQuery); const client: FakeClient = { - getGithubJoinUrl: vi.fn(async (redirect: string) => { - // `redirect` is the localhost callback URL (with the command's state). - resolveCallback(new URL(redirect)); - return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; - }), + getGithubJoinUrl: callback.authUrl, completeGithubJoin: opts.completeGithubJoin, }; - - const run = runGithubJoin(client as unknown as PlatformClient); - - // Once the command has built the callback URL, fire the simulated browser - // redirect back at the localhost server. Swallow fetch errors: on the - // error paths the server is torn down mid-response (process.exit / throw), - // which can surface as ECONNRESET on the in-flight request — irrelevant to - // what we're asserting (the command's outcome). - const fired = callbackReady.then(async (callbackUrl) => { - const hit = opts.callbackQuery(callbackUrl); - await originalFetch(hit).catch(() => {}); - }); - - // Settle on the command's outcome, but make sure the fetch is also drained so - // no dangling promise leaks across tests. - return run.finally(() => fired.catch(() => {})); + return callback.settle(runGithubJoin(client as unknown as PlatformClient)); } describe('runGithubJoin', () => { diff --git a/packages/void/test/unit/github-link-cmd.test.ts b/packages/void/test/unit/github-link-cmd.test.ts index e70dc2b87..75c3e9d01 100644 --- a/packages/void/test/unit/github-link-cmd.test.ts +++ b/packages/void/test/unit/github-link-cmd.test.ts @@ -2,27 +2,17 @@ import { log } from '@voidzero-dev/vite-plus-prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { PlatformApiError, type PlatformClient } from '../../src/cli/client.ts'; import { runGithubLink } from '../../src/cli/github-cmd.ts'; +import { browserCallback, throwOnProcessExit } from '../helpers/github-cli.ts'; -// Mock node:child_process so openBrowser never launches a real browser; the -// localhost callback server is driven directly by fetch in each test. -const mockExecFile = vi.fn(); -vi.mock('node:child_process', () => ({ - execFile: (...args: Array) => mockExecFile(...args), -})); - -const originalFetch = globalThis.fetch.bind(globalThis); +// Keep openBrowser from launching a real browser. +vi.mock('node:child_process', () => ({ execFile: vi.fn() })); type FakeClient = Pick; let exitSpy: ReturnType; beforeEach(() => { - mockExecFile.mockReset(); - // process.exit -> throw so we can assert the error-path exit without killing - // the test runner. - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); + exitSpy = throwOnProcessExit(); }); afterEach(() => { @@ -30,46 +20,17 @@ afterEach(() => { vi.unstubAllGlobals(); }); -/** - * Run runGithubLink against a fake client, intercepting the authorize URL the - * command requests (via the fake getGithubLinkUrl) so the test can POST back to - * the localhost callback with whatever query it wants. - * - * `completeGithubLink` and the callback query are configurable per test. - */ +/** Runs runGithubLink and sends the callback redirect that `callbackQuery` builds. */ function drive(opts: { completeGithubLink: FakeClient['completeGithubLink']; callbackQuery: (callbackUrl: URL) => URL; }): Promise { - let resolveCallback!: (url: URL) => void; - const callbackReady = new Promise((resolve) => { - resolveCallback = resolve; - }); - + const callback = browserCallback(opts.callbackQuery); const client: FakeClient = { - getGithubLinkUrl: vi.fn(async (redirect: string) => { - // `redirect` is the localhost callback URL (with the command's state). - resolveCallback(new URL(redirect)); - return { url: 'https://github.com/login/oauth/authorize?client_id=x' }; - }), + getGithubLinkUrl: callback.authUrl, completeGithubLink: opts.completeGithubLink, }; - - const run = runGithubLink(client as unknown as PlatformClient); - - // Once the command has built the callback URL, fire the simulated browser - // redirect back at the localhost server. Swallow fetch errors: on the - // error paths the server is torn down mid-response (process.exit / throw), - // which can surface as ECONNRESET on the in-flight request — irrelevant to - // what we're asserting (the command's outcome). - const fired = callbackReady.then(async (callbackUrl) => { - const hit = opts.callbackQuery(callbackUrl); - await originalFetch(hit).catch(() => {}); - }); - - // Settle on the command's outcome, but make sure the fetch is also drained so - // no dangling promise leaks across tests. - return run.finally(() => fired.catch(() => {})); + return callback.settle(runGithubLink(client as unknown as PlatformClient)); } describe('runGithubLink', () => { diff --git a/packages/void/test/unit/github-oidc.test.ts b/packages/void/test/unit/github-oidc.test.ts index b1fb698c8..a1e2ab585 100644 --- a/packages/void/test/unit/github-oidc.test.ts +++ b/packages/void/test/unit/github-oidc.test.ts @@ -39,14 +39,6 @@ function jsonResponse(body: unknown, status = 200): Response { const REQUEST_URL = 'https://token.actions.githubusercontent.com/abc?api-version=2.0'; const REQUEST_TOKEN = 'req-token-123'; -function restoreEnv(key: string, prev: string | undefined): void { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } -} - function oidcEnv(overrides: Record = {}): NodeJS.ProcessEnv { return { GITHUB_ACTIONS: 'true', @@ -117,104 +109,56 @@ describe('exchangeGithubOidcForDeployToken', () => { it('forwards CF Access service-token headers on the API POST but not the GitHub GET', async () => { const gatedApiUrl = 'https://api.staging.void.cloud'; vi.stubEnv('VOID_ACCESS_ORIGIN', gatedApiUrl); - const prevId = process.env.CF_ACCESS_CLIENT_ID; - const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET; - process.env.CF_ACCESS_CLIENT_ID = 'cf-id'; - process.env.CF_ACCESS_CLIENT_SECRET = 'cf-secret'; - try { - fetchMock - .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) - .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); - - await exchangeGithubOidcForDeployToken(gatedApiUrl, 'my-app', oidcEnv()); - - // GitHub OIDC GET hits token.actions.githubusercontent.com — never CF Access. - const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; - const getHeaders = getInit.headers as Record; - expect(getHeaders['CF-Access-Client-Id']).toBeUndefined(); - expect(getHeaders['CF-Access-Client-Secret']).toBeUndefined(); - - // Void API POST carries the service-token pair so Access-gated staging passes. - const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; - const postHeaders = postInit.headers as Record; - expect(postHeaders['CF-Access-Client-Id']).toBe('cf-id'); - expect(postHeaders['CF-Access-Client-Secret']).toBe('cf-secret'); - } finally { - restoreEnv('CF_ACCESS_CLIENT_ID', prevId); - restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret); - } + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'cf-id'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'cf-secret'); + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken(gatedApiUrl, 'my-app', oidcEnv()); + + // The GitHub OIDC GET goes to token.actions.githubusercontent.com, never through CF Access. + const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; + const getHeaders = getInit.headers as Record; + expect(getHeaders['CF-Access-Client-Id']).toBeUndefined(); + expect(getHeaders['CF-Access-Client-Secret']).toBeUndefined(); + + const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + const postHeaders = postInit.headers as Record; + expect(postHeaders['CF-Access-Client-Id']).toBe('cf-id'); + expect(postHeaders['CF-Access-Client-Secret']).toBe('cf-secret'); }); it('forwards a CF_ACCESS_TOKEN user JWT on the API POST but not the GitHub GET', async () => { const gatedApiUrl = 'https://api.staging.void.cloud'; vi.stubEnv('VOID_ACCESS_ORIGIN', gatedApiUrl); - const prevToken = process.env.CF_ACCESS_TOKEN; - process.env.CF_ACCESS_TOKEN = accessJwt; - try { - fetchMock - .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) - .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); - - await exchangeGithubOidcForDeployToken(gatedApiUrl, 'my-app', oidcEnv()); - - const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; - expect((getInit.headers as Record)['cf-access-token']).toBeUndefined(); - - const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; - expect((postInit.headers as Record)['cf-access-token']).toBe(accessJwt); - } finally { - restoreEnv('CF_ACCESS_TOKEN', prevToken); - } + vi.stubEnv('CF_ACCESS_TOKEN', accessJwt); + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken(gatedApiUrl, 'my-app', oidcEnv()); + + const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect((getInit.headers as Record)['cf-access-token']).toBeUndefined(); + + const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect((postInit.headers as Record)['cf-access-token']).toBe(accessJwt); }); it('never forwards shared Access credentials to a self-hosted API', async () => { - const prevId = process.env.CF_ACCESS_CLIENT_ID; - const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET; - process.env.CF_ACCESS_CLIENT_ID = 'cf-id'; - process.env.CF_ACCESS_CLIENT_SECRET = 'cf-secret'; - try { - fetchMock - .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) - .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); - - await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); - - const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; - const postHeaders = postInit.headers as Record; - expect(postHeaders['CF-Access-Client-Id']).toBeUndefined(); - expect(postHeaders['CF-Access-Client-Secret']).toBeUndefined(); - } finally { - restoreEnv('CF_ACCESS_CLIENT_ID', prevId); - restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret); - } - }); + vi.stubEnv('CF_ACCESS_CLIENT_ID', 'cf-id'); + vi.stubEnv('CF_ACCESS_CLIENT_SECRET', 'cf-secret'); + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); - it('omits CF Access headers on the API POST when no CF Access env is set (prod path)', async () => { - const prevId = process.env.CF_ACCESS_CLIENT_ID; - const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET; - const prevToken = process.env.CF_ACCESS_TOKEN; - delete process.env.CF_ACCESS_CLIENT_ID; - delete process.env.CF_ACCESS_CLIENT_SECRET; - delete process.env.CF_ACCESS_TOKEN; - try { - fetchMock - .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) - .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); - - await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); - - const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; - const postHeaders = postInit.headers as Record; - expect(postHeaders['CF-Access-Client-Id']).toBeUndefined(); - expect(postHeaders['CF-Access-Client-Secret']).toBeUndefined(); - expect(postHeaders[accessJwt]).toBeUndefined(); - // The plain Content-Type header is still present. - expect(postHeaders['Content-Type']).toBe('application/json'); - } finally { - restoreEnv('CF_ACCESS_CLIENT_ID', prevId); - restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret); - restoreEnv('CF_ACCESS_TOKEN', prevToken); - } + const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + const postHeaders = postInit.headers as Record; + expect(postHeaders['CF-Access-Client-Id']).toBeUndefined(); + expect(postHeaders['CF-Access-Client-Secret']).toBeUndefined(); }); it('throws before any fetch when the request vars are absent', async () => { diff --git a/packages/void/test/unit/github-update-cmd.test.ts b/packages/void/test/unit/github-update-cmd.test.ts index 8bf4286b0..fe259a7c5 100644 --- a/packages/void/test/unit/github-update-cmd.test.ts +++ b/packages/void/test/unit/github-update-cmd.test.ts @@ -1,43 +1,15 @@ import { log } from '@voidzero-dev/vite-plus-prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { - PlatformApiError, - type GithubConnection, - type PlatformClient, - type ProjectResponse, -} from '../../src/cli/client.ts'; +import { PlatformApiError, type PlatformClient } from '../../src/cli/client.ts'; import { runGithubUpdate } from '../../src/cli/github-cmd.ts'; import * as output from '../../src/cli/output.ts'; - -const projectId = 'proj_test'; -const slug = 'my-app'; - -function makeProject(): ProjectResponse { - return { - id: projectId, - user_id: 'user_1', - slug, - type: 'void', - created_at: '2024-01-01T00:00:00Z', - custom_domain: null, - role: 'owner', - }; -} - -function makeConnection(overrides: Partial = {}): GithubConnection { - return { - id: 'conn_1', - project_id: projectId, - installation_id: '123456', - repo_full_name: 'octocat/hello-world', - branch: 'main', - build_executor: 'container', - workflow_path: '.github/workflows/void-deploy.yml', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - ...overrides, - }; -} +import { + makeConnection, + makeProject, + projectId, + slug, + throwOnProcessExit, +} from '../helpers/github-cli.ts'; type FakeClient = Pick< PlatformClient, @@ -78,11 +50,7 @@ const originalIsTTY = process.stdin.isTTY; beforeEach(() => { // Force the non-interactive (non-TTY) path so tests never hit clack prompts. (process.stdin as { isTTY?: boolean }).isTTY = false; - // process.exit -> throw so we can assert the error-path exit without killing - // the test runner. - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code})`); - }) as never); + exitSpy = throwOnProcessExit(); successSpy = vi.spyOn(output, 'printSuccess').mockImplementation(() => {}); infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {}); @@ -242,35 +210,6 @@ describe('runGithubUpdate (non-TTY)', () => { expect(updateGithubConnection).not.toHaveBeenCalled(); }); - it('reports missing flags (not "connect first") for a disconnected project', async () => { - // If the guard ran AFTER getGithubConnection, this 404 would surface the - // "connect first" message instead of the missing-flags usage error. - const { client, listProjects, getGithubConnection, updateGithubConnection } = makeClient({ - getGithubConnection: vi.fn(async () => { - throw new PlatformApiError( - 'Failed to get GitHub connection', - 404, - JSON.stringify({ error: 'connection not found' }), - ); - }), - }); - - await expect( - runGithubUpdate('/project', client, { - subcommand: 'update', - projectName: slug, - }), - ).rejects.toThrow('process.exit(1)'); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - 'update: Nothing to update. Pass --branch , --executor , and/or --workflow <.github/workflows/...>.', - ); - expect(listProjects).not.toHaveBeenCalled(); - expect(getGithubConnection).not.toHaveBeenCalled(); - expect(updateGithubConnection).not.toHaveBeenCalled(); - }); - it('does not call the API when the requested values equal the current ones', async () => { const { client, updateGithubConnection } = makeClient({ getGithubConnection: vi.fn(async () => diff --git a/packages/void/test/unit/login-command.test.ts b/packages/void/test/unit/login-command.test.ts index 07e61812e..ae23da531 100644 --- a/packages/void/test/unit/login-command.test.ts +++ b/packages/void/test/unit/login-command.test.ts @@ -52,7 +52,6 @@ vi.mock('../../src/cli/auth', () => ({ removeToken: (...args: Array) => mockRemoveToken(...args), runPlatformLogin: (...args: Array) => mockRunPlatformLogin(...args), savePlatformToken: (...args: Array) => mockSavePlatformToken(...args), - isStagingMode: () => false, })); vi.mock('../../src/cli/platform-cmd', () => ({ @@ -99,9 +98,9 @@ beforeEach(() => { mockIsExpiredTokenError.mockReturnValue(false); }); -describe('runLoginCommand', () => { - const root = '/project'; +const root = '/project'; +describe('promptForLoginToken', () => { it('logs into the explicit platform with its sole provider and scopes token storage', async () => { await promptForLoginToken(root, { apiUrl: 'https://company.example.com', @@ -118,7 +117,9 @@ describe('runLoginCommand', () => { 'https://company.example.com', ); }); +}); +describe('runLoginCommand', () => { it('keeps the logout-first flow when the stored token is still valid', async () => { mockGetToken.mockReturnValue('existing-token'); mockWhoami.mockResolvedValue({ login: 'cpojer', plan: 'pro' }); diff --git a/packages/void/test/unit/logs-filter.test.ts b/packages/void/test/unit/logs-filter.test.ts index d8a4b047c..3fc5587be 100644 --- a/packages/void/test/unit/logs-filter.test.ts +++ b/packages/void/test/unit/logs-filter.test.ts @@ -107,8 +107,6 @@ describe('filterLogEntries', () => { }); it("--level log matches entries whose log line has no level (printEntry renders those as '[log]')", () => { - // `printEntry` falls back to 'log' for nullish levels; the filter must - // do the same so that `--level log` matches what the renderer shows. const E_NULLISH_LEVEL = entry({ // Cast covers the user-DTO case where `level` is missing or undefined. logs: [ @@ -129,10 +127,7 @@ describe('filterLogEntries', () => { expect(filterLogEntries([E_OK, E_EXCEPTION], undefined, 'TypeError')).toEqual([E_EXCEPTION]); }); - it('substring filter "error" no longer conflates levels — only matches messages containing the literal word', () => { - // Older behavior: --filter error would match every error-level line because - // "[error]" appeared in renderings. The new --filter is a clean substring - // search over the raw message text and exception name/message. + it('substring filter "error" matches message text, not the log level', () => { const errorWordEntry = entry({ logs: [{ level: 'info', message: ['this is an error in the message'], timestamp: 0 }], }); @@ -141,18 +136,30 @@ describe('filterLogEntries', () => { undefined, 'error', ); - // E_ERROR_LOG message is "boom" — no literal "error" substring, so it's filtered out. - // Only entries whose text literally contains "error" come through. expect(result).toEqual([errorWordEntry]); }); it('--level error AND --filter websocket: ANDs both conditions', () => { const result = filterLogEntries([E_OK, E_ERROR_LOG, E_MIXED, E_INFO_LOG], 'error', 'websocket'); - // Only E_MIXED has both an error-level log AND a "websocket" substring. expect(result).toEqual([E_MIXED]); }); }); +function setFetchMock() { + const fetchMock: ReturnType = vi.fn(async () => Response.json({ data: [] })); + globalThis.fetch = withPlatformDiscovery(fetchMock); + return fetchMock; +} + +/** The API request URL; the platform discovery request never reaches the mock. */ +function requestUrl(fetchMock: ReturnType): string { + const call = fetchMock.mock.calls[0]; + if (!call) { + throw new Error('fetch was not called'); + } + return String(call[0]); +} + describe('PlatformClient.getLogs URL', () => { const originalFetch = globalThis.fetch; @@ -161,27 +168,12 @@ describe('PlatformClient.getLogs URL', () => { vi.restoreAllMocks(); }); - function setFetchMock() { - const fetchMock: ReturnType = vi.fn(async () => Response.json({ data: [] })); - globalThis.fetch = withPlatformDiscovery(fetchMock); - return fetchMock; - } - - function lastUrl(fetchMock: ReturnType): string { - const calls = fetchMock.mock.calls as Array>; - if (calls.length === 0) { - throw new Error('fetch was not called'); - } - const first = calls[0]?.[0]; - return String(first); - } - it('omits level when not provided', async () => { const fetchMock = setFetchMock(); const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getLogs('proj_1', '1h'); - const url = lastUrl(fetchMock); + const url = requestUrl(fetchMock); expect(url).toContain('since=1h'); expect(url).not.toContain('level='); }); @@ -191,7 +183,7 @@ describe('PlatformClient.getLogs URL', () => { const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getLogs('proj_1', '1h', 200, undefined, 'all'); - expect(lastUrl(fetchMock)).not.toContain('level='); + expect(requestUrl(fetchMock)).not.toContain('level='); }); it("appends level=error when level === 'error'", async () => { @@ -199,7 +191,7 @@ describe('PlatformClient.getLogs URL', () => { const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getLogs('proj_1', '1h', 200, undefined, 'error'); - expect(lastUrl(fetchMock)).toContain('level=error'); + expect(requestUrl(fetchMock)).toContain('level=error'); }); it('serializes deploymentId and level together', async () => { @@ -207,7 +199,7 @@ describe('PlatformClient.getLogs URL', () => { const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getLogs('proj_1', '1h', 200, 'dpl_abc', 'warn'); - const url = lastUrl(fetchMock); + const url = requestUrl(fetchMock); expect(url).toContain('deploymentId=dpl_abc'); expect(url).toContain('level=warn'); }); @@ -221,26 +213,12 @@ describe('PlatformClient.getRequests URL', () => { vi.restoreAllMocks(); }); - function setFetchMock() { - const fetchMock: ReturnType = vi.fn(async () => Response.json({ data: [] })); - globalThis.fetch = withPlatformDiscovery(fetchMock); - return fetchMock; - } - - function lastUrl(fetchMock: ReturnType): string { - const calls = fetchMock.mock.calls as Array>; - if (calls.length === 0) { - throw new Error('fetch was not called'); - } - return String(calls[0]?.[0]); - } - it('hits the /requests endpoint with since and limit, omitting status by default', async () => { const fetchMock = setFetchMock(); const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getRequests('proj_1', '12h'); - const url = lastUrl(fetchMock); + const url = requestUrl(fetchMock); expect(url).toContain('/projects/proj_1/requests?'); expect(url).toContain('since=12h'); expect(url).toContain('limit=200'); @@ -252,7 +230,7 @@ describe('PlatformClient.getRequests URL', () => { const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getRequests('proj_1', '1h', 200, 'all'); - expect(lastUrl(fetchMock)).not.toContain('status='); + expect(requestUrl(fetchMock)).not.toContain('status='); }); it('appends a status class filter', async () => { @@ -260,6 +238,6 @@ describe('PlatformClient.getRequests URL', () => { const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); await client.getRequests('proj_1', '1h', 200, '5xx'); - expect(lastUrl(fetchMock)).toContain('status=5xx'); + expect(requestUrl(fetchMock)).toContain('status=5xx'); }); }); diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 492ab67be..07d548429 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -58,6 +58,8 @@ beforeEach(() => { keyring.read.mockReset().mockReturnValue(null); keyring.write.mockReset().mockReturnValue(true); keyring.remove.mockReset().mockReturnValue(true); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); }); afterEach(() => { @@ -93,8 +95,6 @@ describe('project team target selection', () => { : Response.json({}); }); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await runProjectCommand(root, { subcommand: 'team', action: 'pending' }); await runProjectCommand(root, { @@ -123,7 +123,6 @@ describe('project team target selection', () => { const root = rootWithPlatform(platform, { platformUrl: platformA }); const projectPath = join(root, '.void', 'project.json'); const originalProject = readFileSync(projectPath, 'utf8'); - vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); connect(platformA, 'Platform A'); connect(platformB, 'Platform B'); keyring.read.mockImplementation((service: string, account: string) => { @@ -143,8 +142,6 @@ describe('project team target selection', () => { : Response.json({}); }); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); chmodSync(projectPath, 0o444); chmodSync(join(root, '.void'), 0o555); @@ -185,7 +182,6 @@ describe('project team target selection', () => { const platformA = 'https://platform-a.example.test'; const platformB = 'https://platform-b.example.test'; const root = rootWithPlatform(platform, { platformUrl: platformA }); - vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); vi.stubEnv('VOID_TOKEN', 'void-cloud-token'); vi.stubEnv('VOID_API_URL', undefined); connect(platformA, 'Platform A'); @@ -197,8 +193,6 @@ describe('project team target selection', () => { ); const fetchMock = vi.fn(async () => Response.json({ invitations: [] })); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await expect( runProjectCommand(root, { subcommand: 'team', action: 'pending' }), @@ -209,14 +203,12 @@ describe('project team target selection', () => { it('uses an explicit environment origin and its matching token over saved project and active connections', async () => { const root = rootWithPlatform('void', { platformUrl: 'https://platform-a.example.test' }); - vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); connect('https://platform-b.example.test', 'Platform B'); connect('https://platform-c.example.test', 'Platform C'); vi.stubEnv('VOID_API_URL', 'https://platform-c.example.test/'); vi.stubEnv('VOID_TOKEN', 'platform-c-token'); const fetchMock = vi.fn(async () => Response.json({ invitations: [] })); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); const output: Array = []; vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { output.push(String(chunk)); @@ -238,7 +230,6 @@ describe('project team target selection', () => { const platformA = 'https://platform-a.example.test'; const platformB = 'https://platform-b.example.test'; const root = rootWithPlatform('void', { platformUrl: platformA }); - vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); connect(platformA, 'Platform A'); connect(platformB, 'Platform B'); keyring.read.mockImplementation((service: string, account: string) => @@ -248,8 +239,6 @@ describe('project team target selection', () => { ); const fetchMock = vi.fn(); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('missing login'); }); @@ -264,7 +253,6 @@ describe('project team target selection', () => { const platformA = 'https://platform-a.example.test'; const platformB = 'https://platform-b.example.test'; const root = rootWithPlatform('void', { platformUrl: platformA }); - vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); connect(platformA, 'Platform A'); connect(platformB, 'Platform B'); keyring.read.mockImplementation((service: string, account: string) => { @@ -282,8 +270,6 @@ describe('project team target selection', () => { }), ); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await runProjectCommand(root, { subcommand: 'team', @@ -320,7 +306,6 @@ describe('project team target selection', () => { written += String(chunk); return true; }); - vi.spyOn(console, 'log').mockImplementation(() => undefined); const warnedAt: Array = []; const warn = vi.spyOn(log, 'warn').mockImplementation(() => { warnedAt.push(written.length); @@ -362,8 +347,6 @@ describe('project team target selection', () => { }), ); vi.stubGlobal('fetch', withPlatformDiscovery(fetchMock)); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await runProjectCommand(root, { subcommand: 'team', @@ -397,7 +380,6 @@ describe('project team target selection', () => { }), ), ); - vi.spyOn(console, 'log').mockImplementation(() => undefined); const output: Array = []; vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { output.push(String(chunk)); @@ -432,7 +414,6 @@ describe('project team target selection', () => { }), ), ); - vi.spyOn(console, 'log').mockImplementation(() => undefined); const output: Array = []; vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { output.push(String(chunk)); @@ -488,8 +469,6 @@ describe('project team target selection', () => { 'fetch', vi.fn(async () => Response.json({ error }, { status })), ); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const result = runProjectCommand(root, { subcommand: 'team', @@ -518,8 +497,6 @@ describe('project team target selection', () => { 'fetch', vi.fn(async () => new Response(body, { status: 403 })), ); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const error = await runProjectCommand(root, { subcommand: 'team', action: 'accept', @@ -541,8 +518,6 @@ describe('project team target selection', () => { Response.json({ error: 'user is already a project member' }, { status: 409 }), ), ); - vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.spyOn(process.stdout, 'write').mockImplementation(() => true); await expect( runProjectCommand(root, { subcommand: 'team', diff --git a/packages/void/test/unit/remote/client.test.ts b/packages/void/test/unit/remote/client.test.ts index 98562af45..44e356133 100644 --- a/packages/void/test/unit/remote/client.test.ts +++ b/packages/void/test/unit/remote/client.test.ts @@ -186,67 +186,17 @@ describe('ProxyClient', () => { }), ); - // Verify projectId was injected into body const call = fetchMock.mock.calls[0]; expect(call).toBeDefined(); - if (!call) { - throw new Error('Expected fetch to be called'); - } const [, init] = call as unknown as [RequestInfo | URL, RequestInit]; const body = JSON.parse(String(init.body)); expect(body.projectId).toBe(PROJECT_ID); expect(body.binding).toBe('DB'); expect(body.sql).toBe('SELECT 1'); }); - - it('throws on non-OK responses with error message from body', async () => { - setFetchMock( - async () => new Response(JSON.stringify({ error: 'not found' }), { status: 404 }), - ); - - const client = new ProxyClient(PROXY_URL, TOKEN, PROJECT_ID); - await expect(client.fetch('/d1/query', { binding: 'MISSING' })).rejects.toThrow( - /remote: Proxy request failed with 404/, - ); - }); - - it('returns response for successful requests', async () => { - setFetchMock(async () => Response.json({ results: [{ id: 1 }], success: true })); - - const client = new ProxyClient(PROXY_URL, TOKEN, PROJECT_ID); - const res = await client.fetch('/d1/query', { binding: 'DB', sql: 'SELECT 1' }); - - expect(res.ok).toBe(true); - const data = (await res.json()) as { results: Array }; - expect(data.results).toEqual([{ id: 1 }]); - }); }); describe('stream()', () => { - it('returns raw Response for streaming', async () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('chunk1')); - controller.close(); - }, - }); - setFetchMock( - async () => - new Response(stream, { headers: { 'content-type': 'application/octet-stream' } }), - ); - - const client = new ProxyClient(PROXY_URL, TOKEN, PROJECT_ID); - const res = await client.stream('/r2/get', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ binding: 'STORAGE', key: 'file.txt' }), - }); - - expect(res.ok).toBe(true); - const text = await res.text(); - expect(text).toBe('chunk1'); - }); - it('injects Authorization header and projectId into body', async () => { const fetchMock = setFetchMock(async () => Response.json({ ok: true })); @@ -259,27 +209,11 @@ describe('ProxyClient', () => { const call = fetchMock.mock.calls[0]; expect(call).toBeDefined(); - if (!call) { - throw new Error('Expected fetch to be called'); - } const [, init] = call as unknown as [RequestInfo | URL, RequestInit]; const headers = new Headers(init.headers); expect(headers.get('Authorization')).toBe(`Bearer ${TOKEN}`); - // Verify projectId was injected into body const body = JSON.parse(String(init.body)); expect(body.projectId).toBe(PROJECT_ID); }); - - it('throws on non-OK responses', async () => { - setFetchMock(async () => new Response('server error', { status: 502 })); - - const client = new ProxyClient(PROXY_URL, TOKEN, PROJECT_ID); - await expect( - client.stream('/r2/get', { - method: 'POST', - body: JSON.stringify({ binding: 'STORAGE', key: 'file.txt' }), - }), - ).rejects.toThrow(/remote: Proxy request failed with 502/); - }); }); }); diff --git a/packages/void/test/unit/stream-deploy-errors.test.ts b/packages/void/test/unit/stream-deploy-errors.test.ts index 1b6d51e26..b10e69295 100644 --- a/packages/void/test/unit/stream-deploy-errors.test.ts +++ b/packages/void/test/unit/stream-deploy-errors.test.ts @@ -147,17 +147,6 @@ describe('formatStreamDeployError', () => { expect(formatted.message).toContain('HTTP 500'); }); - it('never replaces a real error message with only "Connection lost during deploy"', () => { - // Regression guard: prior behavior collapsed any non-DeployEventError to the - // opaque connection-lost string, hiding the real cause. The new contract must - // always preserve the original message substring for generic errors. - const err = new Error('socket hang up'); - - const formatted = formatStreamDeployError(err, null); - - expect(formatted.message).toContain('socket hang up'); - }); - it('falls back to the raw body when the JSON payload has no error/message field', () => { const err = new PlatformApiError('Deploy failed', 500, JSON.stringify({ foo: 'bar' })); From 11d35d9bd760aa1eedb5f52c477824eee686da11 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 15:25:43 +0800 Subject: [PATCH 003/127] test(void): drop unused registry fixture from login tests - runPlatformLogin takes the origin directly and never reads the platform registry - remove the temp registry, saved connections and VOID_API_URL holder Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/test/unit/auth-login.test.ts | 41 ++++------------------ 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/packages/void/test/unit/auth-login.test.ts b/packages/void/test/unit/auth-login.test.ts index 956d0b5da..6153a2883 100644 --- a/packages/void/test/unit/auth-login.test.ts +++ b/packages/void/test/unit/auth-login.test.ts @@ -6,11 +6,6 @@ const accessJwt = 'header.' + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url') + '.signature'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; -let registry: string; import { createHash } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { runPlatformLogin } from '../../src/cli/auth.ts'; @@ -39,27 +34,6 @@ vi.mock('node:child_process', () => ({ beforeEach(() => { mockExecFile.mockReset(); exchangeRequests.length = 0; - process.env.VOID_API_URL = 'https://api.test'; - registry = mkdtempSync(join(tmpdir(), 'void-login-connections-')); - process.env.VOID_PLATFORM_REGISTRY_DIR = registry; - savePlatformConnection({ - accountId: null, - apiUrl: 'https://api.test', - applicationDomain: 'apps.example.test', - displayName: 'Test', - installationId: null, - kind: 'connected', - proxyUrl: 'https://proxy.example.test', - }); - savePlatformConnection({ - accountId: null, - apiUrl: 'https://api.staging.void.cloud', - applicationDomain: 'apps.example.test', - displayName: 'Test', - installationId: null, - kind: 'connected', - proxyUrl: 'https://proxy.example.test', - }); vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { const url = @@ -80,10 +54,7 @@ beforeEach(() => { }); afterEach(() => { - rmSync(registry, { recursive: true, force: true }); - delete process.env.VOID_PLATFORM_REGISTRY_DIR; vi.unstubAllGlobals(); - delete process.env.VOID_API_URL; delete process.env.CF_ACCESS_TOKEN; delete process.env.VOID_ACCESS_ORIGIN; }); @@ -109,7 +80,7 @@ describe('runPlatformLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin('https://api.test', 'github', resolveLoginUrl); const loginUrl = new URL(await loginUrlPromise); const redirect = loginUrl.searchParams.get('redirect'); @@ -167,7 +138,7 @@ describe('runPlatformLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin('https://api.test', 'github', resolveLoginUrl); const loginUrl = new URL(await loginUrlPromise); const redirect = loginUrl.searchParams.get('redirect'); @@ -205,7 +176,7 @@ describe('runPlatformLogin', () => { resolveLoginUrl = resolve; }); - const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin('https://api.test', 'github', resolveLoginUrl); // Attach the rejection assertion up front so the exchange failure doesn't // surface as an unhandled rejection. const rejection = expect(loginPromise).rejects.toThrow(/Failed to exchange login code/); @@ -229,16 +200,16 @@ describe('runPlatformLogin', () => { }); it('sends Cloudflare Access headers when exchanging a staging login code', async () => { - process.env.VOID_API_URL = 'https://api.staging.void.cloud'; + const apiUrl = 'https://api.staging.void.cloud'; process.env.CF_ACCESS_TOKEN = accessJwt; - process.env.VOID_ACCESS_ORIGIN = process.env.VOID_API_URL; + process.env.VOID_ACCESS_ORIGIN = apiUrl; let resolveLoginUrl!: (url: string) => void; const loginUrlPromise = new Promise((resolve) => { resolveLoginUrl = resolve; }); - const loginPromise = runPlatformLogin(process.env.VOID_API_URL!, 'github', resolveLoginUrl); + const loginPromise = runPlatformLogin(apiUrl, 'github', resolveLoginUrl); const loginUrl = new URL(await loginUrlPromise); const redirect = loginUrl.searchParams.get('redirect'); expect(redirect).toBeTruthy(); From 12ced83ecbb9722954a1883a310430a3e2cff40a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 13:20:30 +0800 Subject: [PATCH 004/127] refactor(cli): trim managed deploy barrel, dead options and essays - move dist/worker dir resolution to a leaf module so the plugin no longer imports deploy.ts - keep only CLI-used re-exports in deploy.ts; tests import defining modules - drop unused manifest options (vars, webSockets, duplicate workerMain), the unreachable framework live throw, metadataFetch, log.success and the needed Set - pass streamDirectR2Deploy an options object and use getContentType directly - require the provision module in scrubWranglerAuthCredentials; drift check uses buildEnv - cut stale or repeated comments in deploy, package, transport and yarn-pnp Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/build-output-dir.ts | 52 ++++++ packages/void/src/cli/deploy-drift.ts | 3 +- packages/void/src/cli/deploy-helpers.ts | 89 +-------- .../void/src/cli/deploy-managed-events.ts | 87 ++++----- .../void/src/cli/deploy-managed-framework.ts | 44 ++--- packages/void/src/cli/deploy-managed-full.ts | 35 ++-- .../void/src/cli/deploy-managed-static.ts | 12 +- packages/void/src/cli/deploy-transport.ts | 40 +--- packages/void/src/cli/deploy-types.ts | 2 - packages/void/src/cli/deploy.ts | 176 ++---------------- packages/void/src/cli/package.ts | 97 ++-------- packages/void/src/cli/yarn-pnp.ts | 136 ++------------ packages/void/src/plugin-prerender.ts | 2 +- .../cloudflare-deploy-build.test.ts | 8 +- .../cloudflare-durable-build.test.ts | 8 +- .../integration/kitchen-sink-build.test.ts | 2 +- .../application-analysis-consumers.test.ts | 2 +- .../void/test/unit/assemble-manifest.test.ts | 2 +- packages/void/test/unit/asset-hashing.test.ts | 26 +-- .../void/test/unit/cloudflare-deploy.test.ts | 44 +++-- packages/void/test/unit/deploy-auth.test.ts | 3 +- packages/void/test/unit/deploy-drift.test.ts | 4 +- packages/void/test/unit/deploy-events.test.ts | 2 +- .../void/test/unit/deploy-platform.test.ts | 7 +- .../test/unit/deploy-prerender-skip.test.ts | 2 +- .../test/unit/deploy-project-setup.test.ts | 18 +- .../void/test/unit/deploy-project.test.ts | 5 +- .../void/test/unit/deploy-static-only.test.ts | 2 +- .../void/test/unit/direct-r2-deploy.test.ts | 2 +- .../unit/generate-headers-redirects.test.ts | 4 +- .../test/unit/init-starter-migrations.test.ts | 2 +- .../migration-binding-consistency.test.ts | 10 - 32 files changed, 258 insertions(+), 670 deletions(-) create mode 100644 packages/void/src/cli/build-output-dir.ts diff --git a/packages/void/src/cli/build-output-dir.ts b/packages/void/src/cli/build-output-dir.ts new file mode 100644 index 000000000..96dea254c --- /dev/null +++ b/packages/void/src/cli/build-output-dir.ts @@ -0,0 +1,52 @@ +import { existsSync } from 'node:fs'; +import { join } from 'pathslash'; +import type { FrameworkPreset } from './preset.ts'; + +/** + * Resolve the top-level build output directory: the first segment of a + * framework preset's `workerDir`, or `dist` for a Void app. + */ +export function resolveDistDir( + root: string, + fwPreset?: FrameworkPreset, + buildCommand?: string, +): string { + if (fwPreset) { + const topDir = fwPreset.workerDir.split('/')[0]; + const distDir = join(root, topDir); + if (!existsSync(distDir)) { + throw new Error( + `deploy: Build output directory '${topDir}' not found. Run '${buildCommand ?? fwPreset.buildCommand}' first.`, + ); + } + return distDir; + } + return join(root, 'dist'); +} + +/** + * Resolve the worker output subdirectory within the build dir: the rest of a + * framework preset's `workerDir`, or the first known Void output that exists. + */ +export function resolveWorkerDirName( + distDir: string, + fwPreset?: FrameworkPreset, + buildCommand?: string, +): string { + if (fwPreset) { + const subDir = fwPreset.workerDir.split('/').slice(1).join('/'); + if (!existsSync(join(distDir, subDir))) { + throw new Error( + `deploy: Worker output '${fwPreset.workerDir}' not found. Run '${buildCommand ?? fwPreset.buildCommand}' first.`, + ); + } + return subDir; + } + + for (const name of ['ssr', 'rsc', 'server']) { + if (existsSync(join(distDir, name))) { + return name; + } + } + throw new Error('deploy: Worker output not found. Expected dist/ssr/ or dist/rsc/.'); +} diff --git a/packages/void/src/cli/deploy-drift.ts b/packages/void/src/cli/deploy-drift.ts index 4767494aa..c3e86f58e 100644 --- a/packages/void/src/cli/deploy-drift.ts +++ b/packages/void/src/cli/deploy-drift.ts @@ -18,7 +18,6 @@ import { listDrizzleArtifacts } from '../migrations/validate.ts'; import { readProjectPaths } from '../project-paths.ts'; import { buildEnv } from '../shared/untrusted-env.ts'; import { log, getErrorMessage } from './deploy-helpers.ts'; -import { scrubWranglerAuthCredentials } from './deploy-transport.ts'; import { Buffer } from './node-buffer.ts'; const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs'); @@ -105,7 +104,7 @@ function runDrizzleKitGenerate(root: string, configPath: string): string { // drizzle-kit loads the user's drizzle config + schema (user code); keep the // CF Access service token AND every wrangler-auth credential out of it, matching the // build spawns. Generating migrations needs no Cloudflare credential. - env: scrubWranglerAuthCredentials(buildEnv()), + env: buildEnv(), }); const stderr = bufferToString(result.stderr); const stdout = bufferToString(result.stdout); diff --git a/packages/void/src/cli/deploy-helpers.ts b/packages/void/src/cli/deploy-helpers.ts index aacdbdd14..1dbc0b098 100644 --- a/packages/void/src/cli/deploy-helpers.ts +++ b/packages/void/src/cli/deploy-helpers.ts @@ -1,6 +1,6 @@ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { log as clackLog } from '@voidzero-dev/vite-plus-prompts'; +import { readFileSync } from 'node:fs'; +import { isCancel, log as clackLog, text } from '@voidzero-dev/vite-plus-prompts'; import { join, resolve } from 'pathslash'; import { findEnvFile } from '../codegen/env-types.ts'; import { type DatabaseDialect, type VoidConfig, isNodeTarget } from '../config.ts'; @@ -29,7 +29,6 @@ import { formatProjectCommand, resolveProjectCommand, type StaticPreset, - type FrameworkPreset, } from './preset.ts'; import { attachSelectedPlatform, @@ -52,14 +51,13 @@ import type { WrapperFeatures } from './wrapper.ts'; import { needsWrapper, generateWrapper } from './wrapper.ts'; export const log: Record< - 'error' | 'info' | 'message' | 'step' | 'success' | 'warn', + 'error' | 'info' | 'message' | 'step' | 'warn', (message: string) => void > = { error: (message: string) => clackLog.error(formatCloudflareUserMessage(message)), info: (message: string) => clackLog.info(formatCloudflareUserMessage(message)), message: (message: string) => clackLog.message(formatCloudflareUserMessage(message)), step: (message: string) => clackLog.step(formatCloudflareUserMessage(message)), - success: (message: string) => clackLog.step(formatCloudflareUserMessage(message)), warn: (message: string) => clackLog.warn(formatCloudflareUserMessage(message)), }; @@ -438,9 +436,9 @@ export function getGitCommit(root: string): string | null { return githubSha; } - // `git status` can invoke a repo-configured `core.fsmonitor` hook, so keep - // the CF Access service-token pair out of these git children too (matches - // the build spawns). + // `git status` can run a repo-configured `core.fsmonitor` hook, so scrub + // credentials like the build spawns. No `localBinRoot`: `git` must never + // resolve to a binary shipped in the project's node_modules/.bin. const gitEnv = buildEnv(); // Check if in a git repo execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: 'ignore', env: gitEnv }); @@ -489,8 +487,7 @@ export function validateDeployMigrations( /** * Ensure a managed external SQL project has Hyperdrive, prompting for the - * connection string when it is missing. When the resources endpoint is not - * available, Hyperdrive is checked at deploy time instead. + * connection string when it is missing. */ export async function ensureManagedHyperdrive( client: PlatformClient, @@ -501,14 +498,12 @@ export async function ensureManagedHyperdrive( try { resources = await client.getResources(projectId); } catch { - // Resources endpoint not available — Hyperdrive will be checked at deploy time + // Best effort: the platform checks Hyperdrive again during the deploy. } if (!resources || resources.some((resource) => resource.type === 'hyperdrive')) { return; } - const { text: promptText, isCancel: isCancelPrompt } = - await import('@voidzero-dev/vite-plus-prompts'); - const connectionString = await promptText({ + const connectionString = await text({ message: `Your project uses ${dialect === 'mysql' ? 'MySQL' : 'PostgreSQL'}. Enter your connection string:`, placeholder: dialect === 'mysql' @@ -526,7 +521,7 @@ export async function ensureManagedHyperdrive( } }, }); - if (isCancelPrompt(connectionString)) { + if (isCancel(connectionString)) { log.info('Deploy cancelled.'); process.exit(0); } @@ -583,67 +578,3 @@ export async function assertEnvSchemaValid( } throw new DeployEventError('deploy: Aborting because env.ts schema is not satisfied.'); } - -/** - * Resolve the top-level build output directory. - * - * When a framework preset is available, uses its workerDir to derive the - * parent directory (e.g. "build/server" → "build"). This gives framework- - * specific error messages when the output is missing. - * - * For non-framework (Void app) deploys, defaults to "dist". - */ -export function resolveDistDir( - root: string, - fwPreset?: FrameworkPreset, - buildCommand?: string, -): string { - if (fwPreset) { - // Derive the top-level build dir from the preset's workerDir - // e.g. "build/server" → "build", "dist/ssr" → "dist" - const topDir = fwPreset.workerDir.split('/')[0]; - const distDir = join(root, topDir); - if (!existsSync(distDir)) { - throw new Error( - `deploy: Build output directory '${topDir}' not found. Run '${buildCommand ?? fwPreset.buildCommand}' first.`, - ); - } - return distDir; - } - return join(root, 'dist'); -} - -/** - * Resolve the worker output subdirectory name within the build dir. - * - * When a framework preset is available, uses the preset's workerDir to - * derive the subdirectory name and gives a framework-specific error. - * - * For non-framework (Void app) deploys, probes for known subdirectories. - */ -export function resolveWorkerDirName( - distDir: string, - fwPreset?: FrameworkPreset, - buildCommand?: string, -): string { - if (fwPreset) { - // Derive the subdirectory from the preset's workerDir - // e.g. "build/server" → "server", "dist/ssr" → "ssr" - const parts = fwPreset.workerDir.split('/'); - const subDir = parts.slice(1).join('/'); - if (!existsSync(join(distDir, subDir))) { - throw new Error( - `deploy: Worker output '${fwPreset.workerDir}' not found. Run '${buildCommand ?? fwPreset.buildCommand}' first.`, - ); - } - return subDir; - } - - // Non-framework: probe for known output subdirectories - for (const name of ['ssr', 'rsc', 'server']) { - if (existsSync(join(distDir, name))) { - return name; - } - } - throw new Error('deploy: Worker output not found. Expected dist/ssr/ or dist/rsc/.'); -} diff --git a/packages/void/src/cli/deploy-managed-events.ts b/packages/void/src/cli/deploy-managed-events.ts index 2928e270c..eb746a90a 100644 --- a/packages/void/src/cli/deploy-managed-events.ts +++ b/packages/void/src/cli/deploy-managed-events.ts @@ -1,4 +1,5 @@ import { createDeployArtifact, createDeployUploadPayload } from '@void/deploy-core'; +import { getContentType } from '@void/edge'; import { type SpinnerResult } from '@voidzero-dev/vite-plus-prompts'; import { type NotFoundHandling, type RoutingConfig } from '../config.ts'; import { getTokenSource } from './auth.ts'; @@ -15,7 +16,7 @@ import { type GitDeployMetadata } from './git-metadata.ts'; import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts'; import { type CliLogger } from './log-file.ts'; import { formatCount } from './output.ts'; -import { getAssetContentType, type AssetManifestEntry } from './package.ts'; +import { type AssetManifestEntry } from './package.ts'; import type { DeployEvent, DeployManifest } from './types.ts'; /** @@ -223,29 +224,38 @@ export async function streamDeployEvents( * 3. R2 PUT failures (4xx, 5xx, network) are surfaced with the asset * path + hash so the user can correlate against the JSONL trace. */ -export async function streamDirectR2Deploy( - client: PlatformClient, - projectId: string, - manifest: DeployManifest, - assetManifest: Record, - assetFiles: Map, - needed: Array, - gitMetadata: GitDeployMetadata | undefined, - preflightUsed: boolean, - workerFiles: Map | undefined, - s: SpinnerResult, - cliLog?: CliLogger, -): Promise { +export async function streamDirectR2Deploy({ + client, + projectId, + manifest, + assetManifest, + assetFiles, + needed, + gitMetadata, + preflightUsed, + workerFiles, + spinner: s, + cliLog, +}: { + client: PlatformClient; + projectId: string; + manifest: DeployManifest; + assetManifest: Record; + assetFiles: Map; + needed: Array; + gitMetadata?: GitDeployMetadata; + /** Whether preflight let the server reuse any stored asset. */ + preflightUsed: boolean; + workerFiles?: Map; + spinner: SpinnerResult; + cliLog?: CliLogger; +}): Promise { if (needed.length > 0) { - // Memory: drop buffers for assets the server already has so peak - // memory tracks `needed`, not the full bundle. Skipped entries become - // GC-eligible immediately rather than after `finalizeDeploy` returns. - // The pre-prune size is captured for the JSONL log only. We materialize - // the key list before deleting so the iterator isn't disturbed mid-walk. + // Drop buffers the server already has so peak memory tracks `needed`, + // not the full bundle. const neededPaths = new Set(needed); const preBufferCount = assetFiles.size; - const allPaths = Array.from(assetFiles.keys()); - for (const path of allPaths) { + for (const path of assetFiles.keys()) { if (!neededPaths.has(path)) { assetFiles.delete(path); } @@ -269,9 +279,6 @@ export async function streamDirectR2Deploy( for (const path of needed) { const entry = assetManifest[path]; if (!entry) { - // Should never fire — `needed` is preflight's projection of - // assetManifest. Defensive throw makes the cause obvious if a - // future preflight regression slips through. throw new Error(`internal: preflight returned ${path} but assetManifest has no entry`); } if (seenBlake3.has(entry.blake3)) { @@ -287,7 +294,8 @@ export async function streamDirectR2Deploy( blake3: entry.blake3, md5: entry.md5, size: entry.size, - contentType: getAssetContentType(path), + // Signed into the presigned PUT; dispatch serves with the same table. + contentType: getContentType(path), }; }); @@ -315,9 +323,6 @@ export async function streamDirectR2Deploy( } const body = assetFiles.get(path); if (!body) { - // Defensive — `uniqueNeeded` came from `assetManifest`, which was - // constructed from `assetFiles`. If this fires, the upstream - // collection diverged from preflight. throw new Error( `internal: asset ${path} declared in manifest but missing from buffer cache`, ); @@ -423,13 +428,7 @@ export function mergeAndLintRoutingRules( return merged; } -/** - * Emit a single aggregated lint warning when a `_redirects` file contains - * one or more `!` force suffixes on 3xx entries (e.g. `301!`, `302!`). - * The `!` is a no-op on redirects — we parse them as if the `!` weren't - * there — but we nudge the user to clean up for clarity. Silenced when - * `count === 0`. - */ +/** Warn once about `!` suffixes on 3xx `_redirects` entries, which are no-ops. */ export function warnRedundantForceOn3xx(count: number): void { if (count === 0) { return; @@ -440,21 +439,9 @@ export function warnRedundantForceOn3xx(count: number): void { } /** - * Class B/C framework deploys pin `not_found_handling: 'none'`, so - * `routing.notFound` does nothing there — say so instead of dropping it - * silently. - * - * These presets emit no `run_worker_first`, so Cloudflare's default applies and - * the asset layer answers before the framework worker: real prerendered files - * already win. `not_found_handling` only governs what the framework's own - * `env.ASSETS.fetch()` delegation returns, which is framework-internal - * plumbing. Honoring the override there would be a behavior change, not a fix — - * `single-page-application` makes an unmatched delegation return - * `dist/client/index.html` at **200** (Astro/SvelteKit hybrid builds commonly - * prerender the root, so real 404s silently become the home page), and - * `404-page` steals the 404 from the framework's own error route. - * - * Silenced when unset. + * Class B/C framework deploys pin `not_found_handling: 'none'`, so warn when + * `routing.notFound` is set. Honoring it would turn framework 404s into a 200 + * `index.html` or take the 404 from the framework's own error route. */ export function warnIgnoredNotFoundForFramework( notFound: NotFoundHandling | undefined, diff --git a/packages/void/src/cli/deploy-managed-framework.ts b/packages/void/src/cli/deploy-managed-framework.ts index e9cf9f3a1..ac850511c 100644 --- a/packages/void/src/cli/deploy-managed-framework.ts +++ b/packages/void/src/cli/deploy-managed-framework.ts @@ -52,7 +52,6 @@ export async function runFrameworkDeploy( log.info(`${detected.name} framework deploy`); cliLog?.info('deploy_mode', { mode: 'framework', framework: detected.name }); - // 1. Build const buildCmd = voidConfig.inference?.build ?? formatProjectCommand(root, fwPreset.buildCommand); if (!skipBuild) { log.step(`Building (${buildCmd})...`); @@ -68,7 +67,6 @@ export async function runFrameworkDeploy( cliLog?.info('build_skipped', { reason: 'skipBuild' }); } - // 2. Verify output paths const workerDir = join(root, fwPreset.workerDir); const assetsDir = join(root, fwPreset.assetsDir); const frameworkAssetIgnorePatterns = @@ -85,9 +83,7 @@ export async function runFrameworkDeploy( voidConfig.routing, ); - // `assetConfig` below is pinned to `not_found_handling: 'none'` on this path, - // so `routing.notFound` is inert. Warned here (outside the packaging spinner) - // rather than at the pin itself. + // Warn before the packaging spinner starts. warnIgnoredNotFoundForFramework(voidConfig.routing?.notFound, detected.name); if (!existsSync(workerDir)) { @@ -112,7 +108,7 @@ export async function runFrameworkDeploy( assertDeploySchemaDriftFree(root, dialect); - // 4. Collect migrations (before Hyperdrive check so migrations can gate provisioning) + // Validate migrations before the Hyperdrive check so migrations can gate provisioning. const validatedMigrations = validateDeployMigrations(paths.migrationsDir, analysis.migrations); // Pre-deploy: ensure Hyperdrive is provisioned for external SQL projects. @@ -120,7 +116,6 @@ export async function runFrameworkDeploy( await ensureManagedHyperdrive(client, config.projectId, dialect); } - // 5. Generate wrapper if needed (crons or queues present) const rawJobs = analysis.jobs; const rawQueues = analysis.queues; const originalEntry = join(workerDir, fwPreset.workerMain); @@ -135,7 +130,7 @@ export async function runFrameworkDeploy( let actualWorkerDir = workerDir; let actualWorkerMain = fwPreset.workerMain; - // Step 1: Bundle if framework has unbundled bare imports (e.g. SvelteKit) + // Bundle when the framework leaves bare imports unbundled (e.g. SvelteKit). if (fwPreset.bundle) { log.step('Bundling worker...'); const bundleResult = await bundleEntry(root, originalEntry); @@ -144,34 +139,24 @@ export async function runFrameworkDeploy( log.info('Worker bundled'); } - // Step 2: Wrap if internal deploy endpoints are needed (in-place, no re-bundling) + // Wrap in place, without re-bundling, when internal deploy endpoints are needed. if (needsWrapper(wrapperFeatures)) { log.step('Generating wrapper...'); await generateDeployWrapperIfNeeded(root, actualWorkerDir, actualWorkerMain, wrapperFeatures); log.info('Wrapper generated'); } - // 8. Read wrangler compat settings (void.config.ts > wrangler.jsonc > build output) - // Use the original workerDir (not actualWorkerDir) because bundleEntry() may - // reassign actualWorkerDir to a temporary output — the plugin-generated - // wrangler.json with correct compat settings lives in the original build output. + // Read compat settings from the original workerDir: bundleEntry() may move + // actualWorkerDir to a temporary output without the generated wrangler.json. const wranglerCompat = resolveDeployWranglerCompat(root, voidConfig, workerDir, { dialect }); - // 9. Resolve revalidate + prerender from void.config.ts const revalidate = collectRevalidate(voidConfig, null); const prerender = voidConfig.routing?.isr === false ? undefined : voidConfig.routing?.prerender; - // Build manifest-format schedules and queues from raw scans const { schedules, queues } = toManifestSchedulesAndQueues(rawJobs, rawQueues); - // Production env values are remote secrets. Client values are compiled - // from schema defaults or the build shell; no dotenv value enters the manifest. - const envVars: Record = {}; - - // 10a. Validate against env.ts schema if present. await assertEnvSchemaValid(root, client, config.projectId); - // 11. Package & deploy const s = createSpinner(); const onProgress = (msg: string) => s.message(msg); @@ -201,10 +186,8 @@ export async function runFrameworkDeploy( framework: detected.name, }); const packageStartedAt = Date.now(); - const neededSet = skipped > 0 ? new Set(needed) : null; const packageOptions = { bindingNames: analysis.bindingNames, - workerMain: actualWorkerMain, compatibilityDate: wranglerCompat?.compatibilityDate, compatibilityFlags: wranglerCompat?.compatibilityFlags, cpuMs: voidConfig.worker?.limits?.cpu_ms, @@ -220,11 +203,8 @@ export async function runFrameworkDeploy( revalidate, revalidateQueryAllowlist: voidConfig.routing?.revalidateQueryAllowlist, prerender, - // Pinned, NOT threaded from `routing.notFound` — see - // `warnIgnoredNotFoundForFramework` for why the override must not apply to - // Class B/C. The warning above tells the user when they set it anyway. + // Not `routing.notFound`; see `warnIgnoredNotFoundForFramework`. assetConfig: { not_found_handling: 'none' }, - vars: envVars, packageOptions, hashedAssetsPrefix: fwPreset.hashedAssetsPrefix ?? 'assets', headerRules, @@ -242,19 +222,19 @@ export async function runFrameworkDeploy( // by the framework via `migration-handler.ts`); the deploy body only // needs `manifest.migrations.pending` so the platform knows to invoke // the worker's `/__void/migrate` endpoint after dispatch. - const result = await streamDirectR2Deploy( + const result = await streamDirectR2Deploy({ client, - config.projectId, + projectId: config.projectId, manifest, assetManifest, assetFiles, needed, gitMetadata, - neededSet !== null, + preflightUsed: skipped > 0, workerFiles, - s, + spinner: s, cliLog, - ); + }); note( formatDeploySummary({ diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index 6f9b5065c..6915dec8e 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -22,14 +22,13 @@ import { requiresSandboxPlatformImage, resolveSandboxConfig } from '../sandbox/c import { resolveRemoteMode } from '../shared/env.ts'; import { buildEnv } from '../shared/untrusted-env.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; +import { resolveDistDir, resolveWorkerDirName } from './build-output-dir.ts'; import { PlatformClient } from './client.ts'; import { assertDeploySchemaDriftFree } from './deploy-drift.ts'; import { resolveFullDeployBuildCommand, log, getExitCode, - resolveDistDir, - resolveWorkerDirName, resolveDeployLiveBindingConfig, hasNonPageBackend, hasPrerenderSources, @@ -88,7 +87,7 @@ export async function runFullDeploy( assertManagedDurableStateSupported(root); - // 3. Build (child process to avoid Vite API side effects on bundle output) + // Build in a child process to avoid Vite API side effects on bundle output. if (!skipBuild) { const { label: buildCmd, command: buildExec } = resolveFullDeployBuildCommand( root, @@ -98,9 +97,7 @@ export async function runFullDeploy( cliLog?.info('build_start', { command: buildCmd }); const buildStartedAt = Date.now(); try { - // Pass VOID_DEPLOY_PROJECT_ID so plugin-prerender (running inside this - // child vite build) can resolve the Void projectId without relying on - // .void/project.json, which may be stale or absent under `--project `. + // VOID_DEPLOY_PROJECT_ID marks a managed build: platform dispatch owns routing and ISR. execSync(buildExec, { cwd: root, stdio: 'inherit', @@ -118,8 +115,8 @@ export async function runFullDeploy( cliLog?.info('build_skipped', { reason: 'skipBuild' }); } - // 4. Resolve build output paths from build artifacts (avoids re-resolving - // Vite config in the main process — plugins only run once inside the build) + // Resolve output paths from build artifacts instead of re-resolving the Vite + // config here; plugins run only once, inside the build. const frameworkBuildCmd = fwPreset ? formatProjectCommand(root, fwPreset.buildCommand) : undefined; @@ -144,7 +141,7 @@ export async function runFullDeploy( } } - // 5. Framework mode (Class A — passed from runDeploy) + // Class A framework mode, detected by runDeploy. const isFrameworkMode = detected != null; // Managed static prerender is deliberately outside the Vite subprocess. The @@ -212,7 +209,7 @@ export async function runFullDeploy( assertDeploySchemaDriftFree(root, dialect); - // 7. Collect and validate migrations (before Hyperdrive check so migrations can gate provisioning) + // Validate migrations before the Hyperdrive check so migrations can gate provisioning. const validatedMigrations = validateDeployMigrations(paths.migrationsDir, analysis.migrations); // Pre-deploy: ensure Hyperdrive is provisioned for external SQL projects. @@ -332,7 +329,6 @@ export async function runFullDeploy( voidToken: await client.createPrerenderToken(config.projectId), }); - // 8. Read wrangler compat settings for Cloudflare targets const wranglerCompat = resolveDeployWranglerCompat( root, voidConfig, @@ -345,13 +341,8 @@ export async function runFullDeploy( ); const workerMain = resolveDeployWorkerMain(voidConfig, join(distDir, workerDirName)); - // Production env values are remote secrets; dotenv is local-only. - const envVars: Record = {}; - - // 9a. Validate against env.ts schema if present. await assertEnvSchemaValid(root, client, config.projectId); - // 10. Package & Upload const s = createSpinner(); const onProgress = (msg: string) => s.message(msg); @@ -396,7 +387,6 @@ export async function runFullDeploy( framework: detected?.name, }); const packageStartedAt = Date.now(); - const neededSet = skipped > 0 ? new Set(needed) : null; const voidPackageOptions = { compatibilityDate: wranglerCompat?.compatibilityDate, compatibilityFlags: wranglerCompat?.compatibilityFlags, @@ -427,7 +417,6 @@ export async function runFullDeploy( ssr: isSsr || hasRoutes || hasMiddleware || hasWebSockets || authEnabled, framework: isFrameworkMode ? detected!.name : undefined, revalidate, - vars: envVars, queues, prerender: uniquePrerenderPaths, assetConfig, @@ -467,19 +456,19 @@ export async function runFullDeploy( mode: 'full', durationMs: Date.now() - packageStartedAt, }); - const result = await streamDirectR2Deploy( + const result = await streamDirectR2Deploy({ client, - config.projectId, + projectId: config.projectId, manifest, assetManifest, assetFiles, needed, gitMetadata, - neededSet !== null, + preflightUsed: skipped > 0, workerFiles, - s, + spinner: s, cliLog, - ); + }); note( formatDeploySummary({ diff --git a/packages/void/src/cli/deploy-managed-static.ts b/packages/void/src/cli/deploy-managed-static.ts index d8c0ea040..5f29d6c16 100644 --- a/packages/void/src/cli/deploy-managed-static.ts +++ b/packages/void/src/cli/deploy-managed-static.ts @@ -137,7 +137,6 @@ export async function runStaticDeploy( s.start('Packaging...'); cliLog?.info('package_start', { mode: 'static' }); const packageStartedAt = Date.now(); - const neededSet = skipped > 0 ? new Set(needed) : null; const manifest = buildStaticManifest({ appType: preset.appType, @@ -151,19 +150,18 @@ export async function runStaticDeploy( mode: 'static', durationMs: Date.now() - packageStartedAt, }); - const result = await streamDirectR2Deploy( + const result = await streamDirectR2Deploy({ client, - config.projectId, + projectId: config.projectId, manifest, assetManifest, assetFiles, needed, gitMetadata, - neededSet !== null, - undefined, - s, + preflightUsed: skipped > 0, + spinner: s, cliLog, - ); + }); note( formatDeploySummary({ typeLabel, assets: result.assets, unchangedAssets: skipped }), diff --git a/packages/void/src/cli/deploy-transport.ts b/packages/void/src/cli/deploy-transport.ts index 0325f20cb..a57770cc8 100644 --- a/packages/void/src/cli/deploy-transport.ts +++ b/packages/void/src/cli/deploy-transport.ts @@ -4,7 +4,7 @@ import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; import { CLOUDFLARE_DEPLOY_BUILD_ENV } from '../shared/env.ts'; import { WRANGLER_AUTH_CREDENTIAL_ENV_VARS } from '../shared/untrusted-env.ts'; import { CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV } from '../shared/version-metadata.ts'; -import { requireProvisionModule, loadedProvisionModule } from './deploy-provision.ts'; +import { requireProvisionModule } from './deploy-provision.ts'; /** * Marks a build child as driven by a MANAGED `void deploy`. @@ -21,9 +21,8 @@ import { requireProvisionModule, loadedProvisionModule } from './deploy-provisio * later `void dev` in the same shell. * * Deliberately NOT set by `runCloudflareDeploy` (`--platform cloudflare`): that flow - * hands the built output to `wrangler deploy` against the user's own account and IS the - * self-host path the warning is about. (It rejects framework SSR outright today, so no - * Class A build reaches it either way.) + * deploys the built output to the user's own account and IS the self-host path the + * warning is about. */ export const MANAGED_DEPLOY_BUILD_ENV = { VOID_DEPLOY_BUILD: '1' } as const; @@ -167,10 +166,9 @@ export function resolveDeployTransport(root: string, rootConfig: Record { - delete target[name]; - }); + const { deleteEnvVarCaseAware: deleteVar } = requireProvisionModule(); for (const name of WRANGLER_AUTH_CREDENTIAL_ENV_VARS) { deleteVar(env, name); } diff --git a/packages/void/src/cli/deploy-types.ts b/packages/void/src/cli/deploy-types.ts index bb748d1d2..29e490214 100644 --- a/packages/void/src/cli/deploy-types.ts +++ b/packages/void/src/cli/deploy-types.ts @@ -105,8 +105,6 @@ export interface CloudflareDeployDeps { prepareFrameworkArtifact?: typeof prepareCloudflareFrameworkArtifact; /** Upload, readiness-probe, and activate the verified Worker Version. */ activateVersion?: typeof activateCloudflareVersion; - /** Read-only metadata transport; writes always use the pinned uploader. */ - metadataFetch?: typeof globalThis.fetch; /** * Wrangler-uploader content-hash seam (default: {@link hashWranglerUploader}). Snapshotted * BEFORE the build and re-checked AFTER, so a build that overwrote the wrangler bundle the diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index bc2075816..4c1de3c6a 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -45,24 +45,15 @@ import { import { getRequestedProjectSlug } from './resolve-project.ts'; import type { DeployTarget } from './types.ts'; -export { parseRootWranglerConfig } from '@void/deploy-cloudflare'; - export function resolveDeploymentPlatform( root: string, options?: RunDeployOptions, ): DeploymentPlatform { + // Projects with no saved choice predate the init prompt and keep the Void default. return options?.platform ?? readDeploymentPlatform(root) ?? 'void'; } -/** - * Run the full deploy pipeline: - * 1. Check auth - * 2. Resolve project + deploy mode - * 3. Build (if needed) - * 4. Package output - * 5. Upload to platform - * 6. Print live URL - */ +/** Run `void deploy`; a failure carries a summary of the deploy log in its message. */ export async function runDeploy(root: string, options?: RunDeployOptions): Promise { console.log(); @@ -108,40 +99,6 @@ export async function runDeploy(root: string, options?: RunDeployOptions): Promi } } -/** - * Build a child-process environment with deployment credentials removed. - * - * The deploy flow spawns UNTRUSTED user build code (`vite build`, framework - * preset builds, drizzle-kit loading the user schema) which inherits the - * spawn env. A shared, long-lived CF Access service token is needed only by - * this CLI's own API client (which captures the Access headers once at - * construction and reuses them for every request) — never by the build — so it - * must not survive into those children, or it would leak to arbitrary repo - * build scripts/plugins. - * - * We scrub it on the CHILD env rather than mutating `process.env`: deleting it - * from the parent also breaks interactive expired-token re-auth, which reads - * the trusted readiness-header capture / cloudflared pair-guard AFTER the build - * step. Managed prerender runs later in this trusted parent process with a - * dedicated short-lived proxy credential. The deploy token, Cloudflare, - * Access, and production database credentials never reach arbitrary build - * scripts or plugins. - * - * `localBinRoot` is an explicit OPT-IN, not a default. Build commands are run - * through a shell (`execSync`) and a user-configured `inference.build` (e.g. - * `vitepress build && vite build`) names bare binaries that only exist in the - * project's own `node_modules/.bin`; under the build container's - * `npx --yes void deploy` that happens to work — npx prepends every ancestor - * `.bin` itself — but a directly invoked `node_modules/.bin/void deploy` or a - * globally installed `void` inherits no such PATH and the bare binary fails to - * resolve. Passing it makes the project's bin dir shadow the system one for - * that child, so it belongs ONLY on the three build execs. The other callers - * (`getGitCommit`, the drizzle-kit spawn) exec a system binary or an absolute - * path and must not hand a dependency-shipped `node_modules/.bin/git` a child - * process holding `VOID_TOKEN`. - */ -export { buildEnv } from '../shared/untrusted-env.ts'; - const cloudflareDeployTarget: RunDeployTargetAdapter = { target: 'cloudflare', capabilities: { @@ -227,8 +184,6 @@ async function runDeployInner( options: RunDeployOptions | undefined, cliLog: CliLogger, ): Promise { - // A missing field retains the historical Void behavior. New interactive - // init runs always persist an explicit choice, with Cloudflare preselected. const link = await maybeLinkCloudflareDeploy(root, options); if (link === 'cancelled') { log.info('Deployment cancelled.'); @@ -280,27 +235,13 @@ async function runManagedDeploy( `deploy: This project is linked to ${savedProject.platformUrl}. Pass --project to deploy to ${apiUrl} without changing the saved link.`, ); } - // 1. Auth — resolve the deploy token by precedence: - // 1. explicit `VOID_TOKEN` env (deliberate override; wins everywhere) - // 2. GitHub Actions OIDC exchange (in Actions, when no explicit token) - // 3. saved keychain / config file (local dev) - // - // OIDC MUST beat an AMBIENT saved token here. `getToken()` returns the env - // token AND a keychain/config-file token, so calling it unconditionally - // first would let a leftover `void account login` credential on a self-hosted - // / reused runner preempt OIDC. Only consult it before OIDC when the user - // supplied an explicit env token; that still applies the shared origin - // guard before any platform client can receive the credential. With no env - // token, mint OIDC first and consult the saved credential only as fallback. + // Token precedence: VOID_TOKEN, then GitHub Actions OIDC, then the saved + // login. OIDC must beat the saved login so a leftover `void account login` + // on a reused runner cannot preempt it. let token = process.env.VOID_TOKEN ? getToken(root) : null; if (!token && isGithubActionsOidcAvailable()) { - // Running inside GitHub Actions with no explicit VOID_TOKEN: mint a - // GitHub OIDC token and exchange it for a short-lived, project-scoped - // deploy token. The slug must resolve WITHOUT a platform client (there - // is no token yet): --project flag > VOID_PROJECT env > linked - // .void/project.json — the same sources the deploy path uses below. A - // workflow with `id-token: write` intends OIDC, so an unknown slug is a - // hard error — we do NOT silently fall back to a saved token. + // A workflow with `id-token: write` intends OIDC, so an unknown slug is a + // hard error rather than a fallback to the saved login. const slug = requestedProjectSlug ?? linkedProject?.slug; if (!slug) { throw new Error( @@ -311,31 +252,23 @@ async function runManagedDeploy( const tokenOrigin = getApiUrl(root); token = await exchangeGithubOidcForDeployToken(tokenOrigin, slug); maskGithubActionsSecret(token); - // Keep source accounting and expired-token handling consistent with an - // explicit CI token. `buildEnv()` always removes this value before any - // project-controlled child starts; managed prerender obtains a separate - // proxy-only token from the authenticated platform client. + // Treat the exchanged token like an explicit CI token; `buildEnv()` + // still removes it from every project-controlled child. process.env.VOID_TOKEN = token; process.env.VOID_API_URL = tokenOrigin; cliLog.info('github_oidc_exchange_success', { slug }); } - // Local dev / non-Actions (or Actions without id-token permission): fall - // back to the saved keychain/config token. `getToken()` re-checks - // `VOID_TOKEN` first, which is harmlessly unset on this branch. token ??= getToken(root); if (!token) { log.error('No auth token found. Run `void account login` first.'); process.exit(1); } + // Access credentials stay in `process.env` for the expired-token re-auth + // below; `buildEnv()` removes them from each project-controlled child. const client = new PlatformClient(token, { root, cliLog }); - // The CF Access service-token pair is intentionally left in `process.env` - // so interactive expired-token re-auth (below) can still read it. It is - // kept OUT of every untrusted build child instead, via `buildEnv()` on the - // spawn env — see that helper for the full rationale. - - // 2. Resolve target project (CLI flag > env var > linked project file) + // Resolve the target project: CLI flag > env var > linked project file. let config: ProjectConfig | null = null; if (requestedProjectSlug) { @@ -413,7 +346,7 @@ async function runManagedDeploy( ); } - // 3. Resolve deploy app type: void.config.ts > CLI flags > auto-detection + // Resolve the app type: void.config.ts > CLI flags > auto-detection. const voidConfig = readConfig(root); cliLog.info('config_resolved', { target: voidConfig.target, @@ -544,87 +477,16 @@ async function runManagedDeploy( } } -export { - type RunDeployOptions, - type DeployWranglerRunResult, - type DeployWranglerRun, - type CloudflareDeployDeps, -} from './deploy-types.ts'; +export { type RunDeployOptions } from './deploy-types.ts'; +export { parseRootWranglerConfig } from '@void/deploy-cloudflare'; export { loadProvisionModule } from './deploy-provision.ts'; - -export { - assertNoConfigOverrideInProjectDotenv, - resolveDeployTransport, - deployChildEnv, -} from './deploy-transport.ts'; - -export { - snapshotBindingIdentities, - DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS, - DEPLOY_PASSTHROUGH_ASSERT_FIELDS, - DEPLOY_WHOLE_OBJECT_PINNED_FIELDS, - snapshotPassthroughIdentities, - snapshotPassthroughRawFields, - reconstructExpectedContainers, - reconstructExpectedDurableObjectState, - hashWranglerUploader, - assertWranglerUploaderUnchanged, -} from './deploy-binding-snapshot.ts'; - -export { verifyGeneratedDeployConfig } from './deploy-artifact-validation.ts'; - -export { deriveWorkerName, computeDesiredCloudflareResources } from './deploy-resources.ts'; - -export { - assertMigrationsProduceAuthSchema, - validateAuthMigrationSchema, - compareWranglerMigrationPaths, - checkWranglerMigrationApplySet, -} from './deploy-migrations.ts'; - +export { resolveDeployTransport } from './deploy-transport.ts'; +export { deriveWorkerName } from './deploy-resources.ts'; +export { checkWranglerMigrationApplySet } from './deploy-migrations.ts'; export { createDeployWranglerRunner, isWranglerNonInteractiveOrCI, runWranglerAuthToken, - ensureCloudflareAuthSecret, } from './deploy-cloudflare-auth.ts'; - -export { - runCloudflareDeploy, - discoverRootWranglerConfig, - formatCloudflareActivationError, -} from './deploy-cloudflare.ts'; - -export { prepareCloudflareFrameworkArtifact } from './deploy-cloudflare-static.ts'; - -export { - applyDeployEvent, - streamDeployEvents, - warnUnpublishedRouting, - warnRedundantForceOn3xx, - warnIgnoredNotFoundForFramework, -} from './deploy-managed-events.ts'; - -export { assembleVoidManifest, runStaticDeploy } from './deploy-managed-static.ts'; - -export { assertManagedDurableStateSupported } from './deploy-managed-framework.ts'; - -export { hasDeploySchemaDrift } from './deploy-drift.ts'; - -export { - generateDeployWrapperIfNeeded, - ensurePreviouslyDeployedLiveBindingConfig, - resolveDeployLiveBindingConfig, - resolveDeployWranglerCompat, - resolveDeployWorkerMain, - promptDeployProjectConfig, - resolveStaticBuildCommand, - resolveFullDeployBuildCommand, - hasPrerenderSources, - hasNonPageBackend, - resolveStaticDeployPreset, - resolveDistDir, - resolveWorkerDirName, -} from './deploy-helpers.ts'; -export type { EmittedWorkerVarsResult } from './env-validation.ts'; +export { discoverRootWranglerConfig } from './deploy-cloudflare.ts'; diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 033fd503e..a551e7b32 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { getContentType } from '@void/edge'; import { hash as blake3hash } from 'blake3-jit'; import ignore from 'ignore'; import { extname, join, relative } from 'pathslash'; @@ -36,9 +35,6 @@ const textEncoder = new TextEncoder(); type ProgressCallback = (message: string) => void; -const FRAMEWORK_LIVE_UNSUPPORTED_MESSAGE = - 'deploy: void/live is currently supported in Void apps only.'; - /** * Strip dev-only / internal-only fields from each rule before shipping the * manifest. The resulting entries contain only `{ source, destination, status }`. @@ -65,34 +61,12 @@ function stripDevOnlyRuleFields( } /** - * Layer user-defined `routing.fallbacks` with the SPA app type's implicit - * `/index.html` fallback. Decision: user fallbacks take precedence; if none - * matches, the SPA default kicks in. - * - * The platform's `not_found_handling: 'single-page-application'` is a global - * switch — set once per deployment — so we can't have the platform evaluate - * user fallbacks *before* applying it. Instead, when the user has configured - * fallbacks on a SPA app, we disable the platform-level SPA setting and - * append a synthetic catch-all fallback rule (`/*` → `/index.html`) after - * the user's rules. The dispatch worker evaluates routing rules with - * first-match-wins semantics, so placing the synthetic rule last means any - * user rule that matches a more specific pattern fires first; the synthetic - * rule only catches paths no user rule matched. - * - * When the user has no fallbacks configured, the platform-level SPA switch - * is left intact (zero worker-side overhead for the common case). - * - * `notFound` is the explicit `routing.notFound` from void.json. It overrides - * the value this function would otherwise infer from `appType`, mirroring - * `resolveAssetRoutingPolicy` on the worker-with-assets path. It is resolved - * *before* the SPA branch, not patched onto the result afterwards: an override - * away from `single-page-application` must also skip the synthetic - * `/* -> /index.html` rule below, otherwise the SPA shell would still be - * served — just through a fallback rule instead of the platform switch — and - * the override would be a no-op. This matters because `detectPreset` - * classifies any Vite project without backend files as `spa`, so a plain Vite - * MPA lands on `appType: 'spa'` and `routing.notFound: '404-page'` is its only - * escape hatch. + * Layer user `routing.fallbacks` over the SPA default. The platform SPA switch + * is global, so a SPA with user fallbacks turns it off and appends a synthetic + * `/* -> /index.html` rule after them (first match wins). An explicit + * `routing.notFound` is resolved first, so overriding away from SPA also skips + * the synthetic rule. `detectPreset` classes a plain Vite MPA as `spa`, so this + * override is its only escape hatch. */ export function resolveSpaFallbackChain( appType: 'spa' | 'static' | undefined, @@ -123,13 +97,8 @@ export function resolveSpaFallbackChain( }; } - // User fallbacks present on a SPA app — disable the platform-level SPA - // switch and append a synthetic catch-all rule after user rules, so that - // under first-match-wins any more specific user rule fires first. If the - // user already has an *unscoped* `/*` → `/index.html`, skip the append so - // we don't ship two identical rules (which would trip the duplicate-source - // lint). A host-scoped catch-all only covers one hostname, so other bound - // hosts still need the synthetic global SPA fallback. + // An unscoped user `/* -> /index.html` already is the catch-all; a duplicate + // would trip the duplicate-source lint. A host-scoped one covers one host only. const alreadyHasCatchAll = userRules.some( (r) => r.host === undefined && r.source === '/*' && r.destination === '/index.html', ); @@ -188,18 +157,6 @@ export function computeAssetHash(content: Buffer, filePath: string): string { return Buffer.from(blake3hash(input)).toString('hex').slice(0, 32); } -/** - * Resolve the content-type to declare on a direct-to-R2 PUT for `path`. - * The value is part of the signed Content-Type header on the presigned - * URL — if the bytes are uploaded with a different content-type, R2 - * rejects the PUT (signature mismatch). Uses the same table dispatch serves - * assets with, so R2-stored and serve-time content types agree. Defaults to - * `application/octet-stream` for unknown extensions. - */ -export function getAssetContentType(path: string): string { - return getContentType(path); -} - /** * Compute MD5 of raw content as a 32-char lowercase hex string. * @@ -217,11 +174,7 @@ export function computeMd5(content: Buffer): string { /** * Compute both BLAKE3 (CF-compatible asset hash) and MD5 (wire-level R2 - * upload-integrity checksum) from a single in-memory buffer. Returned in - * the v2 manifest entry shape for direct-to-R2 deploys. - * - * Single-pass: callers read the file once into `content`; this function - * then derives both digests from that buffer. The file is never read twice. + * upload-integrity checksum) in the v2 manifest entry shape. */ export function computeAssetHashes( content: Buffer, @@ -324,7 +277,6 @@ export async function collectAndHashAssets( // `collectAssetFiles` already skipped ignored paths. const relativePath = '/' + toSlash(relative(dir, filePath)); checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); - // Single disk read per file; both hashes are derived from the same buffer. const content = readFileSync(filePath); const { blake3, md5 } = computeAssetHashes(content, filePath); assetManifest[relativePath] = { @@ -358,7 +310,7 @@ export type PackageOptions = { compatibilityDate?: string; /** Cloudflare compatibility_flags from framework's wrangler config. */ compatibilityFlags?: Array; - /** Workers CPU-time limit (ms) from void.json `worker.limits.cpu_ms`. */ + /** Workers CPU-time limit (ms) from void.config.ts `worker.limits.cpu_ms`. */ cpuMs?: number; /** Cloudflare Sandbox container metadata for platform deploys. */ sandbox?: ResolvedSandboxConfig; @@ -504,7 +456,6 @@ export function buildVoidManifest(opts: { revalidate?: number | Record; pages?: Array<{ pattern: string; revalidate?: number }>; pathRevalidate?: Record; - vars?: Record; queues?: Array; prerender?: Array; assetConfig?: AssetConfig; @@ -525,7 +476,6 @@ export function buildVoidManifest(opts: { revalidate, pages, pathRevalidate, - vars, queues, prerender, assetConfig, @@ -610,12 +560,6 @@ export function buildVoidManifest(opts: { manifest.prerender = prerender; } - // Layer user fallbacks with the SPA default (if applicable). When the - // caller signals SPA mode via `not_found_handling: 'single-page-application'` - // and the user has `routing.fallbacks` configured, we disable the - // platform-level SPA switch and append a synthetic catch-all rule so user - // fallbacks are evaluated first and the SPA default only fires when none - // matched. let resolvedAssetConfig = assetConfig; let resolvedFallbackRules = fallbackRules; if (assetConfig?.not_found_handling === 'single-page-application') { @@ -628,10 +572,6 @@ export function buildVoidManifest(opts: { manifest.assetConfig = resolvedAssetConfig; } - if (vars && Object.keys(vars).length > 0) { - manifest.vars = vars; - } - if (options?.workerMain) { manifest.workerMain = options.workerMain; } @@ -757,14 +697,12 @@ export function buildFrameworkManifest(opts: { revalidateQueryAllowlist?: Record>; prerender?: Array; assetConfig?: AssetConfig; - vars?: Record; - packageOptions?: PackageOptions; + packageOptions?: Omit; hashedAssetsPrefix?: string; headerRules?: Array; redirectRules?: Array; fallbackRules?: Array; dialect?: 'sqlite' | 'postgresql' | 'mysql'; - webSockets?: Array; }): DeployManifest { const po = opts.packageOptions; const dialect = opts.dialect ?? 'sqlite'; @@ -777,9 +715,6 @@ export function buildFrameworkManifest(opts: { dialect, po?.bindingNames, ); - if (opts.bindings.needsLive) { - throw new Error(FRAMEWORK_LIVE_UNSUPPORTED_MESSAGE); - } if (opts.migrations && opts.migrations.length > 0) { manifest.migrations = toMigrationsManifest(opts.migrations, dialect); @@ -803,14 +738,11 @@ export function buildFrameworkManifest(opts: { if (opts.assetConfig) { manifest.assetConfig = opts.assetConfig; } - if (opts.vars && Object.keys(opts.vars).length > 0) { - manifest.vars = opts.vars; - } manifest.ssr = true; manifest.framework = opts.frameworkName; manifest.type = 'framework'; - manifest.workerMain = po?.workerMain ?? opts.workerMain; + manifest.workerMain = opts.workerMain; if (po?.compatibilityDate) { manifest.compatibilityDate = po.compatibilityDate; } @@ -837,11 +769,6 @@ export function buildFrameworkManifest(opts: { manifest.fallbackRules = stripDevOnlyRuleFields(opts.fallbackRules); } - const websocketManifest = toWebSocketManifest(opts.webSockets); - if (websocketManifest) { - manifest.websocket = websocketManifest; - } - return manifest; } diff --git a/packages/void/src/cli/yarn-pnp.ts b/packages/void/src/cli/yarn-pnp.ts index 8ccbca92e..7c828ce42 100644 --- a/packages/void/src/cli/yarn-pnp.ts +++ b/packages/void/src/cli/yarn-pnp.ts @@ -2,11 +2,7 @@ import { existsSync, readdirSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { dirname, join } from 'pathslash'; -/** - * Read PnP runtime files at a single directory without walking - * ancestors. Returns a descriptor with the files that exist, or - * `undefined` when neither is present. - */ +/** Read the PnP runtime files in `dir` only, or `undefined` when neither exists. */ function readPnpRuntimeAt(dir: string): { pnpCjs?: string; pnpLoader?: string } | undefined { const cjs = join(dir, '.pnp.cjs'); const loader = join(dir, '.pnp.loader.mjs'); @@ -22,43 +18,10 @@ function readPnpRuntimeAt(dir: string): { pnpCjs?: string; pnpLoader?: string } } /** - * Returns true when `dir` contains a local package-manager lockfile - * (`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lock`, - * `bun.lockb`). A lockfile at `dir` is definitive proof that a package - * manager runs install from this directory: Yarn / pnpm / npm / bun - * all write the lockfile at the install root, and workspace members - * SHARE the root lockfile rather than carrying their own. - * - * Two independent call sites depend on this signal: - * - * 1. `findYarnPnpRuntime` / `findInstalledYarnPnpRuntime` below use - * it as the "leaf-managed install ownership" gate: a nested - * project with its own lockfile must NOT inherit an outer Yarn - * PnP runtime into `NODE_OPTIONS`, because the outer `.pnp.cjs` - * patches `Module._resolveFilename` to consult a graph that - * doesn't contain the leaf's dependencies — drizzle-kit then - * fails to resolve `drizzle-orm/...`. - * 2. `resolveWorkspaceDeferral` in `init.ts` uses it as the - * "workspace-skip boundary": a subpackage with its own lockfile - * is not governed by the workspace install and must run install - * + starter migration locally. - * - * Both call sites must check the same set of lockfile paths — if one - * drifts (for example, adding a new package manager's lockfile to - * only one helper), the two paths will silently disagree on the same - * directory. Exported as the single source of truth to lock the - * invariant in one place. - * - * Note: `package.json#packageManager` (Corepack pin) is deliberately - * NOT a signal here. A Yarn Berry workspace member can legitimately - * declare its own `packageManager: "yarn@..."` while still resolving - * through the workspace root's `.pnp.cjs`; treating the pin as - * leaf-managed would stop the ancestor walk before reaching the real - * PnP root and break `void db generate` / starter migration - * generation in valid Yarn PnP monorepos. The lockfile check is - * sufficient: a fresh-cloned leaf project with only a Corepack pin - * will still trigger install, which writes the lockfile before - * `generateStarterMigration()` fires, at which point the gate engages. + * Whether `dir` has its own package-manager lockfile, i.e. it is an install + * root: workspace members share the root lockfile. `packageManager` is not a + * signal, because a Yarn workspace member may pin it and still resolve through + * the root's `.pnp.cjs`. */ export function hasLocalLockfile(dir: string): boolean { return ( @@ -71,20 +34,9 @@ export function hasLocalLockfile(dir: string): boolean { } /** - * Walk ancestors from `start` (inclusive) looking for Yarn Berry PnP - * runtime files. Returns the directory containing them (with both - * possibly-existing paths) if found, or `undefined` otherwise. - * - * Walking ancestors is important for workspace subpackages — in a Yarn - * PnP monorepo, `.pnp.cjs` lives at the workspace root, not in each - * subpackage. Checking only `start` would miss the common shape. - * - * Local PnP files at `start` are always authoritative and take - * precedence over the leaf-managed guard below. When `start` has no - * local PnP files but DOES have a local lockfile, stop here — ancestor - * PnP data belongs to a different install graph and injecting it will - * break resolution for the local one. Only walk past `start` when - * neither condition holds. + * Find Yarn Berry PnP runtime files at `start` or its nearest ancestor, since a + * monorepo keeps `.pnp.cjs` at the workspace root. A lockfile at `start` stops + * the walk: an outer PnP graph lacks the local install's dependencies. */ function findYarnPnpRuntime( start: string, @@ -114,44 +66,9 @@ function findYarnPnpRuntime( } /** - * Stricter variant of {@link findYarnPnpRuntime}: only returns a result - * when the PnP runtime files AND a post-install marker live in the - * SAME ancestor directory. - * - * Yarn Berry repos commonly commit `.pnp.cjs` to - * version control but NOT `.yarn/cache/*.zip` (zero-install is an - * opt-in, not the default). A fresh `git clone` before `yarn install` - * therefore has `.pnp.cjs` on disk while the PnP data points at ZIP - * archives that haven't been fetched yet. Trusting `.pnp.cjs` existence - * alone as proof that "yarn install has run" leads `init` to skip the - * install step, and the subsequent `drizzle-kit generate` spawn crashes - * because PnP tries to load `void/schema-d1` from a missing ZIP. - * - * Accept EITHER of two markers, to cover both non-zero-install - * and zero-install shapes: - * - * 1. `.yarn/install-state.gz` in the same dir. Yarn Berry (3.x and - * 4.x) writes this on every successful `yarn install` and it is - * gitignored by convention, so its presence strictly implies - * "yarn install has populated this checkout at least once". This - * is the only marker present in a classic (non-zero-install) repo. - * 2. `.yarn/cache/` containing at least one `.zip` entry in the same - * dir. In zero-install repos (`nodeLinker: pnp`, cache committed - * to git), `install-state.gz` is commonly gitignored or absent, - * but the populated cache IS on disk — that's the whole point of - * zero-install. A populated cache is therefore sufficient proof - * that the checkout is ready to resolve dependencies. - * - * The same-directory requirement locks the invariant — a committed - * `.pnp.cjs` in a subdirectory must not be covered by markers sitting - * at an unrelated ancestor. - * - * NOTE: `findYarnPnpRuntime` intentionally does NOT apply this stricter - * check. NODE_OPTIONS injection for drizzle-kit spawns should still - * happen whenever PnP runtime files are present: if install hasn't run, - * drizzle-kit will crash with or without the injected env, so the - * inject is harmless; if the user is about to run install and then hits - * a spawn, the env needs to be prepared. + * Like {@link findYarnPnpRuntime}, but only when Yarn has installed into that + * same directory. A committed `.pnp.cjs` alone does not prove an install: its + * ZIP archives may not be fetched yet. */ export function findInstalledYarnPnpRuntime( start: string, @@ -161,23 +78,13 @@ export function findInstalledYarnPnpRuntime( } /** - * Return true when `dir` looks like a post-install Yarn Berry PnP - * checkout. Two valid markers (see `findInstalledYarnPnpRuntime`): - * - * - `.yarn/install-state.gz` — written by every successful - * `yarn install` in a classic (non-zero-install) repo. - * - `.yarn/cache/*.zip` — present in a zero-install checkout, where - * archives are committed to git and `install-state.gz` is often - * gitignored or absent. + * Yarn writes `.yarn/install-state.gz` on every install; a zero-install + * checkout instead commits a populated `.yarn/cache`. */ function isYarnPnpInstalled(dir: string): boolean { - // Non-zero-install: Yarn writes this on every successful - // `yarn install`. if (existsSync(join(dir, '.yarn', 'install-state.gz'))) { return true; } - // Zero-install: archives are committed to git. A populated cache is - // the post-install proof. const cacheDir = join(dir, '.yarn', 'cache'); if (!existsSync(cacheDir)) { return false; @@ -191,17 +98,9 @@ function isYarnPnpInstalled(dir: string): boolean { } /** - * Build a child-process env that injects Yarn Berry PnP loaders into - * `NODE_OPTIONS` when a PnP runtime is found at or above `root`. This - * mirrors what `yarn node` does under the hood: `--require` the PnP - * CJS shim (patches `fs` + `Module._resolveFilename` to understand ZIP - * virtual paths) and, when present, `--experimental-loader` the PnP - * ESM loader. - * - * Any pre-existing `NODE_OPTIONS` from the parent process (for example - * when the command is invoked as `yarn void ...` and yarn already set - * it) is appended AFTER our injected flags so our PnP `--require` runs - * first. Non-PnP projects inherit `process.env` unchanged. + * Build a child env that loads the Yarn PnP runtime found at or above `root`, + * like `yarn node` does. Existing `NODE_OPTIONS` go after our flags so the PnP + * `--require` patches resolution first. */ export function buildDrizzleKitSpawnEnv(root: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; @@ -221,9 +120,6 @@ export function buildDrizzleKitSpawnEnv(root: string): NodeJS.ProcessEnv { flags.push(`--experimental-loader ${pathToFileURL(pnp.pnpLoader).href}`); } const injected = flags.join(' '); - // Preserve any pre-existing NODE_OPTIONS by prepending ours — order - // matters for `--require` so the PnP loader patches `fs` / - // `Module._resolveFilename` before anything the user registered. env.NODE_OPTIONS = process.env.NODE_OPTIONS ? `${injected} ${process.env.NODE_OPTIONS}` : injected; diff --git a/packages/void/src/plugin-prerender.ts b/packages/void/src/plugin-prerender.ts index aec818295..dba2da003 100644 --- a/packages/void/src/plugin-prerender.ts +++ b/packages/void/src/plugin-prerender.ts @@ -1,6 +1,6 @@ import { join } from 'pathslash'; import type { Plugin } from 'vite'; -import { resolveDistDir, resolveWorkerDirName } from './cli/deploy.ts'; +import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; import { isNodeTarget, type VoidConfig } from './config.ts'; import { voidLog, voidWarn, setShowTimestamp } from './log.ts'; import { prerenderPages, prerenderPagesNode } from './prerender.ts'; diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index 07ca44948..ffe5a17ed 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -1,5 +1,6 @@ import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, normalize, resolve } from 'node:path'; +import { parseRootWranglerConfig } from '@void/deploy-cloudflare'; import spawn from 'cross-spawn'; import { toSlash } from 'pathslash'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; @@ -7,15 +8,14 @@ import { createIsolatedPlayground, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; +import { verifyGeneratedDeployConfig } from '../../src/cli/deploy-artifact-validation.ts'; import { - computeDesiredCloudflareResources, - parseRootWranglerConfig, reconstructExpectedContainers, snapshotBindingIdentities, snapshotPassthroughIdentities, snapshotPassthroughRawFields, - verifyGeneratedDeployConfig, -} from '../../src/cli/deploy.ts'; +} from '../../src/cli/deploy-binding-snapshot.ts'; +import { computeDesiredCloudflareResources } from '../../src/cli/deploy-resources.ts'; import { materializeCloudflareConfig } from '../../src/cloudflare-config.ts'; import { renderVoidConfig } from '../../src/config-write.ts'; import { readConfig } from '../../src/config.ts'; diff --git a/packages/void/test/integration/cloudflare-durable-build.test.ts b/packages/void/test/integration/cloudflare-durable-build.test.ts index 206fef5a7..11c540b42 100644 --- a/packages/void/test/integration/cloudflare-durable-build.test.ts +++ b/packages/void/test/integration/cloudflare-durable-build.test.ts @@ -1,19 +1,19 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { parseRootWranglerConfig } from '@void/deploy-cloudflare'; import spawn from 'cross-spawn'; import { describe, expect, it } from 'vite-plus/test'; import { createIsolatedPlayground } from '../../../../test/integration-helpers.ts'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; +import { verifyGeneratedDeployConfig } from '../../src/cli/deploy-artifact-validation.ts'; import { - computeDesiredCloudflareResources, - parseRootWranglerConfig, reconstructExpectedContainers, reconstructExpectedDurableObjectState, snapshotBindingIdentities, snapshotPassthroughIdentities, snapshotPassthroughRawFields, - verifyGeneratedDeployConfig, -} from '../../src/cli/deploy.ts'; +} from '../../src/cli/deploy-binding-snapshot.ts'; +import { computeDesiredCloudflareResources } from '../../src/cli/deploy-resources.ts'; import { syncWranglerBindings } from '../../src/cli/wrangler.ts'; import { materializeCloudflareConfig } from '../../src/cloudflare-config.ts'; import { renderVoidConfig } from '../../src/config-write.ts'; diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index acee21b5c..ad8c140b5 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -6,7 +6,7 @@ import { createIsolatedPlayground, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; -import { assembleVoidManifest } from '../../src/cli/deploy.ts'; +import { assembleVoidManifest } from '../../src/cli/deploy-managed-static.ts'; import { readConfig } from '../../src/config.ts'; import { scanPages } from '../../src/pages/scan.ts'; import { collectPrerenderPaths } from '../../src/prerender.ts'; diff --git a/packages/void/test/unit/application-analysis-consumers.test.ts b/packages/void/test/unit/application-analysis-consumers.test.ts index 4e16b4607..11a041215 100644 --- a/packages/void/test/unit/application-analysis-consumers.test.ts +++ b/packages/void/test/unit/application-analysis-consumers.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import type { Plugin, ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; -import { computeDesiredCloudflareResources } from '../../src/cli/deploy.ts'; +import { computeDesiredCloudflareResources } from '../../src/cli/deploy-resources.ts'; import { buildFrameworkManifest, buildVoidManifest } from '../../src/cli/package.ts'; import { readConfig, type VoidConfig } from '../../src/config.ts'; import { validateMigrations } from '../../src/migrations/validate.ts'; diff --git a/packages/void/test/unit/assemble-manifest.test.ts b/packages/void/test/unit/assemble-manifest.test.ts index 9b0b49af3..bfc69cf6c 100644 --- a/packages/void/test/unit/assemble-manifest.test.ts +++ b/packages/void/test/unit/assemble-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { assembleVoidManifest } from '../../src/cli/deploy.ts'; +import { assembleVoidManifest } from '../../src/cli/deploy-managed-static.ts'; import type { InferredBindings } from '../../src/inference/walk.ts'; import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; diff --git a/packages/void/test/unit/asset-hashing.test.ts b/packages/void/test/unit/asset-hashing.test.ts index 7366b61b8..6770fd704 100644 --- a/packages/void/test/unit/asset-hashing.test.ts +++ b/packages/void/test/unit/asset-hashing.test.ts @@ -1,5 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { getContentType } from '@void/edge'; import { hash as blake3hash } from 'blake3-jit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -19,13 +20,8 @@ vi.mock('node:fs', async () => { const fsModule = await import('node:fs'); const readFileSyncMock = fsModule.readFileSync as unknown as ReturnType; -const { - collectAndHashAssets, - computeAssetHash, - computeAssetHashes, - computeMd5, - getAssetContentType, -} = await import('../../src/cli/package.ts'); +const { collectAndHashAssets, computeAssetHash, computeAssetHashes, computeMd5 } = + await import('../../src/cli/package.ts'); const TMP = join(import.meta.dirname, '.tmp-asset-hashing-test'); @@ -141,27 +137,25 @@ describe('collectAndHashAssets — v2 manifest entries', () => { }); }); -describe('getAssetContentType', () => { +describe('asset upload content type', () => { it('returns text/markdown for .md files', () => { - expect(getAssetContentType('/guide/github-actions-cache.md')).toBe( - 'text/markdown; charset=utf-8', - ); + expect(getContentType('/guide/github-actions-cache.md')).toBe('text/markdown; charset=utf-8'); }); it('returns text/markdown for .markdown files', () => { - expect(getAssetContentType('/notes.markdown')).toBe('text/markdown; charset=utf-8'); + expect(getContentType('/notes.markdown')).toBe('text/markdown; charset=utf-8'); }); it('returns text/yaml for .yaml and .yml files', () => { - expect(getAssetContentType('/config.yaml')).toBe('text/yaml; charset=utf-8'); - expect(getAssetContentType('/config.yml')).toBe('text/yaml; charset=utf-8'); + expect(getContentType('/config.yaml')).toBe('text/yaml; charset=utf-8'); + expect(getContentType('/config.yml')).toBe('text/yaml; charset=utf-8'); }); it('returns application/toml for .toml files', () => { - expect(getAssetContentType('/config.toml')).toBe('application/toml'); + expect(getContentType('/config.toml')).toBe('application/toml'); }); it('returns application/octet-stream for unknown extensions', () => { - expect(getAssetContentType('/file.xyz')).toBe('application/octet-stream'); + expect(getContentType('/file.xyz')).toBe('application/octet-stream'); }); }); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 7ba30a2dc..c66e89500 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -13,6 +13,7 @@ import { stripVTControlCharacters } from 'node:util'; import { CloudflareWorkerInspectionError, parseCloudflareVersionMessage, + parseRootWranglerConfig, type CloudflareOperation, } from '@void/deploy-cloudflare'; import { log } from '@voidzero-dev/vite-plus-prompts'; @@ -24,35 +25,41 @@ import { operationFixtureRequest, } from '../../../deploy-cloudflare/test/operation-fixture.ts'; import { readCloudflareLink } from '../../src/cli/cloudflare-link.ts'; +import { verifyGeneratedDeployConfig } from '../../src/cli/deploy-artifact-validation.ts'; import { - assertMigrationsProduceAuthSchema, - assertNoConfigOverrideInProjectDotenv, assertWranglerUploaderUnchanged, - checkWranglerMigrationApplySet, - compareWranglerMigrationPaths, - computeDesiredCloudflareResources, - deployChildEnv, DEPLOY_PASSTHROUGH_ASSERT_FIELDS, DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS, DEPLOY_WHOLE_OBJECT_PINNED_FIELDS, - ensureCloudflareAuthSecret, - formatCloudflareActivationError, hashWranglerUploader, - parseRootWranglerConfig, - prepareCloudflareFrameworkArtifact, reconstructExpectedDurableObjectState, - runCloudflareDeploy, - runWranglerAuthToken, snapshotBindingIdentities, snapshotPassthroughIdentities, snapshotPassthroughRawFields, +} from '../../src/cli/deploy-binding-snapshot.ts'; +import { + ensureCloudflareAuthSecret, + runWranglerAuthToken, +} from '../../src/cli/deploy-cloudflare-auth.ts'; +import { formatCloudflareActivationError } from '../../src/cli/deploy-cloudflare-helpers.ts'; +import { prepareCloudflareFrameworkArtifact } from '../../src/cli/deploy-cloudflare-static.ts'; +import { runCloudflareDeploy } from '../../src/cli/deploy-cloudflare.ts'; +import { + assertMigrationsProduceAuthSchema, + checkWranglerMigrationApplySet, + compareWranglerMigrationPaths, validateAuthMigrationSchema, - verifyGeneratedDeployConfig, - type CloudflareDeployDeps, - type DeployWranglerRun, - type DeployWranglerRunResult, - type EmittedWorkerVarsResult, -} from '../../src/cli/deploy.ts'; +} from '../../src/cli/deploy-migrations.ts'; +import { computeDesiredCloudflareResources } from '../../src/cli/deploy-resources.ts'; +import { + assertNoConfigOverrideInProjectDotenv, + deployChildEnv, +} from '../../src/cli/deploy-transport.ts'; +import type { + CloudflareDeployDeps, + DeployWranglerRun, + DeployWranglerRunResult, +} from '../../src/cli/deploy-types.ts'; import { SENDING_FREE_PLAN_HINT, type EmailDns } from '../../src/cli/email-cloudflare.ts'; import { runEmailCloudflareCommand } from '../../src/cli/email.ts'; import { @@ -63,6 +70,7 @@ import { } from '../../src/cli/env-schema-probe.ts'; import { dischargeSelfHostEnvContext, + type EmittedWorkerVarsResult, type SelfHostEnvContext, type SelfHostSchemaFacts, } from '../../src/cli/env-validation.ts'; diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index 1de6aa1ed..aee9d1557 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path'; import { intro } from '@voidzero-dev/vite-plus-prompts'; import { toSlash } from 'pathslash'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { buildEnv, runDeploy } from '../../src/cli/deploy.ts'; +import { runDeploy } from '../../src/cli/deploy.ts'; +import { buildEnv } from '../../src/shared/untrusted-env.ts'; const mockError = vi.fn(); const mockInfo = vi.fn(); diff --git a/packages/void/test/unit/deploy-drift.test.ts b/packages/void/test/unit/deploy-drift.test.ts index 153e31e12..dc1a98a6c 100644 --- a/packages/void/test/unit/deploy-drift.test.ts +++ b/packages/void/test/unit/deploy-drift.test.ts @@ -12,8 +12,8 @@ import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import { generateVoidAuthDrizzleSchema } from '../../src/auth/drizzle.ts'; -import { getDeploySchemaDriftMessage } from '../../src/cli/deploy-drift.ts'; -import { hasDeploySchemaDrift, validateAuthMigrationSchema } from '../../src/cli/deploy.ts'; +import { getDeploySchemaDriftMessage, hasDeploySchemaDrift } from '../../src/cli/deploy-drift.ts'; +import { validateAuthMigrationSchema } from '../../src/cli/deploy-migrations.ts'; import { writeDrizzleConfig } from '../../src/drizzle/config.ts'; import { collectMigrations } from '../../src/migrations/collect.ts'; diff --git a/packages/void/test/unit/deploy-events.test.ts b/packages/void/test/unit/deploy-events.test.ts index 20f3c3f61..6a07e1023 100644 --- a/packages/void/test/unit/deploy-events.test.ts +++ b/packages/void/test/unit/deploy-events.test.ts @@ -17,7 +17,7 @@ import { streamDeployEvents, warnIgnoredNotFoundForFramework, warnUnpublishedRouting, -} from '../../src/cli/deploy.ts'; +} from '../../src/cli/deploy-managed-events.ts'; import type { CliLogger } from '../../src/cli/log-file.ts'; import type { DeployEvent } from '../../src/cli/types.ts'; diff --git a/packages/void/test/unit/deploy-platform.test.ts b/packages/void/test/unit/deploy-platform.test.ts index 481d35a07..c1b5dc760 100644 --- a/packages/void/test/unit/deploy-platform.test.ts +++ b/packages/void/test/unit/deploy-platform.test.ts @@ -2,11 +2,8 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vite-plus/test'; -import { - assertManagedDurableStateSupported, - resolveDeploymentPlatform, - runDeploy, -} from '../../src/cli/deploy.ts'; +import { assertManagedDurableStateSupported } from '../../src/cli/deploy-managed-framework.ts'; +import { resolveDeploymentPlatform, runDeploy } from '../../src/cli/deploy.ts'; const roots: Array = []; diff --git a/packages/void/test/unit/deploy-prerender-skip.test.ts b/packages/void/test/unit/deploy-prerender-skip.test.ts index 91288b5da..baeee8ad7 100644 --- a/packages/void/test/unit/deploy-prerender-skip.test.ts +++ b/packages/void/test/unit/deploy-prerender-skip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { hasPrerenderSources } from '../../src/cli/deploy.ts'; +import { hasPrerenderSources } from '../../src/cli/deploy-helpers.ts'; import { compileRouteTable } from '../../src/router/compile.ts'; import type { ScanResult } from '../../src/router/scan.ts'; diff --git a/packages/void/test/unit/deploy-project-setup.test.ts b/packages/void/test/unit/deploy-project-setup.test.ts index f29d0c98d..9e9f86ee8 100644 --- a/packages/void/test/unit/deploy-project-setup.test.ts +++ b/packages/void/test/unit/deploy-project-setup.test.ts @@ -76,7 +76,7 @@ describe('promptDeployProjectConfig', () => { it.each(['create', 'link'])( 'keeps the saved destination when choosing to %s a temporary deployment project', async (action) => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); vi.stubEnv('VOID_API_URL', client.apiUrl); const projectFile = join(root, '.void/project.json'); @@ -115,7 +115,7 @@ describe('promptDeployProjectConfig', () => { ); it('does not show link option when there are no existing projects', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([]); client.createProject.mockResolvedValueOnce({ id: 'proj_new', slug: 'new-app' }); @@ -162,7 +162,7 @@ describe('promptDeployProjectConfig', () => { }); it('re-prompts when the server rejects a short slug for a free-plan user', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const { PlatformApiError } = await import('../../src/cli/client.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([]); @@ -187,7 +187,7 @@ describe('promptDeployProjectConfig', () => { }); it('retries when 403 body mentions slug but code is generic', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const { PlatformApiError } = await import('../../src/cli/client.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([]); @@ -213,7 +213,7 @@ describe('promptDeployProjectConfig', () => { }); it('does not retry on non-slug 403 errors (e.g. account suspended)', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const { PlatformApiError } = await import('../../src/cli/client.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([]); @@ -230,7 +230,7 @@ describe('promptDeployProjectConfig', () => { }); it('does not show project names when creating with existing projects available', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([ { @@ -262,7 +262,7 @@ describe('promptDeployProjectConfig', () => { }); it('only shows project names after the user chooses to link', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([ { @@ -306,7 +306,7 @@ describe('promptDeployProjectConfig', () => { }); it('hides reader projects and identifies roles when selecting a deployment destination', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([ { id: 'proj_reader', slug: 'read-only-app', role: 'reader' }, @@ -340,7 +340,7 @@ describe('promptDeployProjectConfig', () => { }); it('offers project creation without an empty link picker when every project is read-only', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy.ts'); + const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); client.listProjectChoices.mockResolvedValueOnce([ { id: 'proj_reader', slug: 'read-only-app', role: 'reader' }, diff --git a/packages/void/test/unit/deploy-project.test.ts b/packages/void/test/unit/deploy-project.test.ts index 89872dbac..45f966b72 100644 --- a/packages/void/test/unit/deploy-project.test.ts +++ b/packages/void/test/unit/deploy-project.test.ts @@ -3,19 +3,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { toSlash } from 'pathslash'; import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { resolveDistDir, resolveWorkerDirName } from '../../src/cli/build-output-dir.ts'; import type { PlatformClient, ProjectResponse } from '../../src/cli/client.ts'; import { ensurePreviouslyDeployedLiveBindingConfig, generateDeployWrapperIfNeeded, resolveDeployLiveBindingConfig, resolveDeployWorkerMain, - resolveDistDir, resolveDeployWranglerCompat, resolveFullDeployBuildCommand, resolveStaticDeployPreset, resolveStaticBuildCommand, - resolveWorkerDirName, -} from '../../src/cli/deploy.ts'; +} from '../../src/cli/deploy-helpers.ts'; import { formatProjectCommand, resolveProjectCommand, diff --git a/packages/void/test/unit/deploy-static-only.test.ts b/packages/void/test/unit/deploy-static-only.test.ts index b5050bb80..724a57a18 100644 --- a/packages/void/test/unit/deploy-static-only.test.ts +++ b/packages/void/test/unit/deploy-static-only.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { hasNonPageBackend } from '../../src/cli/deploy.ts'; +import { hasNonPageBackend } from '../../src/cli/deploy-helpers.ts'; const NONE = { hasRoutes: false, diff --git a/packages/void/test/unit/direct-r2-deploy.test.ts b/packages/void/test/unit/direct-r2-deploy.test.ts index 2a343fe64..c8e97144d 100644 --- a/packages/void/test/unit/direct-r2-deploy.test.ts +++ b/packages/void/test/unit/direct-r2-deploy.test.ts @@ -60,7 +60,7 @@ vi.mock('../../src/cli/output.ts', async () => { const fsModule = await import('node:fs'); const readFileSyncMock = fsModule.readFileSync as unknown as ReturnType; -const { runStaticDeploy } = await import('../../src/cli/deploy.ts'); +const { runStaticDeploy } = await import('../../src/cli/deploy-managed-static.ts'); const { openDeployLog } = await import('../../src/cli/log-file.ts'); const { PlatformClient } = await import('../../src/cli/client.ts'); diff --git a/packages/void/test/unit/generate-headers-redirects.test.ts b/packages/void/test/unit/generate-headers-redirects.test.ts index 0a9ce20b4..58138caa6 100644 --- a/packages/void/test/unit/generate-headers-redirects.test.ts +++ b/packages/void/test/unit/generate-headers-redirects.test.ts @@ -442,7 +442,7 @@ describe('warnRedundantForceOn3xx', () => { // `redundantForceOn3xx` and strips them silently; the deploy pipeline // surfaces a single aggregated `log.warn` via `warnRedundantForceOn3xx`. it('skips emitting when the count is zero', async () => { - const { warnRedundantForceOn3xx } = await import('../../src/cli/deploy.ts'); + const { warnRedundantForceOn3xx } = await import('../../src/cli/deploy-managed-events.ts'); const { log } = await import('@voidzero-dev/vite-plus-prompts'); const warnSpy = log.warn as ReturnType; warnSpy.mockClear(); @@ -453,7 +453,7 @@ describe('warnRedundantForceOn3xx', () => { }); it('emits a single aggregated warning including the count when > 0', async () => { - const { warnRedundantForceOn3xx } = await import('../../src/cli/deploy.ts'); + const { warnRedundantForceOn3xx } = await import('../../src/cli/deploy-managed-events.ts'); const { log } = await import('@voidzero-dev/vite-plus-prompts'); const warnSpy = log.warn as ReturnType; warnSpy.mockClear(); diff --git a/packages/void/test/unit/init-starter-migrations.test.ts b/packages/void/test/unit/init-starter-migrations.test.ts index 2c24f025e..20a68b845 100644 --- a/packages/void/test/unit/init-starter-migrations.test.ts +++ b/packages/void/test/unit/init-starter-migrations.test.ts @@ -10,7 +10,7 @@ import { import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; -import { hasDeploySchemaDrift } from '../../src/cli/deploy.ts'; +import { hasDeploySchemaDrift } from '../../src/cli/deploy-drift.ts'; import { generateStarterMigration } from '../../src/cli/init.ts'; import { createPagesStarterTemplate, type ScaffoldStarterMode } from '../../src/cli/template.ts'; diff --git a/packages/void/test/unit/migration-binding-consistency.test.ts b/packages/void/test/unit/migration-binding-consistency.test.ts index 83d322f02..ffa165b4e 100644 --- a/packages/void/test/unit/migration-binding-consistency.test.ts +++ b/packages/void/test/unit/migration-binding-consistency.test.ts @@ -253,16 +253,6 @@ describe('buildFrameworkManifest migration-binding consistency', () => { expect(manifest.bindings.d1).toBeUndefined(); expect(manifest.migrations).toEqual({ dialect: 'postgresql', pending: 1 }); }); - - it('rejects live bindings because framework workers do not export the live durable object', () => { - expect(() => - buildFrameworkManifest({ - frameworkName: 'nuxt', - workerMain: 'index.js', - bindings: { ...noBindings, needsLive: true }, - }), - ).toThrow('void/live is currently supported in Void apps only'); - }); }); // --------------------------------------------------------------------------- From a22237e5b27c9bc7b77a28b0043479e449cac8e1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 14:02:09 +0800 Subject: [PATCH 005/127] test(void): drop duplicate deploy and manifest tests - delete asset-hashing.test.ts; keep the BLAKE3 pin and v2 entry check in package.test.ts - package and assemble-manifest tests: drop repeats of collectRevalidate, SPA fallback and pages cases; share one makeBindings - deploy-auth: fold repeated OIDC, Cloudflare-target and Access-pair cases into one test each; drop duplicate PATH cases and long comments - drop duplicate cases in deploy-project-setup, deploy-events, deploy-static-only and deploy-platform - cut history comments in the git-metadata test Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/test/unit/assemble-manifest.test.ts | 44 +--- packages/void/test/unit/asset-hashing.test.ts | 161 ------------- packages/void/test/unit/deploy-auth.test.ts | 168 ++----------- packages/void/test/unit/deploy-events.test.ts | 17 -- .../void/test/unit/deploy-platform.test.ts | 10 - .../test/unit/deploy-project-setup.test.ts | 59 +---- .../void/test/unit/deploy-static-only.test.ts | 7 - packages/void/test/unit/git-metadata.test.ts | 12 +- packages/void/test/unit/package.test.ts | 227 ++++-------------- 9 files changed, 73 insertions(+), 632 deletions(-) delete mode 100644 packages/void/test/unit/asset-hashing.test.ts diff --git a/packages/void/test/unit/assemble-manifest.test.ts b/packages/void/test/unit/assemble-manifest.test.ts index bfc69cf6c..114606738 100644 --- a/packages/void/test/unit/assemble-manifest.test.ts +++ b/packages/void/test/unit/assemble-manifest.test.ts @@ -54,11 +54,6 @@ function makeScan(pages: Array): PageScanResult { return { pages, layouts: [], namedLayouts: [] }; } -// This is the production seam `runFullDeploy` uses to forward `pageScan` into -// `manifest.pages`. If the `pages: collectPages(pageScan)` injection is dropped -// from `assembleVoidManifest`, the first assertion below fails (`.toEqual` -// mismatch) and the second fails (`pages` stays undefined) — so this test goes -// RED the moment the wiring regresses. describe('assembleVoidManifest', () => { it('forwards the page scan into manifest.pages (ordered, raw patterns, ISR TTL preserved)', () => { const pageScan = makeScan([ @@ -74,25 +69,18 @@ describe('assembleVoidManifest', () => { ]); }); - it('omits manifest.pages when the scan has no pages', () => { - expect( - assembleVoidManifest(makeScan([]), false, { bindings: MINIMAL_BINDINGS }).pages, - ).toBeUndefined(); - expect(assembleVoidManifest(null, false, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); - }); - - it('emits manifest.pages for a void app (isFrameworkMode = false)', () => { - const scan = makeScan([makePage('/isr', 60)]); - expect(assembleVoidManifest(scan, false, { bindings: MINIMAL_BINDINGS }).pages).toEqual([ - { pattern: '/isr', revalidate: 60 }, - ]); - }); - - it('omits manifest.pages in framework mode even with a populated scan (isFrameworkMode = true)', () => { + it('omits manifest.pages and pathRevalidate in framework mode', () => { // `pages` is a Void pages-mode concept: a Class-A framework routed through // runFullDeploy must NOT emit it, even when it happens to have a pages/ dir. const scan = makeScan([makePage('/isr', 60)]); - expect(assembleVoidManifest(scan, true, { bindings: MINIMAL_BINDINGS }).pages).toBeUndefined(); + const manifest = assembleVoidManifest( + scan, + true, + { bindings: MINIMAL_BINDINGS }, + { '/blog/*': 3600 }, + ); + expect(manifest.pages).toBeUndefined(); + expect(manifest.pathRevalidate).toBeUndefined(); }); it('ships the RAW object config as manifest.pathRevalidate; those pages stay inherit-eligible', () => { @@ -124,18 +112,4 @@ describe('assembleVoidManifest', () => { ]); expect(manifest.pathRevalidate).toBeUndefined(); }); - - it('omits manifest.pathRevalidate in framework mode even with an object config', () => { - const scan = makeScan([makePage('/blog/:slug')]); - const manifest = assembleVoidManifest( - scan, - true, - { bindings: MINIMAL_BINDINGS }, - { - '/blog/*': 3600, - }, - ); - expect(manifest.pages).toBeUndefined(); - expect(manifest.pathRevalidate).toBeUndefined(); - }); }); diff --git a/packages/void/test/unit/asset-hashing.test.ts b/packages/void/test/unit/asset-hashing.test.ts deleted file mode 100644 index 6770fd704..000000000 --- a/packages/void/test/unit/asset-hashing.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { getContentType } from '@void/edge'; -import { hash as blake3hash } from 'blake3-jit'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; - -// `vi.mock` is hoisted above all imports — declare the mock before importing -// `node:fs` consumers so the spy is in place when `package.ts` resolves its -// `import { readFileSync } from 'node:fs'` binding. We re-export every other -// member of the real module via `actual` so existsSync/readdirSync etc. -// continue to work end-to-end against the temp directory. -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs'); - return { - ...actual, - readFileSync: vi.fn(actual.readFileSync), - }; -}); - -const fsModule = await import('node:fs'); -const readFileSyncMock = fsModule.readFileSync as unknown as ReturnType; - -const { collectAndHashAssets, computeAssetHash, computeAssetHashes, computeMd5 } = - await import('../../src/cli/package.ts'); - -const TMP = join(import.meta.dirname, '.tmp-asset-hashing-test'); - -beforeEach(() => { - mkdirSync(TMP, { recursive: true }); - readFileSyncMock.mockClear(); -}); - -afterEach(() => { - rmSync(TMP, { recursive: true, force: true }); -}); - -describe('computeMd5', () => { - it('matches Node crypto.createHash("md5") for raw bytes', () => { - // Sanity: empty string MD5 is the well-known constant. - expect(computeMd5(Buffer.from(''))).toBe('d41d8cd98f00b204e9800998ecf8427e'); - }); - - it('returns 32 lowercase hex chars', () => { - const md5 = computeMd5(Buffer.from('something')); - expect(md5).toHaveLength(32); - expect(md5).toMatch(/^[0-9a-f]{32}$/); - }); - - it('hashes raw content (not base64+ext)', () => { - // MD5 must operate on raw bytes — independent of any extension/base64 - // wrapping that BLAKE3 uses. Pin a known fixture. - expect(computeMd5(Buffer.from('hello\n'))).toBe('b1946ac92492d2347c6235b4d2611184'); - }); -}); - -describe('computeAssetHashes', () => { - it('returns both BLAKE3 and MD5 for known fixtures', () => { - // Fixture: file content "hello\n" with extension "js". - // Expected values pinned from a fresh local computation against - // blake3-jit and node:crypto md5. - const content = Buffer.from('hello\n'); - const filePath = 'test.js'; - const { blake3, md5 } = computeAssetHashes(content, filePath); - expect(blake3).toBe('dc6c72af24e164d6188743458a7da473'); - expect(md5).toBe('b1946ac92492d2347c6235b4d2611184'); - }); - - it('matches the standalone computeAssetHash + computeMd5 outputs', () => { - const content = Buffer.from('some asset bytes'); - const filePath = 'foo/bar.css'; - const both = computeAssetHashes(content, filePath); - expect(both.blake3).toBe(computeAssetHash(content, filePath)); - expect(both.md5).toBe(computeMd5(content)); - }); -}); - -describe('BLAKE3 wire compatibility', () => { - it('matches Wrangler\'s hashFile output for "const x = 1;\\n" / .js', () => { - // Regression test for the existing CF-compatible recipe: - // blake3(base64(content) + extension).hex().slice(0, 32) - // Bit-for-bit compatible with workers-sdk's pages/hash.ts:hashFile. - const content = Buffer.from('const x = 1;\n'); - const filePath = 'foo.js'; - - // Direct computation (independent of computeAssetHash) to lock the - // algorithm in place even if the helper changes shape. - const base64 = content.toString('base64'); - const ext = 'js'; - const input = new TextEncoder().encode(base64 + ext); - const expected = Buffer.from(blake3hash(input)).toString('hex').slice(0, 32); - - expect(expected).toBe('3725f96aa779d1959d5942b1b4e04567'); - expect(computeAssetHash(content, filePath)).toBe(expected); - expect(computeAssetHashes(content, filePath).blake3).toBe(expected); - }); -}); - -describe('collectAndHashAssets — v2 manifest entries', () => { - it('emits { blake3, md5, size, hash } per entry', async () => { - writeFileSync(join(TMP, 'a.js'), 'hello\n'); - - const { assetManifest } = await collectAndHashAssets(TMP); - - const entry = assetManifest['/a.js']; - expect(entry).toBeDefined(); - expect(entry.blake3).toBe('dc6c72af24e164d6188743458a7da473'); - expect(entry.md5).toBe('b1946ac92492d2347c6235b4d2611184'); - expect(entry.size).toBe(Buffer.from('hello\n').length); - // Deprecated v1 alias — must equal blake3 for one release so existing - // consumers reading entry.hash keep working. - expect(entry.hash).toBe(entry.blake3); - }); - - it('reads each file from disk exactly once', async () => { - writeFileSync(join(TMP, 'a.js'), 'one'); - writeFileSync(join(TMP, 'b.css'), 'two'); - writeFileSync(join(TMP, 'c.html'), 'three'); - - // Mock state was cleared in beforeEach; collectAndHashAssets is the only - // call into readFileSync from this point. The mocked fn delegates to the - // real implementation so file content still flows through correctly. - const { assetManifest } = await collectAndHashAssets(TMP); - - // Filter mock calls to just the assets we wrote (createAssetsIgnoreFunction - // does NOT readFileSync .assetsignore unless it exists, but be defensive). - const assetReads = readFileSyncMock.mock.calls.filter(([p]: Array) => { - const path = String(p); - return path.endsWith('a.js') || path.endsWith('b.css') || path.endsWith('c.html'); - }); - expect(assetReads).toHaveLength(3); - // No duplicate path should appear twice — proves single-pass. - const paths = assetReads.map(([p]: Array) => String(p)); - expect(new Set(paths).size).toBe(3); - - // Manifest should still be populated correctly (sanity). - expect(Object.keys(assetManifest).sort()).toEqual(['/a.js', '/b.css', '/c.html']); - }); -}); - -describe('asset upload content type', () => { - it('returns text/markdown for .md files', () => { - expect(getContentType('/guide/github-actions-cache.md')).toBe('text/markdown; charset=utf-8'); - }); - - it('returns text/markdown for .markdown files', () => { - expect(getContentType('/notes.markdown')).toBe('text/markdown; charset=utf-8'); - }); - - it('returns text/yaml for .yaml and .yml files', () => { - expect(getContentType('/config.yaml')).toBe('text/yaml; charset=utf-8'); - expect(getContentType('/config.yml')).toBe('text/yaml; charset=utf-8'); - }); - - it('returns application/toml for .toml files', () => { - expect(getContentType('/config.toml')).toBe('application/toml'); - }); - - it('returns application/octet-stream for unknown extensions', () => { - expect(getContentType('/file.xyz')).toBe('application/octet-stream'); - }); -}); diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index aee9d1557..cfa04c388 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -155,6 +155,8 @@ describe('runDeploy auth failures', () => { expect(intro).not.toHaveBeenCalled(); expect(mockInfo).not.toHaveBeenCalledWith('Deployment target: Cloudflare'); + expect(mockProjectLookup).not.toHaveBeenCalled(); + expect(mockClientCtor).not.toHaveBeenCalled(); }); it('reauthenticates and retries once for expired local auth tokens in interactive mode', async () => { @@ -277,61 +279,26 @@ describe('runDeploy project access', () => { expect(mockProjectLookup).toHaveBeenCalledOnce(); }); - - it('does not fetch Void project roles for a direct Cloudflare deployment', async () => { - linkProject(); - - await expect( - runDeploy(root, { platform: 'cloudflare', projectSlug: 'my-app' }), - ).rejects.toThrow('--project is not supported with platform cloudflare'); - - expect(mockProjectLookup).not.toHaveBeenCalled(); - expect(mockClientCtor).not.toHaveBeenCalled(); - }); }); describe('runDeploy GitHub Actions OIDC exchange', () => { - it('exchanges an OIDC token for a deploy token when no VOID_TOKEN and in Actions', async () => { - mockGetToken.mockReturnValue(null); - mockIsOidcAvailable.mockReturnValue(true); - mockExchangeOidc.mockResolvedValue('minted-token'); - - // The deploy still fails later (project lookup is mocked to reject), but the - // OIDC exchange must run first and mint the token the client is built with. - await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); - - expect(mockExchangeOidc).toHaveBeenCalledTimes(1); - expect(mockExchangeOidc).toHaveBeenCalledWith('https://api.test', 'my-app'); - expect(mockMaskSecret).toHaveBeenCalledWith('minted-token'); - }); - it('prefers OIDC over an AMBIENT saved keychain/file token in Actions (no VOID_TOKEN env)', async () => { - // Regression guard: on a self-hosted / reused runner a leftover - // `void account login` credential lives in the keychain/config file, so - // getToken() returns it — but there is NO explicit VOID_TOKEN env. The - // deploy MUST still mint a fresh project-scoped OIDC deploy token and use - // THAT, not the broad long-lived saved token. (Before the fix, getToken was - // consulted first and the ambient token preempted OIDC entirely.) mockGetToken.mockReturnValue('ambient-saved-token'); mockIsOidcAvailable.mockReturnValue(true); mockExchangeOidc.mockResolvedValue('minted-token'); await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); - // The OIDC exchange ran... expect(mockExchangeOidc).toHaveBeenCalledTimes(1); expect(mockExchangeOidc).toHaveBeenCalledWith('https://api.test', 'my-app'); expect(mockMaskSecret).toHaveBeenCalledWith('minted-token'); - // ...the minted token was exported for the build/prerender... expect(process.env.VOID_TOKEN).toBe('minted-token'); - // ...and the PlatformClient was built with the MINTED token, NOT the saved one. + expect(process.env.VOID_API_URL).toBe('https://api.test'); expect(mockClientCtor).toHaveBeenCalledTimes(1); expect(mockClientCtor.mock.calls[0][0]).toBe('minted-token'); }); it('uses the saved keychain/file token when NOT in Actions (local dev, no exchange)', async () => { - // Local dev: not in GitHub Actions, no VOID_TOKEN env, but a saved token is - // present. It must be used directly with no OIDC exchange. mockGetToken.mockReturnValue('ambient-saved-token'); mockIsOidcAvailable.mockReturnValue(false); @@ -345,24 +312,7 @@ describe('runDeploy GitHub Actions OIDC exchange', () => { expect(process.env.VOID_TOKEN).toBeUndefined(); }); - it('binds the minted token to its issuing platform for later credential reads', async () => { - mockGetToken.mockReturnValue(null); - mockIsOidcAvailable.mockReturnValue(true); - mockExchangeOidc.mockResolvedValue('minted-token'); - - // The deploy throws later, but any in-process credential read must retain - // the issuing origin; a saved project must not redirect the minted token. - await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); - - expect(process.env.VOID_TOKEN).toBe('minted-token'); - expect(process.env.VOID_API_URL).toBe('https://api.test'); - expect(mockExchangeOidc).toHaveBeenCalledWith('https://api.test', 'my-app'); - }); - it('does NOT overwrite an explicit VOID_TOKEN in process.env (no mint)', async () => { - // getToken returns a token (explicit VOID_TOKEN / keychain / file), so the - // OIDC branch is skipped and the existing process.env.VOID_TOKEN is left - // untouched — no re-mint, no clobber. mockGetToken.mockReturnValue('explicit-token'); mockIsOidcAvailable.mockReturnValue(true); process.env.VOID_TOKEN = 'explicit-env-token'; @@ -439,60 +389,23 @@ describe('runDeploy GitHub Actions OIDC exchange', () => { }); }); -// The deploy flow spawns untrusted `vite build` (and other build children) with -// the spawn env. A shared CF Access service token (used only by the CLI's own API -// client, which captures the headers at construction) must NOT survive into those -// children. runDeploy keeps the pair OUT of the child env via `buildEnv()` while -// LEAVING process.env untouched — mutating process.env would break interactive -// expired-token re-auth, which reads the pair after the build step. describe('runDeploy keeps the CF Access service token in process.env', () => { - it('does NOT delete CF_ACCESS_CLIENT_ID/SECRET from process.env (re-auth needs them)', async () => { - const savedId = process.env.CF_ACCESS_CLIENT_ID; - const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; - const savedToken = process.env.CF_ACCESS_TOKEN; - try { - process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; - process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; - process.env.CF_ACCESS_TOKEN = 'jwt-keep-me'; - - // Default mocks make project lookup reject with an expired token in non-TTY - // mode, so runDeploy throws after constructing the client. - await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); - - // The pair is preserved so a later re-auth can still read it. - expect(process.env.CF_ACCESS_CLIENT_ID).toBe('svc-id.access'); - expect(process.env.CF_ACCESS_CLIENT_SECRET).toBe('svc-secret'); - expect(process.env.CF_ACCESS_TOKEN).toBe('jwt-keep-me'); - } finally { - const restore = (key: string, value: string | undefined): void => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restore('CF_ACCESS_CLIENT_ID', savedId); - restore('CF_ACCESS_CLIENT_SECRET', savedSecret); - restore('CF_ACCESS_TOKEN', savedToken); - } - }); - it('exposes the interactive re-auth path to the still-present Access pair', async () => { Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); const savedId = process.env.CF_ACCESS_CLIENT_ID; const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; + const savedToken = process.env.CF_ACCESS_TOKEN; try { process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; + process.env.CF_ACCESS_TOKEN = 'jwt-keep-me'; - // Capture the env the re-auth path sees when it runs (after the client was - // already constructed). cf-access.ts / auth.ts read process.env here, so the - // pair must still be present. - let pairAtReauth: { id?: string; secret?: string } | undefined; + let pairAtReauth: { id?: string; secret?: string; token?: string } | undefined; mockPromptForLoginToken.mockImplementationOnce(() => { pairAtReauth = { id: process.env.CF_ACCESS_CLIENT_ID, secret: process.env.CF_ACCESS_CLIENT_SECRET, + token: process.env.CF_ACCESS_TOKEN, }; return Promise.resolve('fresh-token'); }); @@ -506,7 +419,11 @@ describe('runDeploy keeps the CF Access service token in process.env', () => { ); expect(mockPromptForLoginToken).toHaveBeenCalledTimes(1); - expect(pairAtReauth).toEqual({ id: 'svc-id.access', secret: 'svc-secret' }); + expect(pairAtReauth).toEqual({ + id: 'svc-id.access', + secret: 'svc-secret', + token: 'jwt-keep-me', + }); } finally { const restore = (key: string, value: string | undefined): void => { if (value === undefined) { @@ -517,12 +434,11 @@ describe('runDeploy keeps the CF Access service token in process.env', () => { }; restore('CF_ACCESS_CLIENT_ID', savedId); restore('CF_ACCESS_CLIENT_SECRET', savedSecret); + restore('CF_ACCESS_TOKEN', savedToken); } }); }); -// The build child env must omit every deploy credential even though process.env -// keeps them. Managed prerender obtains a separate proxy-only token afterward. describe('buildEnv', () => { it('omits Cloudflare and Access credentials while process.env keeps them', () => { const savedId = process.env.CF_ACCESS_CLIENT_ID; @@ -545,7 +461,6 @@ describe('buildEnv', () => { const childEnv = buildEnv({ VOID_DEPLOY_PROJECT_ID: 'proj_1' }); - // Pair scrubbed from the CHILD env... expect(childEnv.CF_ACCESS_CLIENT_ID).toBeUndefined(); expect(childEnv.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); expect(childEnv.CF_ACCESS_TOKEN).toBeUndefined(); @@ -555,7 +470,6 @@ describe('buildEnv', () => { expect(childEnv.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); expect(childEnv.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); expect(childEnv.VOID_DEPLOY_PROJECT_ID).toBe('proj_1'); - // ...and process.env is untouched. expect(process.env.CF_ACCESS_CLIENT_ID).toBe('svc-id.access'); expect(process.env.CF_ACCESS_CLIENT_SECRET).toBe('svc-secret'); expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('actions-oidc-bearer'); @@ -584,20 +498,8 @@ describe('buildEnv', () => { }); }); -// Build commands are handed to a SHELL (`execSync`), and a user-configured -// `inference.build` names bare binaries (`vitepress build && vite build`) that -// only exist under the project's own node_modules/.bin. Under the build -// container's `npx --yes void deploy` this happens to work — npx prepends every -// ancestor `.bin` itself — so the gap is invisible there and only bites a -// directly invoked `node_modules/.bin/void deploy` or a globally installed -// `void`, where nothing puts the project's bin dir on PATH. -// -// It is an explicit OPT-IN (`{ localBinRoot }`), never the default: prepending -// the project's bin dir makes a dependency-shipped `node_modules/.bin/` -// shadow the system binary of that name for the child. That is exactly what a -// build command wants, and exactly what `getGitCommit`'s `git` children — which -// run BEFORE any build, with `VOID_TOKEN` in env, even in the no-build deploy -// modes (`--dir`, static/spa presets with no build command) — must not get. +// localBinRoot is opt-in: git and drizzle-kit children must keep the system PATH, +// so a dependency's node_modules/.bin cannot shadow them. describe('buildEnv — local node_modules/.bin on PATH', () => { const DELIMITER = process.platform === 'win32' ? ';' : ':'; @@ -630,18 +532,6 @@ describe('buildEnv — local node_modules/.bin on PATH', () => { } } - it('prepends /node_modules/.bin', () => { - const root = mkdtempSync(join(tmpdir(), 'void-build-env-')); - try { - const childPath = readPath(buildEnv(undefined, { localBinRoot: root })); - expect(childPath?.startsWith(toSlash(join(root, 'node_modules', '.bin')) + DELIMITER)).toBe( - true, - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - it('preserves the inherited PATH after the prepended entry', () => { const root = mkdtempSync(join(tmpdir(), 'void-build-env-')); try { @@ -656,22 +546,6 @@ describe('buildEnv — local node_modules/.bin on PATH', () => { } }); - it('uses the platform PATH delimiter', () => { - const root = mkdtempSync(join(tmpdir(), 'void-build-env-')); - try { - withInheritedPath('/usr/bin', () => { - const childPath = readPath(buildEnv(undefined, { localBinRoot: root })); - const binDir = toSlash(join(root, 'node_modules', '.bin')); - expect(childPath).toBe(`${binDir}${DELIMITER}/usr/bin`); - // Guard against the classic `:`-everywhere bug: on Windows a `:` would - // make the whole variable one unusable entry (`C:\...` already has a `:`). - expect(DELIMITER).toBe(process.platform === 'win32' ? ';' : ':'); - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - it('sets PATH to just the bin dir when nothing was inherited', () => { const root = mkdtempSync(join(tmpdir(), 'void-build-env-')); try { @@ -706,11 +580,6 @@ describe('buildEnv — local node_modules/.bin on PATH', () => { } }); - // The privileged case must be opt-in. `getGitCommit` and the drizzle-kit - // spawn call `buildEnv()` with no options, and a dependency that ships - // `node_modules/.bin/git` must not be able to hijack them — `getGitCommit` - // runs before the build and, on `void deploy --dir dist` or a static/spa - // preset with no build command, is the ONLY child the deploy spawns. it('leaves PATH untouched when no localBinRoot is given', () => { withInheritedPath(`/usr/bin${DELIMITER}/bin`, () => { expect(readPath(buildEnv())).toBe(`/usr/bin${DELIMITER}/bin`); @@ -719,13 +588,6 @@ describe('buildEnv — local node_modules/.bin on PATH', () => { ); }); }); - - it('hands the child the parent PATH byte-for-byte when no localBinRoot is given', () => { - // Deliberately NOT asserting on `process.cwd()/node_modules/.bin`: the test - // runner itself puts that on PATH, so an absence check there would be - // vacuous. Identity with the parent is the property that matters. - expect(readPath(buildEnv())).toBe(readPath(process.env)); - }); }); afterEach(() => { diff --git a/packages/void/test/unit/deploy-events.test.ts b/packages/void/test/unit/deploy-events.test.ts index 6a07e1023..d81ab2b10 100644 --- a/packages/void/test/unit/deploy-events.test.ts +++ b/packages/void/test/unit/deploy-events.test.ts @@ -179,23 +179,6 @@ describe('applyDeployEvent', () => { expect(calls.stop).toHaveLength(0); }); - it('clears the spinner without a duplicate success message when published is true', () => { - const { spinner, calls } = createMockSpinner(); - const done: DoneEvent = { - event: 'done', - deployment: 'dpl_1', - url: 'https://app.void.app', - urls: ['https://app.void.app'], - assets: 0, - workers: 1, - published: true, - }; - const result = applyDeployEvent(done, spinner); - expect(result).toEqual({ kind: 'done', event: done }); - expect(calls.clear).toHaveLength(1); - expect(calls.stop).toHaveLength(0); - }); - it('degrades the done message when published is false but still resolves as a committed done', () => { const { spinner, calls } = createMockSpinner(); const done: DoneEvent = { diff --git a/packages/void/test/unit/deploy-platform.test.ts b/packages/void/test/unit/deploy-platform.test.ts index c1b5dc760..b8b08dd49 100644 --- a/packages/void/test/unit/deploy-platform.test.ts +++ b/packages/void/test/unit/deploy-platform.test.ts @@ -24,21 +24,11 @@ describe('resolveDeploymentPlatform', () => { } }); - it('uses the saved platform', () => { - expect(resolveDeploymentPlatform(makeRoot({ platform: 'cloudflare' }))).toBe('cloudflare'); - expect(resolveDeploymentPlatform(makeRoot({ platform: 'none' }))).toBe('none'); - }); - it('lets an explicit platform override the saved preference', () => { const root = makeRoot({ platform: 'cloudflare' }); expect(resolveDeploymentPlatform(root, { platform: 'void' })).toBe('void'); }); - it('infers Void for legacy linked projects', () => { - const root = makeRoot({ projectId: 'proj_123', slug: 'demo' }); - expect(resolveDeploymentPlatform(root)).toBe('void'); - }); - it('retains Void as the fallback for projects initialized by older SDKs', () => { expect(resolveDeploymentPlatform(makeRoot())).toBe('void'); }); diff --git a/packages/void/test/unit/deploy-project-setup.test.ts b/packages/void/test/unit/deploy-project-setup.test.ts index 9e9f86ee8..ff0aa447f 100644 --- a/packages/void/test/unit/deploy-project-setup.test.ts +++ b/packages/void/test/unit/deploy-project-setup.test.ts @@ -125,6 +125,7 @@ describe('promptDeployProjectConfig', () => { const config = await promptDeployProjectConfig(root, client); expect(client.listProjectChoices).toHaveBeenCalledOnce(); + expect(mockSelect).toHaveBeenCalledTimes(1); expect(mockSelect).toHaveBeenCalledWith({ message: 'Set up a Void project for deploy:', options: [{ value: 'create', label: 'Create a new project' }], @@ -186,32 +187,6 @@ describe('promptDeployProjectConfig', () => { expect(config).toEqual({ projectId: 'proj_new', slug: 'longer-app' }); }); - it('retries when 403 body mentions slug but code is generic', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); - const { PlatformApiError } = await import('../../src/cli/client.ts'); - const client = createClient(); - client.listProjectChoices.mockResolvedValueOnce([]); - // Generic top-level error code with the slug detail only in `message` — - // the retry check must inspect both fields, not short-circuit on `code`. - client.createProject - .mockRejectedValueOnce( - new PlatformApiError( - 'createProject', - 403, - JSON.stringify({ error: 'forbidden', message: 'slug too short for plan' }), - ), - ) - .mockResolvedValueOnce({ id: 'proj_new', slug: 'longer-app' }); - mockSelect.mockResolvedValueOnce('create'); - mockText.mockResolvedValueOnce('oxc').mockResolvedValueOnce('longer-app'); - - const config = await promptDeployProjectConfig(root, client); - - expect(client.createProject).toHaveBeenNthCalledWith(1, 'oxc'); - expect(client.createProject).toHaveBeenNthCalledWith(2, 'longer-app'); - expect(config).toEqual({ projectId: 'proj_new', slug: 'longer-app' }); - }); - it('does not retry on non-slug 403 errors (e.g. account suspended)', async () => { const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const { PlatformApiError } = await import('../../src/cli/client.ts'); @@ -229,38 +204,6 @@ describe('promptDeployProjectConfig', () => { expect(mockText).toHaveBeenCalledTimes(1); }); - it('does not show project names when creating with existing projects available', async () => { - const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); - const client = createClient(); - client.listProjectChoices.mockResolvedValueOnce([ - { - id: 'proj_1', - user_id: 'user_1', - slug: 'existing-app', - type: 'worker', - created_at: '2026-04-24T00:00:00.000Z', - custom_domain: null, - }, - ]); - client.createProject.mockResolvedValueOnce({ id: 'proj_new', slug: 'new-app' }); - mockSelect.mockResolvedValueOnce('create'); - mockText.mockResolvedValueOnce('new-app'); - - const config = await promptDeployProjectConfig(root, client); - - expect(client.listProjectChoices).toHaveBeenCalledOnce(); - expect(mockSelect).toHaveBeenCalledTimes(1); - expect(mockSelect.mock.calls[0]?.[0]).toMatchObject({ - message: 'Set up a Void project for deploy:', - options: [ - { value: 'create', label: 'Create a new project' }, - { value: 'link', label: 'Link an existing project' }, - ], - }); - expect(JSON.stringify(mockSelect.mock.calls[0]?.[0])).not.toContain('existing-app'); - expect(config).toEqual({ projectId: 'proj_new', slug: 'new-app' }); - }); - it('only shows project names after the user chooses to link', async () => { const { promptDeployProjectConfig } = await import('../../src/cli/deploy-helpers.ts'); const client = createClient(); diff --git a/packages/void/test/unit/deploy-static-only.test.ts b/packages/void/test/unit/deploy-static-only.test.ts index 724a57a18..c3b891219 100644 --- a/packages/void/test/unit/deploy-static-only.test.ts +++ b/packages/void/test/unit/deploy-static-only.test.ts @@ -25,11 +25,4 @@ describe('hasNonPageBackend', () => { ] as const)('is true when %s is the only surface', (key) => { expect(hasNonPageBackend({ ...NONE, [key]: true })).toBe(true); }); - - // An `email/` handler compiles to the worker's `email()` export. The - // static-site path uploads no worker at all, so taking it would drop every - // inbound handler and bounce the mail with no error at deploy time. - it('keeps an email-only project on the worker path', () => { - expect(hasNonPageBackend({ ...NONE, hasEmail: true })).toBe(true); - }); }); diff --git a/packages/void/test/unit/git-metadata.test.ts b/packages/void/test/unit/git-metadata.test.ts index 48a51464b..349f521db 100644 --- a/packages/void/test/unit/git-metadata.test.ts +++ b/packages/void/test/unit/git-metadata.test.ts @@ -33,19 +33,13 @@ describe('git deploy metadata', () => { ); }); - // The two git children here run right beside `getGitCommit`, which has - // scrubbed the CF Access service-token pair since it was written. These - // inherited `process.env` verbatim instead — `execFileSync` with no `env` — - // so the pair reached any repo-configured hook or credential helper they - // triggered. `childEnv` is what `void deploy` scrubs. + // Git hooks and credential helpers must see childEnv, not process.env. it.skipIf(process.platform === 'win32')('runs the git children under childEnv', () => { const dir = mkdtempSync(join(tmpdir(), 'void-git-meta-')); const saved = process.env.CF_ACCESS_CLIENT_SECRET; try { mkdirSync(join(dir, 'bin'), { recursive: true }); const shim = join(dir, 'bin', 'git'); - // Reports back whatever the CHILD's env holds, so an inherited secret is - // visible in the return value instead of having to be inferred. writeFileSync(shim, '#!/bin/sh\necho "seen=${CF_ACCESS_CLIENT_SECRET:-none}"\n'); chmodSync(shim, 0o755); process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret.access'; @@ -53,9 +47,7 @@ describe('git deploy metadata', () => { const scrubbed = collectGitDeployMetadata(dir, null, {}, { PATH: join(dir, 'bin') }); expect(scrubbed.gitRef).toBe('seen=none'); - // Control: the same shim DOES see it when the child env carries it, so - // the assertion above is about the env we handed over, not about the - // shim failing to run. + // Control: the shim does run and report the child env. const leaked = collectGitDeployMetadata( dir, null, diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 21d95f754..50cb6d9cf 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -10,13 +10,23 @@ import { collectWorkerFiles, resolveSpaFallbackChain, } from '../../src/cli/package.ts'; -import type { VoidConfig } from '../../src/config.ts'; import type { InferredBindings } from '../../src/inference/walk.ts'; -import { collectRevalidate } from '../../src/isr-config.ts'; -import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; const TMP = join(import.meta.dirname, '.tmp-package-test'); +function makeBindings(overrides: Partial = {}): InferredBindings { + return { + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, + ...overrides, + }; +} + beforeEach(() => { mkdirSync(TMP, { recursive: true }); }); @@ -28,17 +38,8 @@ afterEach(() => { describe('worker packaging', () => { it('marks Sandbox bundles as controller-capable and omits metadata for apps without Sandbox', () => { - const bindings = { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: true, - }; const manifest = buildVoidManifest({ - bindings, + bindings: makeBindings({ needsSandbox: true }), options: { sandbox: { binding: 'SANDBOX', @@ -52,9 +53,7 @@ describe('worker packaging', () => { }, }); expect(manifest.sandbox).toMatchObject({ controllerProtocol: 2, bindingName: 'SANDBOX' }); - expect( - buildVoidManifest({ bindings: { ...bindings, needsSandbox: false } }).sandbox, - ).toBeUndefined(); + expect(buildVoidManifest({ bindings: makeBindings() }).sandbox).toBeUndefined(); }); it('keeps the manifest workerMain aligned with uploaded worker modules', () => { @@ -63,15 +62,7 @@ describe('worker packaging', () => { writeFileSync(join(TMP, 'wrangler.json'), JSON.stringify({ main: 'index.mjs' })); const manifest = buildVoidManifest({ - bindings: { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - }, + bindings: makeBindings(), ssr: true, options: { workerMain: 'index.mjs', @@ -85,49 +76,7 @@ describe('worker packaging', () => { }); }); -// Non-regression: the managed `void deploy` manifest must stay byte-identical -// after the self-host AI + ISR refactors (Tasks 2 & 5). These assert the manifest -// builder still sets `bindings.ai` from `needsAI` and still carries the exact -// `collectRevalidate` output the deploy path feeds it. -describe('buildVoidManifest — self-host non-regression', () => { - function makeBindings(overrides: Partial = {}): InferredBindings { - return { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - ...overrides, - }; - } - - function makePage(pattern: string, revalidate?: number): PageDefinition { - return { - pattern, - params: [], - catchAll: false, - componentPath: `${pattern}.vue`, - serverPath: `${pattern}.server.ts`, - methods: ['GET'], - componentId: pattern.replace(/^\//, ''), - revalidate, - ssrMode: 'auto', - prerenderMode: 'auto', - renderMode: 'server', - shouldPrerender: false, - }; - } - - function makeScan(pages: Array): PageScanResult { - return { pages, layouts: [], namedLayouts: [] }; - } - - function configWith(revalidate: number | Record | undefined): VoidConfig { - return (revalidate === undefined ? {} : { routing: { revalidate } }) as VoidConfig; - } - +describe('buildVoidManifest', () => { it('advertises authenticated email protocol only for a managed inbound handler', () => { expect( buildVoidManifest({ bindings: makeBindings(), hasEmailHandlers: true }).emailProtocolVersion, @@ -145,54 +94,15 @@ describe('buildVoidManifest — self-host non-regression', () => { expect(manifest.bindings.ai).toBe(true); }); - it('omits bindings.ai when the app does not use Workers AI', () => { - const manifest = buildVoidManifest({ bindings: makeBindings({ needsAI: false }) }); - expect(manifest.bindings.ai).toBeUndefined(); - }); - - it('carries collectRevalidate output verbatim into manifest.revalidate (numeric config + per-page)', () => { - // Same inputs the deploy path feeds: void.json config + page-scan per-page rules. - const config = configWith(60); - const scan = makeScan([makePage('/blog/:slug', 120)]); - const revalidate = collectRevalidate(config, scan); - - const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); - - // Merge must be non-trivial (numeric config → path map with "*" fallback). - // The single-segment page pattern `/blog/:slug` stays as `/blog/:slug` so the - // matcher anchors it to ONE path segment (`[^/]+`) at runtime. - expect(revalidate).toEqual({ '/blog/:slug': 120, '*': 60 }); - // …and it must land in the manifest byte-for-byte. - expect(manifest.revalidate).toEqual(revalidate); - expect(Object.keys(manifest.revalidate as Record)).toEqual([ - '/blog/:slug', - '*', - ]); - }); - - it('carries collectRevalidate output verbatim into manifest.revalidate (object config + per-page order)', () => { - const config = configWith({ '/docs/*': 30, '*': 10 }); - const scan = makeScan([makePage('/blog/:slug', 120)]); - const revalidate = collectRevalidate(config, scan); - - const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); - - expect(manifest.revalidate).toEqual(revalidate); - // Per-page entries precede config entries (first-match-wins ordering preserved). - // The per-page `/blog/:slug` pattern stays `/blog/:slug` (single-segment); the - // user's own `routing.revalidate` literal keys (`/docs/*`, `*`) pass untouched. - expect(Object.entries(manifest.revalidate as Record)).toEqual([ - ['/blog/:slug', 120], - ['/docs/*', 30], - ['*', 10], - ]); - }); - - it('leaves manifest.revalidate unset when collectRevalidate yields nothing', () => { - const revalidate = collectRevalidate(configWith(undefined), makeScan([])); - const manifest = buildVoidManifest({ bindings: makeBindings(), revalidate }); - expect(revalidate).toBeUndefined(); - expect(manifest.revalidate).toBeUndefined(); + it('carries revalidate into manifest.revalidate and omits it when unset or 0', () => { + const revalidate = { '/blog/:slug': 120, '*': 60 }; + expect(buildVoidManifest({ bindings: makeBindings(), revalidate }).revalidate).toEqual( + revalidate, + ); + expect(buildVoidManifest({ bindings: makeBindings() }).revalidate).toBeUndefined(); + expect( + buildVoidManifest({ bindings: makeBindings(), revalidate: 0 }).revalidate, + ).toBeUndefined(); }); it('carries a pages array verbatim into manifest.pages', () => { @@ -211,19 +121,6 @@ describe('buildVoidManifest — self-host non-regression', () => { }); describe('manifest limits (worker.limits.cpu_ms)', () => { - function makeBindings(overrides: Partial = {}): InferredBindings { - return { - needsD1: false, - needsKV: false, - needsR2: false, - needsAuth: false, - needsQueues: false, - needsAI: false, - needsSandbox: false, - ...overrides, - }; - } - it('emits manifest.limits when options.cpuMs is set (void manifest)', () => { const manifest = buildVoidManifest({ bindings: makeBindings(), options: { cpuMs: 50 } }); expect(manifest.limits).toEqual({ cpuMs: 50 }); @@ -253,13 +150,6 @@ describe('manifest limits (worker.limits.cpu_ms)', () => { }); expect('limits' in manifest).toBe(false); }); - - it('static manifests never carry limits', () => { - for (const appType of ['static', 'spa'] as const) { - const manifest = buildStaticManifest({ appType }); - expect('limits' in manifest).toBe(false); - } - }); }); describe('resolveSpaFallbackChain', () => { @@ -386,15 +276,6 @@ describe('resolveSpaFallbackChain', () => { }, ]); }); - - it('falls back to the appType-inferred handling when notFound is undefined', () => { - expect(resolveSpaFallbackChain('static', undefined, undefined).assetConfig).toEqual({ - not_found_handling: '404-page', - }); - expect(resolveSpaFallbackChain('spa', undefined, undefined).assetConfig).toEqual({ - not_found_handling: 'single-page-application', - }); - }); }); describe('buildStaticManifest', () => { @@ -407,18 +288,13 @@ describe('buildStaticManifest', () => { }); }); - it('threads routing.notFound into manifest.assetConfig', () => { - const manifest = buildStaticManifest({ appType: 'spa', notFound: '404-page' }); - expect(manifest.type).toBe('spa'); - expect(manifest.assetConfig).toEqual({ not_found_handling: '404-page' }); - }); - it('ships only the user fallbacks when notFound overrides the SPA default', () => { const manifest = buildStaticManifest({ appType: 'spa', notFound: '404-page', fallbackRules: [{ source: '/docs/*', destination: '/docs.html', status: 200, force: false }], }); + expect(manifest.type).toBe('spa'); expect(manifest.assetConfig).toEqual({ not_found_handling: '404-page' }); expect(manifest.fallbackRules).toEqual([ { source: '/docs/*', destination: '/docs.html', status: 200 }, @@ -426,36 +302,6 @@ describe('buildStaticManifest', () => { }); }); -describe('computeAssetHash', () => { - it('returns a 32-char hex string', () => { - const hash = computeAssetHash(Buffer.from('hello world'), 'test.html'); - expect(hash).toHaveLength(32); - expect(hash).toMatch(/^[0-9a-f]{32}$/); - }); - - it('returns consistent hashes for same content and path', () => { - const a = computeAssetHash(Buffer.from('test content'), 'file.js'); - const b = computeAssetHash(Buffer.from('test content'), 'file.js'); - expect(a).toBe(b); - }); - - it('returns different hashes for different content', () => { - const a = computeAssetHash(Buffer.from('content a'), 'file.js'); - const b = computeAssetHash(Buffer.from('content b'), 'file.js'); - expect(a).not.toBe(b); - }); - - it('returns different hashes for same content with different extensions', () => { - const content = Buffer.from('identical bytes'); - const hashHtml = computeAssetHash(content, 'file.html'); - const hashJs = computeAssetHash(content, 'file.js'); - const hashNoExt = computeAssetHash(content, 'file'); - expect(hashHtml).not.toBe(hashJs); - expect(hashHtml).not.toBe(hashNoExt); - expect(hashJs).not.toBe(hashNoExt); - }); -}); - describe('collectAndHashAssets', () => { it('collects files with hashes and sizes', async () => { mkdirSync(join(TMP, 'assets'), { recursive: true }); @@ -470,6 +316,25 @@ describe('collectAndHashAssets', () => { expect(assetFiles.size).toBe(2); }); + it('emits { blake3, md5, size, hash } per entry', async () => { + writeFileSync(join(TMP, 'a.js'), 'hello\n'); + + const { assetManifest } = await collectAndHashAssets(TMP); + + expect(assetManifest['/a.js']).toEqual({ + blake3: 'dc6c72af24e164d6188743458a7da473', + md5: 'b1946ac92492d2347c6235b4d2611184', + size: Buffer.from('hello\n').length, + hash: 'dc6c72af24e164d6188743458a7da473', + }); + }); + + it("matches Wrangler's hashFile BLAKE3 recipe", () => { + expect(computeAssetHash(Buffer.from('const x = 1;\n'), 'foo.js')).toBe( + '3725f96aa779d1959d5942b1b4e04567', + ); + }); + it('returns empty manifest for missing directory', async () => { const { assetManifest, assetFiles } = await collectAndHashAssets(join(TMP, 'nonexistent')); From c2f4d8de02eb87235fb07f45a0c0a988635a4d2a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 15:23:07 +0800 Subject: [PATCH 006/127] refactor(cli): inline asset hash wrapper and drop test-only exports - inline computeAssetHashes into collectAndHashAssets, its only caller - make computeMd5 private to package.ts Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/package.ts | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index a551e7b32..17af6c8b8 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -168,24 +168,10 @@ export function computeAssetHash(content: Buffer, filePath: string): string { * and (b) project-scoped storage (`assets/${projectId}/${blake3}`) means any * collision attack is exploitable only against the attacker's own project. */ -export function computeMd5(content: Buffer): string { +function computeMd5(content: Buffer): string { return createHash('md5').update(content).digest('hex'); } -/** - * Compute both BLAKE3 (CF-compatible asset hash) and MD5 (wire-level R2 - * upload-integrity checksum) in the v2 manifest entry shape. - */ -export function computeAssetHashes( - content: Buffer, - filePath: string, -): { blake3: string; md5: string } { - return { - blake3: computeAssetHash(content, filePath), - md5: computeMd5(content), - }; -} - /** * Create a filter function that excludes .assetsignore, _headers, _redirects, * OS metadata files, dotenv files, deploy-internal patterns, and any patterns @@ -278,10 +264,10 @@ export async function collectAndHashAssets( const relativePath = '/' + toSlash(relative(dir, filePath)); checkWorkerJsAsset(relativePath, assetsIgnoreFilePresent); const content = readFileSync(filePath); - const { blake3, md5 } = computeAssetHashes(content, filePath); + const blake3 = computeAssetHash(content, filePath); assetManifest[relativePath] = { blake3, - md5, + md5: computeMd5(content), size: content.length, // Deprecated v1 alias of `blake3` for consumers still reading `entry.hash`. hash: blake3, From c4f700e5af8bc033c76fefdd5ea664f0d8c9571c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 13:28:41 +0800 Subject: [PATCH 007/127] refactor(email): trim runtime comments and share deadline helpers - Cut long comments and measurement notes in the email runtime to short reasons - Share one timeout race and bounded JSON reader for send, receipt and inbound - Share env readers, base64 helpers, id maker and control-character scan - Drop duplicate address-length checks, unreachable recipient checks and dead try/catch - Remove the dev inbox plugin's unreachable in-process sink; reuse afterServerReady - Point the BINDING_MISSING hint and the from JSDoc at void.config.ts Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-email-dev-inbox.ts | 31 +- packages/void/src/runtime/email-protocol.ts | 85 ++- packages/void/src/runtime/email.ts | 487 +++---------- packages/void/src/runtime/email/dev-inbox.ts | 147 +--- packages/void/src/runtime/email/inbound.ts | 647 ++++------------- packages/void/src/runtime/email/mime.ts | 680 ++++-------------- packages/void/src/runtime/email/testing.ts | 133 +--- packages/void/src/runtime/email/types.ts | 11 +- .../void/test/unit/email-dev-inbox.test.ts | 2 +- packages/void/test/unit/email-inbound.test.ts | 8 +- 10 files changed, 521 insertions(+), 1710 deletions(-) diff --git a/packages/void/src/plugin-email-dev-inbox.ts b/packages/void/src/plugin-email-dev-inbox.ts index 7cbe93972..e3b47c116 100644 --- a/packages/void/src/plugin-email-dev-inbox.ts +++ b/packages/void/src/plugin-email-dev-inbox.ts @@ -2,15 +2,9 @@ // `/__void/inbox` routes for browsing captured messages. Activated when // binding inference detected `void/email` usage. // -// Two ways a message lands in the buffer: -// * `POST /__void/inbox/_capture` — the real `void dev` path. `sendEmail` -// runs in workerd (a separate OS process), so it cannot reach this -// process's sink registry; it POSTs the captured message here through its -// dev assets binding, which @cloudflare/vite-plugin wires directly into -// this middleware stack. -// * the in-process sink — only fires for a `sendEmail` that happens to run -// inside this Node process (e.g. a prerender pass). Kept so that path is -// still captured; it is not the dev-server transport. +// `sendEmail` runs in workerd, a separate OS process, so it POSTs each +// captured message to `/__void/inbox/_capture` through its dev assets +// binding, which @cloudflare/vite-plugin wires into this middleware stack. // // Every route requires the project's dev-trigger token — the same one // `/__void/scheduled` and `/__void/queue` check. Vite's own host validation @@ -21,6 +15,7 @@ import { timingSafeEqual } from 'node:crypto'; import type { Plugin } from 'vite'; import { voidLog } from './log.ts'; +import { afterServerReady } from './plugin-dev-config.ts'; import { clearDevBuffer, DEV_INBOX_ACK_HEADER, @@ -34,7 +29,6 @@ import { listDevMessages, parseCapturedMessage, pushToDevBuffer, - registerEmailSink, type CapturedMessage, } from './runtime/email/dev-inbox.ts'; @@ -60,7 +54,6 @@ export interface EmailDevInboxPluginOptions { export function emailDevInboxPlugin(options: EmailDevInboxPluginOptions): Plugin { const { devTriggerToken } = options; - let unregister: (() => void) | undefined; /** `href` for an inbox path that keeps the browser session authorized. */ const inboxHref = (path: string): string => @@ -69,18 +62,6 @@ export function emailDevInboxPlugin(options: EmailDevInboxPluginOptions): Plugin return { name: 'void:email-dev-inbox', apply: 'serve', - buildStart() { - if (unregister) { - return; - } - unregister = registerEmailSink((m) => pushToDevBuffer(m)); - }, - buildEnd() { - if (unregister) { - unregister(); - unregister = undefined; - } - }, configureServer(server) { // The browser cannot add a header, so the inbox URL is printed with the // token in its query, the same way the cron/queue hint prints its curl. @@ -92,9 +73,7 @@ export function emailDevInboxPlugin(options: EmailDevInboxPluginOptions): Plugin } voidLog(`Email inbox: http://localhost:${port}${inboxHref('')}`); }; - if (server.httpServer) { - server.httpServer.once('listening', () => setTimeout(printInboxUrl, 100)); - } + afterServerReady(server, printInboxUrl); server.middlewares.use(DEV_INBOX_PREFIX, (req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); diff --git a/packages/void/src/runtime/email-protocol.ts b/packages/void/src/runtime/email-protocol.ts index e6a2e1333..3a4548462 100644 --- a/packages/void/src/runtime/email-protocol.ts +++ b/packages/void/src/runtime/email-protocol.ts @@ -21,54 +21,71 @@ type ReceiptVerifier = { fetch(input: string | Request, init?: RequestInit): Promise; }; -async function beforeDeadline(deadline: number, run: () => Promise): Promise { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - throw new Error('email receipt verification timeout'); +/** + * Settle `run()` within `ms`, or reject with `onTimeout()`. `run` is not + * called when `ms` has already elapsed. + */ +export async function withTimeout( + ms: number, + run: () => Promise, + onTimeout: () => Error, +): Promise { + if (ms <= 0) { + throw onTimeout(); } let timer: ReturnType | undefined; - return Promise.race([ - run(), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('email receipt verification timeout')), remaining); - }), - ]).finally(() => { + try { + return await Promise.race([ + run(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(onTimeout()), ms); + }), + ]); + } finally { if (timer !== undefined) { clearTimeout(timer); } - }); + } } -async function readResponseJson(response: Response, deadline: number): Promise { - const declaredLength = response.headers.get('content-length'); +/** + * Read a JSON body of at most `maxBytes`, each read bounded by `race`. A + * declared length must be exact. + */ +export async function readBoundedJson( + response: Response, + maxBytes: number, + race: (run: () => Promise) => Promise, + { rejectEncoding = false }: { rejectEncoding?: boolean } = {}, +): Promise { + const declared = response.headers.get('content-length'); if ( - response.headers.has('content-encoding') || - !response.body || - (declaredLength !== null && - (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_RECEIPT_VERIFICATION_BYTES)) + (rejectEncoding && response.headers.has('content-encoding')) || + (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) ) { - throw new Error('invalid email receipt verification response'); + void response.body?.cancel().catch(() => {}); + throw new Error('email response refused before reading'); + } + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('email response is empty'); } - const expectedLength = declaredLength === null ? null : Number(declaredLength); - const reader = response.body.getReader(); - const bytes = new Uint8Array(MAX_RECEIPT_VERIFICATION_BYTES); + const bytes = new Uint8Array(maxBytes); let size = 0; try { for (;;) { - const chunk = await beforeDeadline(deadline, () => reader.read()); + const chunk = await race(() => reader.read()); if (chunk.done) { break; } - const nextSize = size + chunk.value.byteLength; - if (nextSize > bytes.byteLength) { - throw new Error('email receipt verification response too large'); + if (size + chunk.value.byteLength > maxBytes) { + throw new Error('email response too large'); } bytes.set(chunk.value, size); - size = nextSize; + size += chunk.value.byteLength; } } finally { - // An uncooperative stream may never settle cancellation. Start cleanup, - // but do not let it extend the hard request-and-response deadline. + // Do not await cancellation: an uncooperative stream may never settle it. void reader.cancel().catch(() => {}); try { reader.releaseLock(); @@ -76,8 +93,8 @@ async function readResponseJson(response: Response, deadline: number): Promise { const deadline = Date.now() + RECEIPT_VERIFICATION_DEADLINE_MS; + const beforeDeadline = (run: () => Promise): Promise => + withTimeout(deadline - Date.now(), run, () => new Error('email receipt verification timeout')); const controller = new AbortController(); let response: Response | undefined; try { - response = await beforeDeadline(deadline, () => + response = await beforeDeadline(() => proxy.fetch('https://proxy/email/verify-receipt', { method: 'POST', headers: { 'content-type': 'application/json', 'x-void-internal': projectToken }, @@ -103,7 +122,9 @@ export async function verifyEmailReceiptThroughProxy( if (!response.ok) { return false; } - const body = await readResponseJson(response, deadline); + const body = await readBoundedJson(response, MAX_RECEIPT_VERIFICATION_BYTES, beforeDeadline, { + rejectEncoding: true, + }); return ( !!body && typeof body === 'object' && diff --git a/packages/void/src/runtime/email.ts b/packages/void/src/runtime/email.ts index aa171b72a..d1805f86e 100644 --- a/packages/void/src/runtime/email.ts +++ b/packages/void/src/runtime/email.ts @@ -1,25 +1,12 @@ -// void/email — outbound email via the platform proxy or a direct binding. -// -// On the managed platform, user worker code never holds a `send_email` -// binding directly. The framework builds MIME locally, then POSTs to -// `__VOID_PROXY/email/send`. The platform gateway enforces authoritative -// project policy and quotas and records each provider submission's outcome. -// -// On the developer's own Cloudflare account (`void deploy --platform -// cloudflare`) there is no proxy: the deploy provisions a `send_email` -// binding named `SEND_EMAIL` on the worker itself and the same MIME goes -// straight through `cloudflare:email`, one `EmailMessage` per recipient. -// Transports are tried in order: dev inbox, `SEND_EMAIL`, `__VOID_PROXY`. -// -// In `void dev` (no `__VOID_PROXY`) sends are captured by the dev inbox; -// pass `sendInDev: true` for a single call to bypass. The dev inbox buffer -// lives in the Vite dev server's NODE process while this code runs in -// workerd, so the capture is an explicit HTTP hop over the dev assets -// binding — see `resolveDevInbox` below and `plugin-email-dev-inbox.ts`. +// void/email — outbound email. MIME is built here, then sent through the +// first available transport: the `void dev` inbox, the worker's own +// `SEND_EMAIL` binding (`void deploy --platform cloudflare`, one +// `EmailMessage` per recipient), or the Void platform proxy, which enforces +// project policy and quotas. import { isRecord } from '@void/deploy-core'; +import { readBoundedJson, withTimeout } from './email-protocol.ts'; import { - bytesToBase64, captureMessage, DEV_INBOX_ACK_HEADER, DEV_INBOX_ACK_VALUE, @@ -32,8 +19,13 @@ import { serializeCapturedMessage, type CapturedMessage, } from './email/dev-inbox.ts'; -import { bytesToReadableStream } from './email/inbound.ts'; -import { buildMime, MimeError, normalizeEmail } from './email/mime.ts'; +import { + bytesToReadableStream, + readEnvString, + readProjectSlug, + tryGetRawRuntimeEnv, +} from './email/inbound.ts'; +import { base64, buildMime, MimeError, newId, normalizeEmail } from './email/mime.ts'; import type { EmailDeliveryState, NormalizedEmail, @@ -44,7 +36,6 @@ import type { SendEmailRecipientResult, SendEmailResult, } from './email/types.ts'; -import { getRawRuntimeEnv } from './env-raw.ts'; export type { Address, @@ -79,74 +70,14 @@ export type { ReplyEmailOptions, } from './email/inbound.ts'; -// `createEmail` below throws `MimeError`, and `sendEmail` reports its `code`. -// The barrel is the only supported entry point (`package.json` exposes -// `./email` and `./email/testing`, with no wildcard subpath), so the error -// classes have to ship with the functions that throw them. +// `createEmail` throws `MimeError`; export it with the functions that throw it. export { MimeError } from './email/mime.ts'; export type { MimeErrorCode } from './email/mime.ts'; -// Slug shape we'll accept when substituting into the default `from`. The -// platform owns slug allocation and validates at create-time, but we don't -// trust the value blindly — a slug containing `@`, `+`, or whitespace -// would corrupt the resulting address. RFC 5321/2 atom char-class -// restricted further to lowercase + digits + hyphen, matching the typical -// project-slug shape. -// -// Length cap: 56 chars. The default `from` we build is -// `+noreply@<__VOID_EMAIL_DOMAIN>`; the SMTP local-part limit -// (RFC 5321 § 4.5.3.1.1) is 64 octets, and `+noreply` already eats 8. -// Capping the slug at `64 - 8 = 56` keeps the assembled local-part -// within spec. The cap is domain-independent — only the local-part is -// bounded here. -const VALID_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,54}[a-z0-9])?$/; - function makeError(code: SendEmailErrorCode, message: string, cause?: unknown): SendEmailError { return cause === undefined ? { code, message } : { code, message, cause }; } -// `getRawRuntimeEnv()` throws outside a worker/SSR context. `sendEmail` needs -// graceful degradation in those settings (local Node tools, tests), so resolve -// once via this helper. -function tryGetRawRuntimeEnv(): Record | undefined { - try { - return getRawRuntimeEnv(); - } catch { - return undefined; - } -} - -function readProjectSlug(): string | undefined { - const raw = tryGetRawRuntimeEnv()?.__VOID_PROJECT_SLUG; - if (typeof raw !== 'string' || raw.length === 0) { - return undefined; - } - // Defense-in-depth: refuse to substitute an obviously malformed slug - // into the address. Returning undefined causes the caller to surface - // `INVALID_FROM` rather than build a corrupt envelope. - return VALID_SLUG.test(raw) ? raw : undefined; -} - -// The platform mail domain for this environment (production uses -// `mail.example.com`, staging its own zone). It is injected per -// deployment rather than baked in, so a staging send is not rejected by -// the proxy's sender check. There is deliberately no built-in default: -// silently falling back to the production zone is the bug this binding -// exists to prevent. -function readEmailDomain(): string | undefined { - const raw = tryGetRawRuntimeEnv()?.__VOID_EMAIL_DOMAIN; - return typeof raw === 'string' && raw.length > 0 ? raw : undefined; -} - -// The project's own default sender, written into the worker's `vars` by -// `void deploy --platform cloudflare` from `email.from` in void.config.ts. It is a -// full address on a zone the developer owns, so there is no slug to -// substitute; `normalizeEmail` validates it like any explicit `from`. -function readEmailFrom(): string | undefined { - const raw = tryGetRawRuntimeEnv()?.__VOID_EMAIL_FROM; - return typeof raw === 'string' && raw.length > 0 ? raw : undefined; -} - interface ProxyEnv { __VOID_PROXY?: { fetch(req: Request | string, init?: RequestInit): Promise }; __VOID_PROXY_TOKEN?: string; @@ -166,33 +97,6 @@ class EmailBindingTimeoutError extends Error { } } -function createBindingSendDeadline() { - const deadline = Date.now() + PLATFORM_SEND_DEADLINE_MS; - return { - expired: () => Date.now() >= deadline, - async run(submit: () => Promise): Promise { - const remaining = Math.min(EMAIL_PROVIDER_DEADLINE_MS, deadline - Date.now()); - if (remaining <= 0) { - throw new EmailBindingTimeoutError(); - } - const work = submit(); - let timer: ReturnType | undefined; - try { - return await Promise.race([ - work, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new EmailBindingTimeoutError()), remaining); - }), - ]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } - }, - }; -} - // The legacy `EmailMessage` overload of `SendEmail.send`. Only `messageId` // is read, and defensively: some builds have returned nothing. interface SendEmailBinding { @@ -212,15 +116,10 @@ function resolveSendEmailBinding( } /** - * Map an error thrown by the `send_email` binding to a framework code. - * - * Email Service throws `Error`s carrying a `code` (the `E_*` table in - * Cloudflare's Workers API reference); the older Email Routing sender - * throws message-only errors, so the destination-verification phrasing the - * platform proxy already keys on is kept as a fallback. Anything else is - * `OUTCOME_UNKNOWN` with the original error as `cause`: once `send()` has - * started, an undocumented rejection does not prove the provider declined - * the message before accepting it. + * Map a `send_email` binding error to a framework code: Email Service `E_*` + * codes, then the older Email Routing message. Anything else is + * `OUTCOME_UNKNOWN`: once `send()` started, a rejection does not prove the + * provider declined the message. */ function classifyBindingError(e: unknown): SendEmailError { const message = e instanceof Error ? e.message : String(e); @@ -251,11 +150,9 @@ const UNVERIFIED_DESTINATION_RE = /^[^\n]{0,200}destination address[^\n]{0,200}not (?:yet )?verified/i; /** - * Deliver through the worker's own `SEND_EMAIL` binding. One `EmailMessage` - * per recipient, within one bounded batch, and the same `deliveries` shape - * the proxy returns so a caller can inspect each attempted or cancelled - * outcome in either mode. The stream is rebuilt per send because a - * `ReadableStream` is one-shot. + * Send one `EmailMessage` per recipient through the worker's own binding, + * within one bounded batch, reporting `deliveries` as the proxy does. The + * stream is rebuilt per send: a `ReadableStream` is one-shot. */ async function sendViaBinding( binding: SendEmailBinding, @@ -275,10 +172,11 @@ async function sendViaBinding( ); } const deliveries: Array = []; - const deadline = createBindingSendDeadline(); + const deadline = Date.now() + PLATFORM_SEND_DEADLINE_MS; + const remaining = () => Math.min(EMAIL_PROVIDER_DEADLINE_MS, deadline - Date.now()); for (let index = 0; index < recipients.length; index++) { const recipient = recipients[index]; - if (deadline.expired()) { + if (remaining() <= 0) { for (const unstarted of recipients.slice(index)) { deliveries.push({ recipient: unstarted, @@ -293,8 +191,10 @@ async function sendViaBinding( break; } try { - const result = await deadline.run(() => - binding.send(new EmailMessage(from, recipient, bytesToReadableStream(raw))), + const result = await withTimeout( + remaining(), + () => binding.send(new EmailMessage(from, recipient, bytesToReadableStream(raw))), + () => new EmailBindingTimeoutError(), ); const mid = typeof result?.messageId === 'string' ? result.messageId : ''; deliveries.push({ recipient, ok: true, messageId: mid || fallbackId }); @@ -328,10 +228,8 @@ async function sendViaBinding( } /** - * Dedup recipients by lowercased email (CF won't collapse duplicate - * envelopes for us, and double-billing the quota for the same recipient - * is worse than the rare case where a user actually wanted Cc + Bcc to - * the same address — which doesn't change delivery semantics anyway). + * Recipients deduplicated by lowercased address: Cloudflare does not + * collapse duplicate envelopes, and each one costs quota. */ function uniqueRecipients(normalized: NormalizedEmail): Array { const seen = new Set(); @@ -347,22 +245,10 @@ function uniqueRecipients(normalized: NormalizedEmail): Array { return recipients; } -function newRequestId(): string { - const bytes = new Uint8Array(8); - crypto.getRandomValues(bytes); - let hex = ''; - for (const b of bytes) { - hex += b.toString(16).padStart(2, '0'); - } - return `req_${Date.now().toString(36)}${hex}`; -} - function resolveDefaultFrom(opts: SendEmailOptions): SendEmailOptions { const supplied = opts.from; - // Treat literal `null` the same as undefined here. Passing `null` - // through to `normalizeEmail` makes `parseAddress` throw a TypeError - // that gets surfaced as the opaque MIME_ERROR code; callers expect - // INVALID_FROM for "from is missing/empty". + // `null` counts as missing: `parseAddress` would throw a TypeError, + // reported as MIME_ERROR rather than INVALID_FROM. const isEmpty = supplied === undefined || supplied === null || @@ -371,18 +257,16 @@ function resolveDefaultFrom(opts: SendEmailOptions): SendEmailOptions { if (!isEmpty) { return opts; } - // `__VOID_EMAIL_FROM` (own-account deploys) wins over the platform's - // `+noreply@`: a project that carries both is one whose - // void.config.ts names a sender on purpose. - const configured = readEmailFrom(); + // `__VOID_EMAIL_FROM` (`email.from` in void.config.ts) wins over the + // platform's `+noreply@`: it names a sender on purpose. + const configured = readEnvString('__VOID_EMAIL_FROM'); if (configured) { return { ...opts, from: configured }; } const slug = readProjectSlug(); - const domain = readEmailDomain(); + const domain = readEnvString('__VOID_EMAIL_DOMAIN'); if (!slug || !domain) { - // Strip the empty value so the downstream `from === undefined` check - // surfaces a clean INVALID_FROM rather than passing `null` along. + // Drop `null` so the caller reports a clean INVALID_FROM. if (supplied === null) { const next = { ...opts }; delete (next as { from?: unknown }).from; @@ -430,11 +314,9 @@ function coerceErrorCode(code: unknown): SendEmailErrorCode { } /** - * The text of a refusal the proxy's middleware sent instead of a send - * response: `{ error: string, message?: string }`. `message` is the - * sentence written for a person (the capability gate's "This Void platform - * does not enable email."); `error` is the short code every such refusal - * carries. `null` when the body is not that shape. + * Text of the `{ error, message? }` refusal the proxy's middleware sends + * ahead of the email handler, preferring the human `message`; `null` for any + * other shape. */ function readProxyRefusal(body: unknown): string | null { if (typeof body !== 'object' || body === null || Array.isArray(body)) { @@ -479,10 +361,9 @@ function readProxyResponse(value: unknown): ProxyResponse | null { type RecipientListRead = { ok: true; entries: Array } | { ok: false; reason: string }; /** - * Require exactly one valid outcome per requested recipient in either list - * shape. A missing, duplicate, or foreign entry makes the whole result unknown; - * trimming it could hide a submission. Order is not part of the contract. - * Diagnostics identify entries by index rather than echoing corrupt addresses. + * Require exactly one valid outcome per requested recipient, in any order. A + * missing, duplicate or foreign entry makes the whole result unknown, since + * trimming it could hide a submission. Diagnostics name entries by index. */ function readRecipientList( raw: Array, @@ -519,11 +400,8 @@ function readRecipientList( } /** - * The `ids` list of an `ok: true` response. The proxy answers `ok: true` only - * after every requested recipient has an accepted provider outcome. Each entry - * is `{ recipient, messageId }`; the framework's own Message-ID stands in for - * an empty provider reference. A missing delivery would leave no safe retry - * decision, so the whole response is rejected as malformed. + * The `ids` of an `ok: true` response, one accepted outcome per recipient. + * The framework's Message-ID stands in for an empty provider reference. */ function readDeliveryList( raw: Array, @@ -603,162 +481,63 @@ function readDeliveryState(value: unknown): EmailDeliveryState | undefined { const MAX_PROXY_RESPONSE_BYTES = 64 * 1024; -function createPlatformSendDeadline(): { - signal: AbortSignal; - beforeDeadline: (run: () => Promise) => Promise; -} { - const controller = new AbortController(); - const deadline = Date.now() + PLATFORM_SEND_DEADLINE_MS; - return { - signal: controller.signal, - async beforeDeadline(run: () => Promise): Promise { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - controller.abort(); - throw new Error('email platform send timed out'); - } - const promise = run(); - let timer: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => { - controller.abort(); - reject(new Error('email platform send timed out')); - }, remaining); - }), - ]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } - }, - }; -} - -async function readBoundedProxyJson( - response: Response, - beforeDeadline: (run: () => Promise) => Promise, -): Promise { - const declared = response.headers.get('content-length'); - if ( - declared !== null && - (!/^\d+$/.test(declared) || Number(declared) > MAX_PROXY_RESPONSE_BYTES) - ) { - void response.body?.cancel().catch(() => {}); - throw new Error('email proxy response too large'); - } - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('email proxy response is empty'); - } - const bytes = new Uint8Array(MAX_PROXY_RESPONSE_BYTES); - let size = 0; - try { - for (;;) { - const chunk = await beforeDeadline(() => reader.read()); - if (chunk.done) { - break; - } - if (size + chunk.value.byteLength > bytes.byteLength) { - throw new Error('email proxy response too large'); - } - bytes.set(chunk.value, size); - size += chunk.value.byteLength; - } - if (declared !== null && size !== Number(declared)) { - throw new Error('email proxy response length mismatch'); - } - return JSON.parse( - new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes.subarray(0, size)), - ) as unknown; - } finally { - void reader.cancel().catch(() => {}); - try { - reader.releaseLock(); - } catch { - // A timed-out read may still be settling after cancellation. - } - } -} - async function readResponseAsResult( res: Response, recipients: ReadonlyArray, fallbackId: string, beforeDeadline: (run: () => Promise) => Promise, ): Promise { + let body: unknown; try { - let body: unknown; - try { - body = await readBoundedProxyJson(res, beforeDeadline); - } catch { - return err( - 'OUTCOME_UNKNOWN', - `email: could not establish the send outcome (status ${res.status}); check email logs or repeat the same idempotency key`, - ); - } - const response = readProxyResponse(body); - if (response === null) { - // The proxy's own middleware — auth, platform lifecycle, capability - // gates — answers ahead of the email handler with a flat - // `{ error: string, message?: string }` and a non-2xx status. That - // is a well-formed refusal, not a malformed send response, so carry - // its text through the way the handler's `{ ok: false, error }` is - // carried above; a platform without the email module otherwise reads - // as "malformed response (status 501)" with the reason buried in - // `cause`. - const refusal = readProxyRefusal(body); - if (!res.ok && refusal !== null) { - return err('UPSTREAM_ERROR', `email: proxy returned ${res.status}: ${refusal}`, body); - } - return err( - 'OUTCOME_UNKNOWN', - `email: proxy returned malformed response (status ${res.status})`, - body, - ); - } - // Both list-bearing shapes are validated as a whole against the - // recipients this send asked for; their entries remain unknown until - // the whole list passes validation. - const malformedList = (reason: string): SendEmailResult => - err( - 'OUTCOME_UNKNOWN', - `email: proxy returned a malformed delivery list (${reason}); do not retry with a new idempotency key`, - body, - ); - const operation = - typeof response.operationId === 'string' && response.operationId.length <= 128 - ? { operationId: response.operationId } - : {}; - if (response.ok === true) { - const list = readDeliveryList(response.ids, recipients, fallbackId); - return list.ok ? { ok: true, ids: list.entries, ...operation } : malformedList(list.reason); - } - if ('deliveries' in response) { - const list = readFailureDeliveries(response.deliveries, recipients, fallbackId); - return list.ok - ? { ok: false, deliveries: list.entries, ...operation } - : malformedList(list.reason); + body = await readBoundedJson(res, MAX_PROXY_RESPONSE_BYTES, beforeDeadline); + } catch { + return err( + 'OUTCOME_UNKNOWN', + `email: could not establish the send outcome (status ${res.status}); check email logs or repeat the same idempotency key`, + ); + } + const response = readProxyResponse(body); + if (response === null) { + // A `{ error, message? }` refusal from the proxy's middleware is + // well-formed: report its text. + const refusal = readProxyRefusal(body); + if (!res.ok && refusal !== null) { + return err('UPSTREAM_ERROR', `email: proxy returned ${res.status}: ${refusal}`, body); } - const e = response.error; - return { - ...err( - coerceErrorCode(e.code), - typeof e.message === 'string' ? e.message : 'email: upstream send failed', - ), - ...operation, - }; - } catch (e) { - // Parsing failures must not imply that no provider submission occurred. return err( 'OUTCOME_UNKNOWN', - `email: failed to parse proxy response (status ${res.status}): ${e instanceof Error ? e.message : String(e)}`, - e, + `email: proxy returned malformed response (status ${res.status})`, + body, ); } + const malformedList = (reason: string): SendEmailResult => + err( + 'OUTCOME_UNKNOWN', + `email: proxy returned a malformed delivery list (${reason}); do not retry with a new idempotency key`, + body, + ); + const operation = + typeof response.operationId === 'string' && response.operationId.length <= 128 + ? { operationId: response.operationId } + : {}; + if (response.ok === true) { + const list = readDeliveryList(response.ids, recipients, fallbackId); + return list.ok ? { ok: true, ids: list.entries, ...operation } : malformedList(list.reason); + } + if ('deliveries' in response) { + const list = readFailureDeliveries(response.deliveries, recipients, fallbackId); + return list.ok + ? { ok: false, deliveries: list.entries, ...operation } + : malformedList(list.reason); + } + const e = response.error; + return { + ...err( + coerceErrorCode(e.code), + typeof e.message === 'string' ? e.message : 'email: upstream send failed', + ), + ...operation, + }; } interface DevInboxFetcher { @@ -768,23 +547,16 @@ interface DevInboxFetcher { interface DevInbox { fetcher: DevInboxFetcher; /** - * The dev-trigger token the inbox middleware requires. Injected under the - * same gate as the binding var; when it is somehow absent the POST still - * goes out and the dev server's 401 is reported verbatim — the inbox is - * never skipped in favour of a real send transport. + * Dev-trigger token. When absent the POST still goes out and the 401 is + * reported; the inbox is never skipped for a real transport. */ token: string | undefined; } /** - * Resolve the loopback into the `void dev` inbox, or `undefined` outside dev. - * - * `DEV_INBOX_ENV_VAR` is injected only when `viteCommand === 'serve'` and it - * names the worker's assets binding. In dev, `@cloudflare/vite-plugin` binds - * that name to a miniflare service whose implementation is the Vite dev - * server's Node middleware stack, so a fetch through it lands on the inbox - * middleware in the dev-server process — the only place the buffer exists. - * In a production build neither the var nor that wiring is present. + * The loopback into the `void dev` inbox: `DEV_INBOX_ENV_VAR` names the + * assets binding, which in dev reaches the Vite dev server. Undefined + * outside dev. */ function resolveDevInbox(env: Record | undefined): DevInbox | undefined { const bindingName = env?.[DEV_INBOX_ENV_VAR]; @@ -814,10 +586,8 @@ const DEV_INBOX_ERROR_EXCERPT_CHARS = 200; const DEV_INBOX_ERROR_EXCERPT_TIMEOUT_MS = 1000; /** - * First line of a refusal, so a 401 names the header it wanted. Reads ONE - * chunk under its own deadline: the main 5s race has already settled by the - * time the status is known, and a body that never arrives must not turn a - * clean error into a hung `sendEmail`. + * First line of a refusal, so a 401 names the header it wanted. One chunk + * under its own deadline: a stalled body must not hang `sendEmail`. */ async function readErrorExcerpt(res: Response): Promise { let timer: ReturnType | undefined; @@ -884,8 +654,6 @@ async function postToDevInbox( } const { res } = settled; if (!res.ok) { - // A 401 names the header it wanted; surface that instead of a bare - // status so a missing token is diagnosable from the worker's console. const excerpt = await readErrorExcerpt(res); return { ok: false, @@ -907,24 +675,11 @@ async function postToDevInbox( } } -/** - * Shared result shape for both capture paths (in-process sink and dev inbox). - * - * `ids` is built from the same deduplicated, lowercased list the two - * production transports send to, so code written against a captured result - * sees exactly what a deploy returns: one entry per unique address, keyed the - * way `sendViaBinding` and the proxy report it. The captured message itself - * keeps the original fields; the inbox displays what was asked for, the - * result reports what would be sent. - */ +/** A captured send's result: one id per unique recipient, as a deployed send reports. */ function capturedResult(normalized: NormalizedEmail, captureId: string): SendEmailResult { - const recipients = uniqueRecipients(normalized); - if (recipients.length === 0) { - return err('INVALID_TO', "email: 'to' must include at least one recipient."); - } return { ok: true, - ids: recipients.map((recipient) => ({ recipient, messageId: captureId })), + ids: uniqueRecipients(normalized).map((recipient) => ({ recipient, messageId: captureId })), }; } @@ -962,21 +717,11 @@ export async function sendEmail(opts: SendEmailOptions): Promise(run: () => Promise): Promise => + withTimeout(deadline - Date.now(), run, () => { + controller.abort(); + return new Error('email platform send timed out'); + }); let res: Response; try { - res = await deadline.beforeDeadline(() => + res = await beforeDeadline(() => proxy.fetch('https://email-proxy/email/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, - signal: deadline.signal, + signal: controller.signal, }), ); } catch (e) { @@ -1073,7 +818,7 @@ export async function sendEmail(opts: SendEmailOptions): Promise dev-server hop (`raw` as base64). */ export function serializeCapturedMessage(message: CapturedMessage): string { - return JSON.stringify({ ...message, raw: bytesToBase64(message.raw) }); + return JSON.stringify({ ...message, raw: base64(message.raw) }); } function asAddress(value: unknown): { email: string; name?: string } | undefined { @@ -201,12 +122,17 @@ export function parseCapturedMessage(value: unknown): CapturedMessage | undefine if (typeof v.id !== 'string' || typeof v.raw !== 'string') { return undefined; } - const raw = base64ToBytes(v.raw); + let raw: Uint8Array; + try { + raw = base64Decode(v.raw); + } catch { + return undefined; + } const from = asAddress(v.from); const to = asAddressList(v.to); const cc = asAddressList(v.cc); const bcc = asAddressList(v.bcc); - if (!raw || !from || !to || !cc || !bcc) { + if (!from || !to || !cc || !bcc) { return undefined; } const attachments: CapturedMessage['attachments'] = []; @@ -257,15 +183,7 @@ const SINK_KEY = '__voidEmailSinks__'; const BUFFER_KEY = '__voidEmailDevBuffer__'; const DEFAULT_BUFFER_LIMIT = 100; -/** - * Retained-byte budget for the buffer, alongside the count cap. - * - * A count cap alone does not bound the buffer: every item holds its full raw - * MIME plus the decoded `text`/`html`, and a single message may reach - * `MAX_MESSAGE_BYTES` (5 MiB), so 100 html-heavy messages is on the order of - * gigabytes. This buffer lives in the Vite dev server's Node process — not in - * workerd — so that is the dev server's own heap. - */ +/** Retained-byte budget: 100 messages of up to 5 MiB each would fill the dev server's heap. */ const DEFAULT_BUFFER_BYTES = 64 * 1024 * 1024; interface DevBuffer { @@ -370,7 +288,7 @@ export function captureMessage( messageId: string, ): CapturedMessage { return { - id: generateCaptureId(), + id: newId('dev'), capturedAt: Date.now(), raw, messageId, @@ -392,14 +310,3 @@ export function captureMessage( })), }; } - -function generateCaptureId(): string { - // Compact ULID-like id: timestamp + 8 random bytes hex. - const bytes = new Uint8Array(8); - crypto.getRandomValues(bytes); - let hex = ''; - for (const b of bytes) { - hex += b.toString(16).padStart(2, '0'); - } - return `dev_${Date.now().toString(36)}${hex}`; -} diff --git a/packages/void/src/runtime/email/inbound.ts b/packages/void/src/runtime/email/inbound.ts index 8ef6e9df4..fe0651348 100644 --- a/packages/void/src/runtime/email/inbound.ts +++ b/packages/void/src/runtime/email/inbound.ts @@ -9,21 +9,24 @@ import { EMAIL_MAX_METADATA_BYTES, EMAIL_MAX_REPLY_BYTES, EMAIL_MAX_RESPONSE_BYTES, + type EmailWireAction, } from '@void/platform/email-protocol'; import type { Email as PostalMimeEmail } from 'postal-mime'; +import { withTimeout } from '../email-protocol.ts'; import { getRawRuntimeEnv } from '../env-raw.ts'; -import { buildMime, MimeError, normalizeEmail, validateAddressParts } from './mime.ts'; +import { + base64Decode, + buildMime, + indexOfControl, + MimeError, + normalizeEmail, + validateAddressParts, +} from './mime.ts'; import type { Address, Attachment } from './types.ts'; -// Mirror the slug-validity rule from runtime/email.ts so a malformed -// __VOID_PROJECT_SLUG injection can't smuggle invalid characters into -// the default reply `from` address. -const VALID_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,54}[a-z0-9])?$/; - // SDK limits give handlers early errors. The gateway independently validates // untrusted tenant responses before admitting any action. const MAX_ACTIONS = EMAIL_MAX_ACTIONS; -const MAX_ACTION_ADDRESS_LENGTH = 320; const MAX_ACTION_METADATA_BYTES = EMAIL_MAX_METADATA_BYTES; /** Bounds on a forward's headers. */ @@ -40,6 +43,8 @@ export const MAX_INBOUND_HEADERS: number = EMAIL_MAX_INBOUND_HEADERS; const MAX_REPLY_BYTES = EMAIL_MAX_REPLY_BYTES; const EMAIL_STREAM_DEADLINE_MS = 15_000; const EMAIL_ACTION_TERMINAL_BYTES = 5; +const INBOUND_PROVIDER_DEADLINE_MS = 30_000; +const INBOUND_ACTION_BATCH_DEADLINE_MS = 60_000; const actionEncoder = new TextEncoder(); function emailActionWireSize(action: EmailAction): number { @@ -51,39 +56,32 @@ function emailActionWireSize(action: EmailAction): number { return 5 + metadata.byteLength + (action.type === 'reply' ? 5 + action.raw.byteLength : 0); } -function readRawProjectSlug(): string | undefined { - let raw: unknown; +/** The raw env, or `undefined` outside a worker or SSR request. */ +export function tryGetRawRuntimeEnv(): Record | undefined { try { - raw = getRawRuntimeEnv()?.__VOID_PROJECT_SLUG; + return getRawRuntimeEnv(); } catch { return undefined; } - return typeof raw === 'string' && raw.length > 0 ? raw : undefined; } -function tryReadProjectSlug(): string | undefined { - const raw = readRawProjectSlug(); - return raw !== undefined && VALID_SLUG.test(raw) ? raw : undefined; +/** A non-empty string env var. */ +export function readEnvString(name: string): string | undefined { + const raw = tryGetRawRuntimeEnv()?.[name]; + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; } -// The platform mail domain for this environment, injected per deployment -// (production uses `mail.example.com`, staging its own zone). Mirrors -// `readEmailDomain` in runtime/email.ts — no baked-in default, so a -// staging reply is never built against the production zone. -function tryReadEmailDomain(): string | undefined { - let raw: unknown; - try { - raw = getRawRuntimeEnv()?.__VOID_EMAIL_DOMAIN; - } catch { - return undefined; - } - return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +// At most 56 characters, so `+noreply` fits RFC 5321's 64-octet local part. +const VALID_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,54}[a-z0-9])?$/; + +/** `__VOID_PROJECT_SLUG` when it can head a sender address. */ +export function readProjectSlug(): string | undefined { + const raw = readEnvString('__VOID_PROJECT_SLUG'); + return raw !== undefined && VALID_SLUG.test(raw) ? raw : undefined; } -// The relay-lane marker `createMessageProxy` sets from `envelope.viaDomain` -// — the registered custom domain the message arrived on. Undefined for a -// platform-lane or own-account message, and for a hand-built message in a -// test that never went through the proxy. +// The relay-lane marker `createMessageProxy` sets from `envelope.viaDomain`; +// undefined for platform-lane, own-account and hand-built messages. function readRelayDomain(message: ForwardableEmailMessage): string | undefined { const raw: unknown = (message as { __voidRelayDomain?: unknown }).__voidRelayDomain; return typeof raw === 'string' && raw.length > 0 ? raw : undefined; @@ -132,13 +130,9 @@ export class ParseEmailError extends Error { } /** - * Parse a `ForwardableEmailMessage` into structured fields. postal-mime is - * loaded lazily so workers that never call this don't bundle it. - * - * For proxy messages (built via `createMessageProxy`) the raw bytes are - * cached on first read so subsequent `parseEmail`/`message.raw` accesses - * still work. For real CF `ForwardableEmailMessage`s the raw stream is - * one-shot. + * Parse a `ForwardableEmailMessage` into structured fields with postal-mime, + * loaded on first use. A native Cloudflare message's `raw` stream is + * one-shot, so parse it once. */ export async function parseEmail(message: ForwardableEmailMessage): Promise { if (message.rawSize > MAX_INBOUND_BYTES) { @@ -150,15 +144,10 @@ export async function parseEmail(message: ForwardableEmailMessage): Promise256 - // levels), and a one-shot `raw` stream can fail on a real CF message. throw new ParseEmailError( 'PARSE_FAILED', `parseEmail: could not parse the message body. ${cause instanceof Error ? cause.message : String(cause)}`, @@ -167,12 +156,7 @@ export async function parseEmail(message: ForwardableEmailMessage): Promise; } @@ -197,10 +181,7 @@ export interface EmailEnvelope { } /** Handler intents, validated and admitted by the gateway before delivery. */ -export type EmailAction = - | { type: 'reject'; reason: string } - | { type: 'forward'; to: string; headers?: Record } - | { type: 'reply'; from: string; to: string; raw: Uint8Array }; +export type EmailAction = EmailWireAction; export interface MessageProxy { message: ForwardableEmailMessage; @@ -209,24 +190,13 @@ export interface MessageProxy { export interface CreateMessageProxyOptions { /** - * Project slug stripped from the `+` recipient by the - * dispatcher. When set, `message.to` is rewritten to the bare - * `@` form so user handlers see clean addresses (the slug - * is platform-internal). The original `+@` is - * preserved on a non-enumerable `__voidPlatformTo` property for - * diagnostics. + * Project slug the platform prefixes recipients with + * (`+@`). When set, `message.to` drops the `+`. */ slug?: string; } -/** - * Remove the platform-injected `+` prefix from a recipient address. - * The platform's shared sender (e.g. `mail.example.com`) rewrites every recipient to - * `+@`; user handlers expect `@`. If the - * slug is unknown, missing, or doesn't match the local-part prefix, the - * address is returned unchanged so callers without slug context (e.g. the - * test harness when no slug is set) don't accidentally mangle the address. - */ +/** Remove a leading `+` (case-insensitive) from the local part. */ function stripSlugFromAddress(address: string, slug: string | undefined): string { if (!slug) { return address; @@ -237,8 +207,6 @@ function stripSlugFromAddress(address: string, slug: string | undefined): string } const localPart = address.slice(0, atIdx); const domain = address.slice(atIdx); - // Match `+...` case-insensitively. If the local-part starts with - // the slug followed by `+`, drop the `+` segment. const slugLower = slug.toLowerCase(); const prefix = `${slugLower}+`; if (localPart.toLowerCase().startsWith(prefix)) { @@ -269,29 +237,16 @@ function queueFull(): MimeError { ); } -function addressTooLong(field: 'from' | 'to', length: number): MimeError { - return new MimeError( - field === 'from' ? 'INVALID_FROM' : 'INVALID_TO', - `${field} address is ${length} characters, over the ${MAX_ACTION_ADDRESS_LENGTH}-character limit.`, - ); -} - function invalidForwardHeaders(cause: unknown): MimeError { - // The runtime's own message names the offending name or value. A handler - // may have built that value from the inbound message, so it is bounded. + // The detail may echo inbound-derived text, so it is bounded. const detail = cause instanceof Error ? cause.message.slice(0, 320) : 'not a valid header set'; return new MimeError('MIME_ERROR', `forward: invalid header — ${detail}`); } /** - * The router's `forwardHeadersWithinBounds`, as a throw. It runs on the - * record about to be recorded — the very object the router receives, so the - * count is the router's count: a `Headers` folded its duplicate names before - * it got here, and a record keeps `X-A` and `x-a` as two, both there and - * here. Runs after the `new Headers()` validity check, so every name is a - * token and every value is header-safe; what is left is size. The router - * drops the WHOLE list on any of these — a handler that passed here and - * failed there deployed as one whose managed delivery performed no actions. + * The router's `forwardHeadersWithinBounds`, as a throw: the router drops the + * whole action list on a violation, so refuse it at the handler's own call. + * Counts the record as stored (a `Headers` has already folded duplicates). */ function assertForwardHeadersWithinBounds(headers: Record): void { const names = Object.keys(headers); @@ -308,9 +263,8 @@ function assertForwardHeadersWithinBounds(headers: Record): void `forward: header name is ${name.length} characters, over the ${MAX_FORWARD_HEADER_LENGTH}-character limit.`, ); } - // The declared value type is `string`, and the router refuses anything - // else: `new Headers()` coerces an Array with `String()`, so a non-string - // value has no `length` the bound below could measure. + // The router refuses a non-string value, which `new Headers()` would + // have coerced. const value: unknown = headers[name]; if (typeof value !== 'string') { throw new MimeError( @@ -328,25 +282,9 @@ function assertForwardHeadersWithinBounds(headers: Record): void } /** - * The router's rule for a reject reason (`validateActions` in - * `email-router.ts`: `sanitize(raw, MAX_REJECT_REASON_LENGTH)`, and - * `'rejected by handler'` for an empty one), applied when the reason is - * RECORDED so the native replay carries the string the managed one does. - * - * The reason goes into the SMTP reply to the connecting client, and a - * handler may build it from the inbound message: a remote sender's CRLF is - * then a line injected into that reply, and a multi-megabyte reason is a - * transient-heap amplifier. The router already refuses both; normalizing at - * record time covers the native `setReject()` replay on your own account - * too. NORMALIZED rather than refused, unlike the forward bounds - * above: a reject is the handler's terminal decision, and throwing out of - * it would drop that decision and bounce the message as a dispatch error - * (see the `MAX_ACTIONS` note in `setReject`). Slice FIRST, then replace: a - * global replace materializes ~66 bytes per match, so the slice is what - * bounds the work (see `prefixReplySubject`). The result can be SHORTER - * than the cap: a run of controls inside the window collapses to one space - * and the window is not refilled from beyond it — the router's own trade, - * and it is DSN text. + * The router's reject-reason rule, applied at record time so native replay + * sends the same string. Normalized rather than refused, since a throw would + * drop the reject; slice before replace to bound the work. */ function normalizeRejectReason(reason: string): string { // A JS handler can pass anything; the router treats a non-string as empty. @@ -365,10 +303,7 @@ function normalizeRejectReason(reason: string): string { async function readStreamToBytes( stream: ReadableStream, limit = Infinity, - // The caller decides what crossing `limit` MEANS: a single reply over - // `MAX_MESSAGE_BYTES`, or the cumulative budget running out mid-stream. - // Without this the second case would be reported as the first, naming a - // limit the reply never reached. + // Names the limit that was crossed: one reply's cap or the shared budget. onOverflow: (total: number) => MimeError = replyTooLarge, ): Promise { const reader = stream.getReader(); @@ -377,24 +312,11 @@ async function readStreamToBytes( const deadline = Date.now() + EMAIL_STREAM_DEADLINE_MS; try { for (;;) { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - throw new MimeError('MIME_ERROR', 'email stream timed out'); - } - let timer: ReturnType | undefined; - const { value, done } = await Promise.race([ - reader.read(), - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new MimeError('MIME_ERROR', 'email stream timed out')), - remaining, - ); - }), - ]).finally(() => { - if (timer !== undefined) { - clearTimeout(timer); - } - }); + const { value, done } = await withTimeout( + deadline - Date.now(), + () => reader.read(), + () => new MimeError('MIME_ERROR', 'email stream timed out'), + ); if (done) { break; } @@ -405,8 +327,7 @@ async function readStreamToBytes( chunks.push(value); } } catch (error) { - // Cancellation is cleanup, not part of the stream deadline. A custom - // handler stream may never settle its underlying cancel algorithm. + // Do not await cancel: a custom handler stream may never settle it. void reader.cancel().catch(() => {}); throw error; } finally { @@ -435,23 +356,10 @@ export function bytesToReadableStream(bytes: Uint8Array): ReadableStream+` prefix so handlers see `@` - * instead of `+@`. The original wire-form recipient is - * preserved on a non-enumerable `__voidPlatformTo` property. + * Build the message a handler sees from an envelope. `setReject`, `forward` + * and `reply` record `EmailAction`s instead of dispatching; the caller enacts + * them after the handler returns. `reply()` takes `{ from, to, raw }`, which + * `replyEmail()` passes: a native `EmailMessage` has no readable body. */ export function createMessageProxy( envelope: EmailEnvelope, @@ -460,26 +368,14 @@ export function createMessageProxy( const actions: Array = []; const headers = new Headers(envelope.headers); - // A relayed recipient carries no `+` prefix (the relay lane is - // authenticated by the domain's relay secret, and `viaDomain` names that - // domain), so the slug is inert here even if a caller passed one — the - // generated `handleEmail` already suppresses it, and this keeps a - // coincidental `+` local-part prefix from being stripped off a - // customer-domain address. + // A relayed recipient (on `viaDomain`) carries no `+` prefix, so a + // coincidental one on a customer address is never stripped. const slug = envelope.viaDomain === undefined || envelope.viaDomain === '' ? opts.slug : undefined; - // Strip the platform-injected `+` prefix from message.to so handler - // authors see the same address shape they wrote in `email/.ts`. The - // dispatcher strips the same prefix for routing; mirroring it here keeps - // the user-visible envelope consistent with the routing surface. const userVisibleTo = stripSlugFromAddress(envelope.to, slug); - // Cache raw bytes lazily so repeated reads (e.g. parseEmail() followed by - // anything touching message.raw) work. Real CF messages are one-shot, but - // the proxy is what user handlers see in tests + the platform router, and - // they reasonably expect raw to be replayable since the envelope is - // already pre-buffered by the router. + // Buffer `raw` once so it can be read repeatedly. let cachedRaw: Uint8Array | undefined; let bufferingPromise: Promise | undefined; function readRawOnce(): Promise { @@ -501,8 +397,6 @@ export function createMessageProxy( return bufferingPromise; } - // If the envelope already gave us bytes, expose them eagerly via the cache - // so the WeakMap lookup in parseEmail short-circuits to a sync resolve. if (envelope.raw instanceof Uint8Array) { cachedRaw = envelope.raw; } @@ -530,8 +424,8 @@ export function createMessageProxy( return true; } - // `actions` only ever grows through forward/reply, and `setReject` purges - // both, so length IS the queue depth. + // `actions` only grows through forward/reply, and `setReject` purges both, + // so its length is the queue depth. function assertQueueHasRoom(): void { if (actions.length + pendingReplies >= MAX_ACTIONS) { throw queueFull(); @@ -542,16 +436,11 @@ export function createMessageProxy( from: envelope.from, to: userVisibleTo, headers, - // `raw` is accessed as a property by user code and postal-mime. Return a - // fresh ReadableStream each access, backed by the cached bytes (or - // buffering on first read for stream envelopes). + // A fresh stream per access, backed by the buffered bytes. get raw(): ReadableStream { if (cachedRaw) { return bytesToReadableStream(cachedRaw); } - // Stream envelope, not yet buffered. Tee-style: kick off buffering - // and return a stream that pulls from the eventual bytes. This keeps - // synchronous getter semantics while ensuring later accesses replay. const pending = readRawOnce(); return new ReadableStream({ async start(controller) { @@ -564,7 +453,7 @@ export function createMessageProxy( rawSize: envelope.rawSize, setReject(reason: string) { if (rejected) { - return; // already rejected; ignore second reject + return; } rejected = true; // Reject is terminal and replaces queued deliveries, so it must remain @@ -597,22 +486,13 @@ export function createMessageProxy( return; } assertQueueHasRoom(); - if (to.length > MAX_ACTION_ADDRESS_LENGTH) { - throw addressTooLong('to', to.length); - } - // Validate the bare address before recording any delivery intent. validateAddressParts('to', to); - // Accept both `Headers` (matches CF's runtime signature) and a plain - // `Record`. `Headers.entries()` lowercases keys per - // spec; users who care about preserving header casing in the wire - // form (e.g. for diagnostic headers like `X-Void-Trace`) can pass - // the record form instead. + // `Headers` lowercases names; a plain record keeps the caller's casing. let headersObj: Record | undefined; if (fwdHeaders instanceof Headers) { headersObj = Object.fromEntries(fwdHeaders.entries()); } else if (fwdHeaders) { - // Validate now to avoid partial delivery during native replay. Keep - // the record itself so caller-provided header casing is preserved. + // Validate now so native replay cannot fail after earlier actions fired. try { void new Headers(fwdHeaders); } catch (e) { @@ -620,9 +500,6 @@ export function createMessageProxy( } headersObj = { ...fwdHeaders }; } - // Size, after validity, on the record that is stored — for both forms. - // The router applies these bounds to that record and drops the whole - // list; here they fail the handler's own call, recording nothing. if (headersObj) { assertForwardHeadersWithinBounds(headersObj); } @@ -649,17 +526,7 @@ export function createMessageProxy( } // Everything cheap first, so a doomed reply never buffers its stream. assertQueueHasRoom(); - if (reply.from.length > MAX_ACTION_ADDRESS_LENGTH) { - throw addressTooLong('from', reply.from.length); - } - if (reply.to.length > MAX_ACTION_ADDRESS_LENGTH) { - throw addressTooLong('to', reply.to.length); - } - // Same rule as `forward(to)` above, for the hand-built reply form: - // `replyEmail()` already goes through `normalizeEmail`, but a caller - // who passes `{ from, to, raw }` bypasses it, and otherwise only - // Cloudflare's native `reply()` would refuse a malformed address — - // after every earlier action had fired. Both are bare addresses. + // A hand-built `{ from, to, raw }` bypasses `normalizeEmail`. validateAddressParts('from', reply.from); validateAddressParts('to', reply.to); pendingReplies++; @@ -677,8 +544,7 @@ export function createMessageProxy( ); return; } - // `replyEmail()` is bounded by `buildMime`; a hand-built reply is not, so - // bound it here before the bytes travel to the proxy (or to native reply). + // A hand-built reply is not bounded by `buildMime`. const metadataOnlyAction = { type: 'reply' as const, from: reply.from, @@ -702,10 +568,8 @@ export function createMessageProxy( } rawBytes = reply.raw; } else { - // A native `EmailMessage` lands here: `from` and `to` only, no `raw`. - // Name the problem instead of failing on `undefined.getReader()`, - // which in platform mode is a generic "project handler error" bounce - // with nothing a handler author could act on. + // A native `EmailMessage` has no `raw`: name that rather than fail + // with a generic handler error. const raw: unknown = reply.raw; if ( typeof raw !== 'object' || @@ -719,10 +583,8 @@ export function createMessageProxy( 'no public accessor. Use replyEmail() from void/email, or pass the MIME bytes as raw.', ); } - // The REMAINING budget, not the per-reply cap — an earlier reply has - // already spent part of it, and the stream must stop where the budget - // does. Whichever bound is lower is the one that fires, so the - // callback names the one that actually bound this read. + // Stop at the lower of the per-reply cap and the remaining budget, + // and name that one. const limit = Math.min(MAX_REPLY_BYTES, remaining); rawBytes = await readStreamToBytes(reply.raw, limit, (total) => limit === MAX_REPLY_BYTES @@ -743,11 +605,8 @@ export function createMessageProxy( }, } as unknown as ForwardableEmailMessage; - // Preserve the original wire-form `+@` recipient for - // platform-side diagnostics without surfacing it to user handlers (most - // handlers should never need this — they read `message.to` and expect the - // clean form they wrote in `email/.ts`). Non-enumerable so JSON - // serialization, structured-clone, and `Object.entries` don't leak it. + // The wire-form recipient, for diagnostics; non-enumerable so it never + // serializes. if (envelope.to !== userVisibleTo) { Object.defineProperty(message, '__voidPlatformTo', { value: envelope.to, @@ -757,13 +616,8 @@ export function createMessageProxy( }); } - // Mark the relay lane: the platform proved the domain's relay secret, so - // `viaDomain` names the one registered domain this message arrived on. - // `buildReplyMessage` reads the marker to default `from` to the address - // that received the message (on the customer domain) instead of the - // shared-sender `+noreply@` default — the platform - // pins a relayed reply to the receiving project's registered domains. - // Non-enumerable for the same reason as `__voidPlatformTo` above. + // Relay-lane marker: `buildReplyMessage` then defaults `from` to the + // receiving address on the customer domain. if (typeof envelope.viaDomain === 'string' && envelope.viaDomain.length > 0) { Object.defineProperty(message, '__voidRelayDomain', { value: envelope.viaDomain, @@ -773,40 +627,22 @@ export function createMessageProxy( }); } - // Register cache entry so parseEmail() can replay bytes without consuming - // the proxy's `raw` getter (which would otherwise hand out a fresh stream - // per access — fine for correctness but wasteful for large messages). proxyRawCache.set(message, { read: readRawOnce }); return { message, getActions: () => [...actions] }; } /** - * Whether `Name: value` can be emitted without breaking RFC 5322 §2.1.1. - * - * Folding only happens at whitespace (§3.2.2), so the widest unbreakable run - * is what decides it — a `References` chain of ordinary ids folds fine however - * long, while one oversized id inside it does not. + * Whether `Name: value` fits RFC 5322's 998-octet line. Folding happens only + * at SP or HTAB, so the widest unbroken run decides. */ function fitsHeaderLine(name: string, value: string | undefined): value is string { if (!value) { return false; } - // A predicate, not a maximum, in one pass that allocates nothing. Spreading - // the token list into `Math.max` throws `RangeError` past ~123k arguments - // (measured on workerd), and `split(/\s+/)` materializes every token before - // `.some()` looks at the first — over 30 bytes of heap each, so 10 MiB of - // two-character ids (3.5M tokens, inside MAX_INBOUND_BYTES) measured 134 MB - // and terminates a 128 MB isolate before the predicate can answer. This - // guard exists to stop a hostile header making the app's reply throw, so it - // must be neither the thing that throws nor the thing that runs out of - // memory. - // - // A run ends only at SP or HTAB. That is the whitespace `foldHeader` folds - // at (§3.2.2 FWS), and a threading value is emitted verbatim, so treating - // the rest of `\s` (NBSP, FF, VT …) as a break would pass a value the - // folder cannot break. Counting through them is the strict direction: it - // can only refuse a value, never pass one that fails. + // One pass without allocation: tokenizing a hostile 10 MiB header could + // exhaust the isolate. Other `\s` characters do not end a run, because + // `foldHeader` cannot fold at them. const budget = 998 - (name.length + 2); let run = 0; for (let i = 0; i < value.length; i++) { @@ -815,8 +651,7 @@ function fitsHeaderLine(name: string, value: string | undefined): value is strin run = 0; continue; } - // UTF-8 octets, as `TextEncoder` would count them: a surrogate pair is one - // 4-octet code point, and a lone surrogate encodes as U+FFFD (3 octets). + // UTF-8 octets: a surrogate pair is 4, a lone surrogate (U+FFFD) is 3. if (c < 0x80) { run += 1; } else if (c < 0x800) { @@ -840,39 +675,11 @@ function fitsHeaderLine(name: string, value: string | undefined): value is strin } /** - * Whether `value` carries a C0 control other than HTAB, or DEL. The - * sibling of `fitsHeaderLine` for the other way a remote-controlled id can - * be unrepresentable: a threading value is emitted verbatim, so a control - * inside it would reach the reply's `In-Reply-To` / `References` line raw — - * `\x01` and DEL pass a WHATWG `Headers`, which refuses only CR, LF and - * NUL — and an RFC 5322 §3.6.4 `msg-id` is `dot-atom-text`, which admits no - * control at all. HTAB is not one here: it is the whitespace that separates - * ids (§3.2.2 FWS), the same break `fitsHeaderLine` and `replyReferences` - * cut at. One pass, no allocation, so it costs nothing a hostile header can - * multiply; never a throw, for the same reason as the line guard. - */ -function hasControl(value: string): boolean { - for (let i = 0; i < value.length; i++) { - const c = value.charCodeAt(i); - if ((c < 0x20 && c !== 0x09) || c === 0x7f) { - return true; - } - } - return false; -} - -/** - * `chain` without any id that `hasControl` refuses, the rest in their order - * and joined by single spaces; the chain itself when there is nothing to - * drop, without allocating. Only ONE id is lost — the others still thread — - * whereas an id no line can carry drops the whole chain for the parent's id - * (`replyReferences`), because that guard is about the rendered line and - * this one is about one token. Called only on a chain already cut to - * `MAX_REPLY_REFERENCES_CHARS`, so the per-id slices in the rare bad case - * are bounded by that budget, not by the header a remote sender delivered. + * `chain` without the ids that hold a control character, joined by single + * spaces. Called only on a chain already cut to `MAX_REPLY_REFERENCES_CHARS`. */ function dropControlIds(chain: string): string { - if (!hasControl(chain)) { + if (indexOfControl(chain) === -1) { return chain; } const kept: Array = []; @@ -941,55 +748,18 @@ export interface ReplyEmailOptions { } /** - * Longest decoded inbound `Subject` the default reply subject is built from; - * anything longer falls back to a bare `Re:`. - * - * RFC 5322 §2.1.1 caps a header LINE at 998 octets, and only folding lets a - * header run past it — so a subject is something that fits on a line or - * two. Real ones are tens to hundreds of characters; the longest automated - * ones (a ticket title under a stack of list tags) reach a few hundred, and - * Exchange rejects past 255. Four thousand keeps every one of those with an - * order of magnitude to spare and refuses the shape that is only a subject - * in name. What the bound buys is proportional cost: the control-character - * replacements below cost 66 bytes of heap per match, so at this size they - * cost at most 135 KB, and the reply's own `Subject` line is at most ~20 KB - * — 4096 code units x 3 UTF-8 octets, base64'd in 45-octet encoded-words - * with their framing — against a `MAX_MESSAGE_BYTES` of 5 MiB, so the - * subject alone can never push the reply over the limit. + * Longest decoded inbound subject the default reply subject is built from; + * longer falls back to a bare `Re:`. Real subjects are far shorter, and the + * bound keeps the control-character replacement cheap. */ const MAX_REPLY_SUBJECT_CHARS = 4096; function prefixReplySubject(original: string): string { - // Decode RFC 2047 encoded-words first so we (a) detect existing - // `Re:` prefixes inside encoded subjects (avoids `Re: =?UTF-8?B?...?=` - // becoming `Re: Re: foo` after the recipient's mailer decodes), and - // (b) re-encode cleanly via buildMime's encodeWord on the prefixed - // string instead of stacking encoded-words. - // - // The inbound subject is remote-sender controlled, and two things - // downstream throw on it: `normalizeEmail` rejects CR, LF and NUL, and - // `buildMime` rejects a header line over 998 octets or a message over - // `MAX_MESSAGE_BYTES`. A throw here is not the app's fault, and in - // production it rejects the whole delivery — the reply AND any forward - // the handler queued. So, as with the threading headers below, the - // subject is best-effort: C0 controls (a decoded `=00`, `=0A`, or a - // B-encoded NUL) become a single space, and three shapes fall back to a - // bare `Re:` — an empty subject, one over `MAX_REPLY_SUBJECT_CHARS`, and - // an ASCII one no line can carry. - // - // The bound is checked BEFORE the replacements, on the decoded string. - // A global `replace` materializes one part per match before it builds the - // result — about 66 bytes each — and the inbound header can be the whole - // 10 MiB message: 5 MiB of alternating tab and letter measured 165 MB on - // the first replacement alone and terminated a 128 MB isolate, which is - // the tenant's. It is the DECODED length that is bounded, not the raw - // header's: an encoded-word is always at least as long as what it decodes - // to (base64 is 4/3 of its bytes plus `=?UTF-8?B?…?=` per 45 of them, and - // a Q-encoded 3-octet character is nine characters), so a raw bound would - // refuse a CJK subject of under a thousand characters whose decoded form - // fits. The decode is one linear pass that holds only its output, already - // made to survive the 10 MiB case (see `quotedPrintableDecode` below) — - // accuracy for a pass that is paid either way. + // Decode first so an encoded `Re:` is detected and the result is encoded + // once. The subject is remote-controlled and a throw here would reject the + // whole delivery, so it is best-effort: controls become a space, and an + // empty, over-long or unfoldable ASCII subject becomes a bare `Re:`. The + // decoded length is checked before any replace. const decoded = decodeRfc2047(original); if (decoded.length > MAX_REPLY_SUBJECT_CHARS) { return 'Re:'; @@ -1003,9 +773,7 @@ function prefixReplySubject(original: string): string { return 'Re:'; } const prefixed = /^re:/i.test(cleaned) ? cleaned : `Re: ${cleaned}`; - // Only a pure-ASCII subject can fail to fold: anything else goes through - // `encodeWord`, which splits it into 75-character encoded-words joined - // by spaces, and those fold. An ASCII token wider than a line cannot. + // Non-ASCII goes through `encodeWord`, whose words always fold. if (/^[\x20-\x7e]*$/.test(prefixed) && !fitsHeaderLine('Subject', prefixed)) { return 'Re:'; } @@ -1013,46 +781,23 @@ function prefixReplySubject(original: string): string { } /** - * Longest `References` value a reply carries, in UTF-16 code units — the - * measure `MAX_REPLY_SUBJECT_CHARS` uses. It is checked on `length` alone, - * so a chain the size of the inbound message costs one property read before - * anything is scanned or sliced. - * - * RFC 5322 puts no limit on the field, but everything downstream does. RFC - * 5537 §3.4.4 has a followup agent trim it to 998 characters, and Postfix's - * `header_size_limit` discards header bytes past 100 KiB. A message-id is - * tens of characters, so 8 KiB carries a chain over a hundred replies deep - * with every id intact — an order of magnitude past any conversation — and - * a value this size, folded and encoded, is at most ~25 KB of a 5 MiB - * reply, so the chain alone can never push the reply over the limit. - * Unbounded, it could: the inbound header can be the whole 10 MiB message, - * and short ids pass the line guard — every id folds — yet `foldHeader`'s - * continuations would put the rendered reply over `MAX_MESSAGE_BYTES`. - * - * Past the bound the NEWEST ids are kept, whole, and the parent's id still - * ends the chain. That is what a mail client threads on: it links the reply - * under any id it recognises, and the parent is also `In-Reply-To`. The - * root id RFC 5537 keeps is a Netnews convention (a newsreader identifies a - * thread by it); in mail it would spend the budget on an id older than - * every client in the conversation. + * Longest `References` value a reply carries, in UTF-16 code units. 8 KiB + * keeps any real thread whole; past it the newest ids are kept, since mail + * clients thread on any id they recognise and the parent is `In-Reply-To`. */ const MAX_REPLY_REFERENCES_CHARS = 8192; /** - * The `References` value a reply carries, or `''` when nothing can be - * threaded. In order: the whole inbound chain continued by the parent's - * `messageId` when it fits `MAX_REPLY_REFERENCES_CHARS`; otherwise the - * newest ids of the chain that fit, none of them split; and the parent's id - * alone (which may be empty) when even those hold an id no line can carry. + * The reply's `References`, or `''`: the inbound chain plus the parent's id, + * trimmed to its newest whole ids, or the parent's id alone when an id + * cannot fit a line. */ function replyReferences(originalReferences: string, messageId: string): string { const parent = messageId ? ` ${messageId}` : ''; let chain = originalReferences; const budget = MAX_REPLY_REFERENCES_CHARS - parent.length; if (chain.length > budget) { - // Keep the tail, from the first SP or HTAB at or after the cut so no id - // is split — the breaks `foldHeader` folds at, as in `fitsHeaderLine`. - // The scan and the slice are bounded by the budget, not by the header. + // Cut at the next SP or HTAB so no id is split. let cut = chain.length - budget; while (cut < chain.length) { const c = chain.charCodeAt(cut); @@ -1063,49 +808,31 @@ function replyReferences(originalReferences: string, messageId: string): string } chain = chain.slice(cut); } - // After the cut, so the only chain this ever walks is one the budget - // already bounds. `parent` was checked by the caller and is never dropped. + // After the cut, so the walk is bounded; `parent` was already checked. chain = dropControlIds(chain); chain = `${chain}${parent}`.trim(); - // A chain folds at its spaces, so one this long is fine; it is a single - // oversized id inside it that is not. return fitsHeaderLine('References', chain) ? chain : messageId; } /** - * Minimal RFC 2047 encoded-word decoder. Handles `=?charset?B?base64?=` - * and `=?charset?Q?quoted-printable?=` segments separated by optional - * whitespace. Unknown charsets pass through (best-effort UTF-8 decode); - * malformed inputs return the original. - * - * Scope is intentionally narrow — used only for cosmetic subject - * normalization in `prefixReplySubject`. Anything that needs robust - * decoding (e.g. searching subject text) should use postal-mime's - * parsed view via `parseEmail`. + * Minimal RFC 2047 decoder for the reply subject: B and Q encoded-words, + * unknown charsets read as UTF-8, malformed input returned unchanged. */ function decodeRfc2047(value: string): string { if (!value.includes('=?')) { return value; } try { - // One encoded-word at a time. `replace` with a callback collects every - // match — result array, captures and all — before the callback runs - // once, so a 10 MiB subject of tiny encoded-words (750k of them, inside - // MAX_INBOUND_BYTES) needed 138 MB on the reply path and terminated a - // 128 MB isolate. `matchAll` is lazy: the same path needs 35 MB, and what - // is kept is the decoded output. + // `matchAll`, not `replace` with a callback: replace collects every match + // first, which a subject of tiny encoded-words turns into a heap blowup. const parts: Array = []; let last = 0; for (const m of value.matchAll(/=\?([\w-]+)\?([BbQq])\?([^?]*)\?=(?:\s+(?==\?))?/g)) { const [match, charset, enc, payload] = m; const bytes = enc.toUpperCase() === 'B' ? base64Decode(payload) : quotedPrintableDecode(payload); - // Honor the charset declared in the encoded-word. Q-encoded - // ISO-8859-1 / Windows-1252 subjects are still common in mail - // from older systems; decoding them as UTF-8 mojibakes any - // byte ≥ 0x80. TextDecoder rejects unknown labels by throwing, - // so fall back to UTF-8 only when the requested charset isn't - // supported (better than leaving the raw encoded-word in place). + // Honor the declared charset; fall back to UTF-8 for a label + // TextDecoder rejects. let decoder: TextDecoder; try { decoder = new TextDecoder(charset || 'utf-8', { fatal: false }); @@ -1122,24 +849,9 @@ function decodeRfc2047(value: string): string { } } -function base64Decode(b64: string): Uint8Array { - const binary = atob(b64); - const out = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - out[i] = binary.charCodeAt(i); - } - return out; -} - function quotedPrintableDecode(qp: string): Uint8Array { - // RFC 2047 Q-encoding: `_` = space, `=XX` = hex byte, otherwise literal. - // - // Decode straight into a typed array. The payload regex in `decodeRfc2047` - // accepts whitespace, so an unfolded 10 MiB `Subject` is ONE encoded-word - // and this loop sees every character of it. An `Array` would hold - // one boxed element per character (about 200 MB for 10 MiB of Q text). Every - // input character yields at most one output byte, so `qp.length` is the - // exact upper bound and the decode holds nothing but the output. + // RFC 2047 Q-encoding: `_` is a space, `=XX` a hex byte. Each input + // character yields at most one byte, so a typed array of `qp.length` fits. const out = new Uint8Array(qp.length); let kept = 0; for (let i = 0; i < qp.length; i++) { @@ -1147,9 +859,8 @@ function quotedPrintableDecode(qp: string): Uint8Array { if (ch === 0x5f /* _ */) { out[kept++] = 0x20; } else if (ch === 0x3d /* = */ && i + 2 < qp.length) { - // Both characters must be hex: `parseInt('0G', 16)` is 0, not NaN. A - // malformed escape stays literal, which is what RFC 2045 §6.7 asks of a - // robust decoder and what postal-mime does. + // Both must be hex (`parseInt('0G', 16)` is 0); a malformed escape + // stays literal. const hex = qp.slice(i + 1, i + 3); if (/^[0-9A-Fa-f]{2}$/.test(hex)) { out[kept++] = parseInt(hex, 16); @@ -1165,13 +876,8 @@ function quotedPrintableDecode(qp: string): Uint8Array { } /** - * Pure helper: build the reply MIME from an inbound message + reply opts, - * with `Subject: Re: …` (no double-prefix), `In-Reply-To`, and `References` - * threaded from the original. Threading is best-effort (the inbound values - * are remote-sender controlled): an id no header line can carry is dropped, - * and a `References` chain over `MAX_REPLY_REFERENCES_CHARS` is trimmed to - * its newest ids. Exposed separately so the dispatch step (`message.reply`) - * can be tested without `cloudflare:email`. + * Build the reply MIME for `replyEmail`: `Re:` subject and best-effort + * `In-Reply-To` / `References` threading from the inbound message. */ export async function buildReplyMessage( message: ForwardableEmailMessage, @@ -1182,36 +888,18 @@ export async function buildReplyMessage( const originalMessageId = headers.get('Message-ID') ?? ''; const originalReferences = headers.get('References') ?? ''; - // Default `from` for the reply. On the platform (a slug is injected), - // build `+noreply@` from the slug and mail domain. Falling - // back to `message.to` there would reply from the user-visible - // (slug-stripped) address, which the platform's slug-pin rejects — every - // outbound on the shared sender must carry `+...@`. If the - // domain binding is missing and the caller didn't provide an explicit - // `from`, surface a clear error rather than building an invalid - // envelope. There is deliberately no baked-in domain default: guessing - // the production zone is exactly the bug the binding exists to prevent. - // - // With no slug there is no shared sender to pin to: the message was - // delivered to a real address on the developer's own zone, and replying - // from that address is what a reply means. - // - // A relayed message (a custom domain on the customer's own Cloudflare - // zone — the `__voidRelayDomain` marker from `createMessageProxy`) is the - // same shape as the own-account case even though a slug IS injected: - // `message.to` is a real address on the customer's domain, the platform - // pins relayed replies to the receiving project's registered domains, and - // the send leaves through the customer account's REST send. The shared - // `+noreply@` default would fail that pin. + // Default `from`: the receiving address on your own zone or a relayed + // custom domain; `+noreply@` on the platform's shared sender, + // which pins replies to the slug. A domain is never guessed. let fromAddr: Address; if (opts.from !== undefined) { fromAddr = opts.from; } else if (readRelayDomain(message) !== undefined) { fromAddr = message.to; } else { - const slug = tryReadProjectSlug(); + const slug = readProjectSlug(); if (slug) { - const domain = tryReadEmailDomain(); + const domain = readEnvString('__VOID_EMAIL_DOMAIN'); if (!domain) { throw new Error( "replyEmail: 'from' is required when __VOID_PROJECT_SLUG is set " + @@ -1221,13 +909,9 @@ export async function buildReplyMessage( ); } fromAddr = `${slug}+noreply@${domain}`; - } else if (readRawProjectSlug() !== undefined) { - // A slug IS injected, so this is a platform deployment — it just - // cannot be a sender, because `+noreply@` must stay inside RFC - // 5321's 64-octet local part, which caps the slug at 56 characters. - // Falling through to `message.to` would build the slug-stripped - // `@` shape that the platform's reply pin drops, with no - // error reaching the handler. + } else if (readEnvString('__VOID_PROJECT_SLUG') !== undefined) { + // A platform slug too long for `+noreply@`: `message.to` would + // build a sender the platform drops without telling the handler. throw new Error( "replyEmail: 'from' is required — this project's slug cannot build a " + 'default reply sender (`+noreply@` must stay within the ' + @@ -1241,24 +925,12 @@ export async function buildReplyMessage( const toAddr: Address = opts.to ?? message.from; const subject = opts.subject ?? prefixReplySubject(originalSubject); - // User-supplied `headers` go through the normal (reserved-header - // filtered) channel; threading headers ride the dedicated `threading` - // field that buildMime emits unconditionally. This keeps the - // RESERVED_HEADERS guard against `In-Reply-To`/`References` spoofing - // from user input intact while letting the trusted reply path emit - // them. - // - // Both values come off the INBOUND message, so a remote sender controls - // them. `buildMime` rejects any header line over RFC 5322's 998-octet - // limit, and a message-id has no whitespace to fold at — so threading a - // hostile one verbatim would let anyone make this app's reply throw, with - // the app at fault for nothing. Threading is best-effort: drop a value - // that cannot be represented and still send the reply. A control - // character is the other way an id is unrepresentable (`hasControl`), and - // it is dropped the same way rather than refused by `normalizeEmail`. + // Threading rides `threading`, since `headers` reserve these names. Both + // ids are remote-controlled: drop one that cannot fit a line or holds a + // control character rather than fail the reply. const threading: { inReplyTo?: string; references?: string } = {}; const messageId = - fitsHeaderLine('In-Reply-To', originalMessageId) && !hasControl(originalMessageId) + fitsHeaderLine('In-Reply-To', originalMessageId) && indexOfControl(originalMessageId) === -1 ? originalMessageId : ''; if (messageId) { @@ -1327,72 +999,41 @@ export function isForwardableEmailMessage(value: unknown): value is ForwardableE ); } -const INBOUND_PROVIDER_DEADLINE_MS = 30_000; -const INBOUND_ACTION_BATCH_DEADLINE_MS = 60_000; - -function createInboundProviderDeadline() { - const deadline = Date.now() + INBOUND_ACTION_BATCH_DEADLINE_MS; - return { - async run(submit: () => Promise): Promise { - const remaining = Math.min(INBOUND_PROVIDER_DEADLINE_MS, deadline - Date.now()); - if (remaining <= 0) { - throw new Error('email action batch deadline elapsed before provider submission'); - } - const work = submit(); - let timer: ReturnType | undefined; - try { - return await Promise.race([ - work, - new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - 'email action provider timed out; outcome unknown, do not retry automatically', - ), - ), - remaining, - ); - }), - ]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } - }, - }; -} - /** - * Replay recorded `EmailAction`s on a real `ForwardableEmailMessage`, in - * order. The proxy already made `setReject` terminal (earlier forward/reply - * entries are purged, later ones dropped), so a reject is never followed by - * anything. `reply` needs a genuine `EmailMessage` — Cloudflare rejects a - * duck-typed object — so `cloudflare:email` is loaded here, and only when - * a reply was recorded. - * - * The proxy already validated forward headers and normalized reject reasons. - * Provider refusals still propagate to the native event caller. + * Replay recorded actions on a native message, in order. The proxy already + * made `setReject` terminal and validated the rest. `reply` needs a real + * `EmailMessage`, so `cloudflare:email` loads only when one was recorded. */ export async function enactEmailActions( message: ForwardableEmailMessage, actions: ReadonlyArray, ): Promise { - const providerDeadline = createInboundProviderDeadline(); + const deadline = Date.now() + INBOUND_ACTION_BATCH_DEADLINE_MS; + const submit = async (run: () => Promise): Promise => { + const remaining = Math.min(INBOUND_PROVIDER_DEADLINE_MS, deadline - Date.now()); + if (remaining <= 0) { + throw new Error('email action batch deadline elapsed before provider submission'); + } + return withTimeout( + remaining, + run, + () => + new Error('email action provider timed out; outcome unknown, do not retry automatically'), + ); + }; for (const action of actions) { switch (action.type) { case 'reject': message.setReject(action.reason); break; case 'forward': - await providerDeadline.run(() => + await submit(() => message.forward(action.to, action.headers ? new Headers(action.headers) : undefined), ); break; case 'reply': { const { EmailMessage } = await import('cloudflare:email'); - await providerDeadline.run(() => + await submit(() => message.reply( new EmailMessage(action.from, action.to, bytesToReadableStream(action.raw)), ), diff --git a/packages/void/src/runtime/email/mime.ts b/packages/void/src/runtime/email/mime.ts index e5ab32097..9d70978b8 100644 --- a/packages/void/src/runtime/email/mime.ts +++ b/packages/void/src/runtime/email/mime.ts @@ -13,37 +13,16 @@ import type { Address, Attachment, NormalizedAttachment, NormalizedEmail } from export const MAX_MESSAGE_BYTES: number = 5 * 1024 * 1024; /** - * Upper bound on the TOTAL attachment bytes `normalizeEmail` will materialize. - * - * Base64 expands by `ceil(n / 3) * 4`, so an attachment set larger than three - * quarters of `MAX_MESSAGE_BYTES` cannot fit in a message that passes the - * check at the end of `buildMime` no matter how small everything else is. - * `buildMime` remains the authority on the final size — this bound only stops - * `normalizeEmail` from materializing, and then expanding, bytes already known - * not to fit. - * - * It has to be charged here because that final check comes far too late to - * help. Measured peak live memory is about 10x the attachment bytes (8 MiB in - * gives 83 MiB live) once the normalized copy, the base64 binary string, the - * 76-column slices, the joins, `rawText` and its `TextEncoder.encode` are all - * resident at once. Against a 128 MB isolate ceiling, a set over roughly - * 12 MiB terminates the isolate rather than returning the `MIME_ERROR` the - * docs promise for an oversize payload. - * - * This is a developer-experience guardrail, NOT the security boundary. - * Attachments come from the app's own code, never from a remote sender, and - * the platform proxy already caps the base64 length before decoding and the - * raw length after. What this buys is a precise, catchable failure at the call - * that went wrong, instead of a dead isolate with nothing a caller could act - * on. + * Total attachment bytes `normalizeEmail` will materialize: base64 of more + * than 3/4 of `MAX_MESSAGE_BYTES` cannot fit. Charged before each copy so an + * oversize set fails with `MIME_ERROR` instead of exhausting the isolate; + * `buildMime`'s final size check stays authoritative. */ const MAX_ATTACHMENT_BYTES_TOTAL: number = Math.floor((MAX_MESSAGE_BYTES * 3) / 4); /** - * Bound raw `to`, `cc`, and `bcc` entries before parsing or rendering headers. - * Counting before deduplication also bounds work for duplicate-heavy input. - * The same SDK limit applies in development and on both deployment targets; - * platform admission independently caps the unique recipients at 50. + * Cap on raw `to` + `cc` + `bcc` entries, counted before parsing or + * deduplication so it also bounds the work. */ export const MAX_RECIPIENTS: number = 50; @@ -51,15 +30,8 @@ export const MAX_RECIPIENTS: number = 50; const MAX_CUSTOM_HEADER_BYTES: number = 16 * 1024; /** - * Pre-parse cap on one parsed address, in characters. RFC 5321 §4.5.3.1 - * allows a 64-octet local part and a 255-octet domain, so nothing over 320 - * characters can be delivered; the platform proxy applies the same - * `MAX_RECIPIENT_LENGTH` to the same parsed address. This is the bound on - * the WORK — it is checked before the address is scanned — not the - * deliverability bound, which `email-validator` applies at 254 (see - * `validateAddressParts`). A display name is not bounded — it is folded or - * encoded into legal header lines at any length, and the tokenizers below - * keep that cost linear. + * Pre-parse cap on one address. Nothing over 320 characters (RFC 5321 + * §4.5.3.1) is deliverable; checked before the address is scanned. */ export const MAX_ADDRESS_LENGTH: number = 320; @@ -77,38 +49,30 @@ function rejectCRLF(field: string, value: string): void { } /** - * Refuse any other C0 control or DEL in a value that is rendered VERBATIM - * into a header — an attachment's filename, media type and Content-ID. - * `rejectCRLF` is enough for a value `encodeWord` renders (the subject, a - * custom header value, a display name): anything outside printable ASCII - * sends the whole value into base64, so a control never reaches the wire. - * These three go through `quoteParam` or straight into the line, so a - * control would reach the wire for the receiving MTA to refuse. - * - * RFC 5322 §3.2.4 `qtext` is printable US-ASCII less `"` and `\`; a - * control inside a quoted-string is `obs-qtext`, which §4 says MUST NOT be - * generated. HTAB is deliberately NOT refused: `quoted-string` is - * `DQUOTE *([FWS] qcontent) [FWS] DQUOTE` and `FWS` is built from `WSP` - * (SP / HTAB), so a tab between characters is folding white space exactly - * as a space is — and `obs-NO-WS-CTL`, the RFC's own list of the controls - * that used to be `qtext`, leaves %d9 out. The same set is illegal in an - * RFC 2045 §5.1 `token` (no CTLs) and an RFC 5322 §3.6.4 `msg-id` - * (`dot-atom-text`), so one rule serves the media type and the Content-ID. - * - * The message names the code point and never echoes the value, as - * `validateAddressParts` does. Runs AFTER the filename's length bound, so - * the scan is over a value already known to fit a line. + * Index of the first C0 control other than HTAB, or DEL; -1 when there is + * none. HTAB is folding whitespace (RFC 5322 §3.2.2). */ -function rejectControls(field: string, value: string): void { +export function indexOfControl(value: string): number { for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); if ((c < 0x20 && c !== 0x09) || c === 0x7f) { - throw new MimeError( - 'MIME_ERROR', - `${field} contains a control character (U+${c.toString(16).toUpperCase().padStart(4, '0')}).`, - ); + return i; } } + return -1; +} + +/** + * Refuse a control in a value rendered verbatim into a header (an + * attachment's filename, media type and Content-ID). Names the code point, + * never the value. + */ +function rejectControls(field: string, value: string): void { + const i = indexOfControl(value); + if (i !== -1) { + const code = value.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0'); + throw new MimeError('MIME_ERROR', `${field} contains a control character (U+${code}).`); + } } /** RFC 2045 §5.1 `tspecials`: what a `token` may not contain besides SPACE and CTLs. */ @@ -120,33 +84,10 @@ function isTokenChar(c: number): boolean { } /** - * Refuse an attachment media type that does not parse as RFC 2045 §5.1 - * `type "/" subtype *(";" parameter)`. - * - * `rejectControls` answers only for CTLs, and `buildAttachmentPart` writes - * the value verbatim ahead of its own `; name="…"`, so an unterminated - * quote such as `text/plain; x="` would swallow the builder's `name` - * parameter as the value of `x`. - * - * The grammar is the RFC's, read as a generator: a `token` is printable - * US-ASCII less SPACE and `tspecials`; a `value` is a `token` or a - * quoted-string, whose content is anything but a bare `"` or `\` (the same - * rule `quoteParam` renders `name="…"` by, so a caller's quoted value may - * carry what the builder's own already does). Optional WSP is admitted on - * either side of `;` — the RFC 822 implicit LWSP every parser accepts and - * the form HTTP's `Content-Type` writes — and nowhere else: `text/ plain`, - * a trailing `;`, and an RFC 822 comment are refused rather than repaired. - * Two parameters are also refused on their meaning: a `name`, because the - * builder appends `name=""` and RFC 6838 §4.3 makes it "an error - * for a specific parameter to be specified more than once"; and by that - * same sentence any attribute given twice, compared case-insensitively as - * §5.1 says attributes match. Type and subtype are kept as given — matching - * is case-insensitive, so `TEXT/PLAIN` is legal and renders unchanged. - * - * One pass, no backtracking, and the only allocation is one lower-cased - * attribute per parameter for the duplicate check, which is why this runs - * AFTER the value's length bound. The message names the rule and never - * echoes the value. + * Refuse a media type that is not RFC 2045 §5.1 `type "/" subtype *(";" + * parameter)`: it is written verbatim before the builder's `name=`, so an + * unterminated quote would swallow it. WSP is allowed only around `;`. A + * `name` parameter or a repeated attribute is refused (RFC 6838 §4.3). */ function validateMediaType(field: string, value: string): void { const syntax = () => @@ -240,34 +181,9 @@ function isAtext(c: number): boolean { } /** - * Refuse an attachment Content-ID that would not render as one RFC 5322 - * §3.6.4 `msg-id`. - * - * `rejectControls` answers only for CTLs, and `buildAttachmentPart` writes - * the value verbatim — adding `<` and `>` unless the value already starts - * with `<` — so a malformed id would reach the receiver and break the - * `cid:` reference. - * - * The grammar is the RFC's, read as a generator, with two deliberate - * differences. An `msg-id` is `"<" id-left "@" id-right ">"`, each side a - * `dot-atom-text` (§3.2.3: `1*atext *("." 1*atext)`, so no leading, - * trailing or doubled dot — the strict form; `obs-id-left`/`obs-id-right` - * are §4 obsolete syntax, which MUST NOT be generated); `no-fold-literal` - * (`[…]` for `id-right`) is left out. First difference: the `@` and its - * right side are OPTIONAL. RFC 2392 §2 resolves a `cid:` URL by wrapping - * its body in `<` `>` and matching that against `Content-ID`, so an id - * without `@` — the documented `contentId: 'logo'` + `cid:logo` — is what - * every client already resolves, and the domain that would make it a - * proper `msg-id` adds nothing a reference could use. Second: the angle - * brackets are optional too, because the builder has always added them - * around a bare id; given, they must be one pair around the whole value. - * - * Refused, then: whitespace of any kind, a second `@`, `<` or `>` anywhere - * but as that one pair, `"`, `(`, `)`, `,`, `;`, `:`, `\`, `[`, `]`, - * anything non-ASCII, and the empty string. - * - * One pass, no allocation, which is why this runs AFTER the value's length - * bound. The message names the rule and never echoes the value. + * Refuse a Content-ID that would not render as an RFC 5322 §3.6.4 `msg-id` + * of strict `dot-atom-text`. Unlike `msg-id`, the `@` part and the angle + * brackets are optional: `cid:logo` resolves against `` (RFC 2392). */ function validateContentId(field: string, value: string): void { const syntax = () => @@ -364,37 +280,10 @@ function fieldErrorCode(field: string): MimeErrorCode { } /** - * Validate one parsed address (and its display name, when there is one), - * throwing a `MimeError` for `field` on anything undeliverable. Exported so - * the inbound proxy's `forward(to)` and hand-built `reply()` refuse a bare - * address by the same rule `sendEmail` applies to its `to`. - * - * The address rule is `email-validator`, and nothing else. It is also the - * platform router's rule (`isValidEmailAddress` in the proxy's - * `email-router.ts`), so there is exactly ONE address syntax in the tree: - * an action the framework records is never dropped by the router for its - * shape, and a test harness never captures as a success what the managed - * deployment would lose. - * - * What it enforces: at most 254 characters in total; an ASCII dot-atom - * local part of at most 64 characters (RFC 5322 §3.2.3 `atext`, dots only - * between atoms — no quoted local part, no whitespace, no control, no - * special); a dotted domain of ASCII labels of at most 63 characters each, - * with a TLD that starts with a letter and has at least two characters; so - * `user@localhost`, `x@y` and a raw IDN or SMTPUTF8 address are refused - * (an IDN domain is accepted as punycode, `xn--…`). ASCII-only makes - * octets and characters the same thing, so the RFC 5321 §4.5.3.1 octet - * limits are the package's length checks; the 64 is also what caps the - * default `+noreply@` sender (`VALID_SLUG` in `runtime/email.ts`, - * `MAX_SLUG_LENGTH` = `64 - 8` in `cli/project-slug.ts` and the API). One - * known quirk, pinned in the tests rather than worked around: the FIRST - * character of the local part may not be a backtick, while any later one - * may. - * - * `MAX_ADDRESS_LENGTH` still runs first, on the raw string, so the scan is - * bounded before anything reads the address; the package's own 254 check - * runs before its regex for the same reason. The message never echoes the - * address: it may hold a control character, and naming the rule is enough. + * Validate one parsed address and optional display name, throwing a + * `MimeError` for `field`. The address rule is `email-validator`, the one + * the platform router applies, so a recorded action is never dropped for its + * shape. The message never echoes the address. */ export function validateAddressParts(field: string, email: string, name?: string): void { if (email.length > MAX_ADDRESS_LENGTH) { @@ -412,14 +301,8 @@ export function validateAddressParts(field: string, email: string, name?: string } if (name !== undefined) { rejectCRLF(`${field} display name`, name); - // An ASCII name with specials is rendered as one quoted-string, which - // `foldHeader` never splits: ` "name"` is a line of at least 3 octets - // plus the name. Same reason as the filename guard in - // `normalizeAttachment` — the escaping - // in `formatAddress` costs heap per escaped character, and the line - // check that would refuse it runs only after everything is rendered. - // A non-ASCII name becomes encoded-words and folds; a plain name folds - // at its spaces; neither is bounded here. + // A quoted ASCII name never folds: refuse one too long for its line + // before `formatAddress` escapes it. if ( ASCII_PRINTABLE.test(name) && RFC5322_SPECIALS.test(name) && @@ -431,10 +314,7 @@ export function validateAddressParts(field: string, email: string, name?: string `so its line would exceed the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten it.`, ); } - // `@` in a display name almost always means a typo like - // `a@x.dev` got mis-parsed as `name=a@x.dev, - // email=b@evil.dev`. Reject so the caller sees a clear validation - // error rather than silently rerouting to a different address. + // An `@` in a name usually means a mis-parsed `a@x.dev`. if (name.includes('@')) { throw new MimeError( fieldErrorCode(field), @@ -445,25 +325,9 @@ export function validateAddressParts(field: string, email: string, name?: string } /** - * Undo the quoted-pairs in the content of a quoted display name: `\x` → - * `x` for any `x` (RFC 5322 §3.2.4 — `qtext` excludes `"` and `\`, so a - * quoted-string carries either only as a quoted-pair). The parsed name is - * the RAW text `formatAddress` will quote and escape again, so leaving the - * pairs in place would escape them twice. Only the quoted branch - * of `parseAddress` calls this; a backslash in an unquoted name is a - * character like any other, as it is in the object form. - * - * NOT `text.replace(/\\(.)/g, '$1')`: a global-regex replacement holds one - * part per match before it builds the result, about 60 bytes each, and - * nothing bounds this name before it runs — the quoted-string line guard - * in `validateAddressParts` needs the DECODED name, and a non-ASCII name is - * never bounded at all. Measured, 10 MiB of `\"` pairs peaked at 307 MB of - * heap that way and terminated a 128 MB isolate; this shape peaks at 49 MB. - * Same discipline as `to7bitBody`: one counting pass, a buffer of exactly - * the output size, one fill, and one chunked `codeUnitsToString`. - * The common case — no backslash at all — is one scan and no allocation. - * The regex that admits the text guarantees every backslash is followed by - * a character, so the fill never reads past the end. + * Undo quoted-pairs (`\x` → `x`) in a quoted display name so `formatAddress` + * escapes it once. A counting pass and an exact buffer, not a global-regex + * replace, which allocates per match on an unbounded name. */ function unquotePairs(text: string): string { const first = text.indexOf('\\'); @@ -477,8 +341,7 @@ function unquotePairs(text: string): string { i++; } } - // UTF-16 code units, not bytes: a quoted name may be non-ASCII, and a - // surrogate pair is two units that round-trip exactly. + // UTF-16 code units: a surrogate pair round-trips exactly. const out = new Uint16Array(text.length - pairs); let n = 0; for (let i = 0; i < text.length; i++) { @@ -500,22 +363,14 @@ function unquotePairs(text: string): string { export function parseAddress(value: Address, field: string): { email: string; name?: string } { if (typeof value === 'string') { rejectCRLF(`${field} address`, value); - // Match `Display Name ` with paired quotes around the - // name (both or neither). - // - // The unquoted name must start and end on a non-whitespace character so - // `[^"<\s]` cannot take a space from the surrounding `\s*`. Letting - // consecutive quantifiers compete for one whitespace run makes the - // backtracker cubic in its length (4,000 leading spaces cost 10 s). + // `Display Name `, quotes paired or absent. The unquoted + // name starts and ends on non-whitespace so adjacent quantifiers cannot + // compete for one whitespace run, which backtracks cubically. const quoted = value.match(/^\s*"((?:[^"\\]|\\.)*)"\s*<\s*([^>\s]+)\s*>\s*$/); const angle = quoted ?? value.match(/^\s*(?:([^"<\s](?:[^"<]*[^"<\s])?)\s*)?<\s*([^>\s]+)\s*>\s*$/); if (angle) { - // The quoted form's `\"` and `\\` are quoted-pairs, undone here so - // the parsed name is the raw text — the same thing the object form - // carries — and `formatAddress` escapes it exactly once. Decoded - // BEFORE `validateAddressParts`, whose quoted-string line guard has - // to see the name `formatAddress` will render. + // Undo quoted-pairs first: the name guard must see what renders. const name = (quoted ? unquotePairs(angle[1] ?? '') : angle[1])?.trim(); const email = angle[2]; validateAddressParts(field, email, name); @@ -545,11 +400,7 @@ function countRecipients(value: Address | Array
| undefined): number { return Array.isArray(value) ? value.length : 1; } -/** - * Keeps the `exceeds Cloudflare limit of bytes` tail that `buildMime`'s - * own size error already uses, so the documented shape — and any caller - * matching on it — holds for both paths. - */ +/** Same `exceeds Cloudflare limit of bytes` tail as `buildMime`'s size error. */ function attachmentsTooLargeError(bytes: number): MimeError { return new MimeError( 'MIME_ERROR', @@ -558,13 +409,9 @@ function attachmentsTooLargeError(bytes: number): MimeError { } /** - * Size of `content` WITHOUT materializing it. - * - * A string reports its UTF-16 `length`, which is a sound LOWER bound on its - * UTF-8 byte length — every code unit encodes to at least one byte — so this - * can never reject a string that would have fit; the exact length is charged - * after the encode. Anything else reports 0 and is left to `toBytes` to reject - * by type. + * Size of `content` without materializing it. A string's `length` is a lower + * bound on its UTF-8 size; an unsupported type reports 0 for `toBytes` to + * reject. */ function declaredSize(content: Attachment['content']): number { if (content instanceof Uint8Array) { @@ -606,13 +453,8 @@ async function normalizeAttachment(att: Attachment): Promise MAX_LINE_OCTETS) { throw new MimeError( 'MIME_ERROR', @@ -620,15 +462,9 @@ async function normalizeAttachment(att: Attachment): Promise; name="…"` puts the media type on the line ahead of the - // builder's own parameter, and `validateMediaType` keeps one string per - // parameter, so both run over a value already known to fit. if (att.contentType.length + CONTENT_TYPE_LINE_OVERHEAD > MAX_LINE_OCTETS) { throw new MimeError( 'MIME_ERROR', @@ -641,15 +477,7 @@ async function normalizeAttachment(att: Attachment): Promise` is one token after the field - // name and `foldHeader` returns the line whole: exactly the field name - // plus the id plus the `<` `>` `buildAttachmentPart` adds around a bare - // one — an id that already starts with `<` renders as given, so it is - // allowed those 2 characters more. `length` never exceeds the UTF-8 - // octet count, so this cannot reject an id whose line would have fit; - // an over-long id with whitespace in it is refused here rather than - // by the grammar, and refused either way. + // `Content-ID: ` never folds; the builder adds `<>` around a bare id. const brackets = att.contentId.startsWith('<') ? 0 : 2; if (att.contentId.length + brackets + CONTENT_ID_LINE_OVERHEAD > MAX_LINE_OCTETS) { throw new MimeError( @@ -661,11 +489,8 @@ async function normalizeAttachment(att: Attachment): Promise MAX_LINE_OCTETS) { throw new MimeError( 'MIME_ERROR', @@ -735,9 +551,7 @@ export async function normalizeEmail(opts: { `so its line would exceed the ${MAX_LINE_OCTETS}-octet limit in RFC 5322 §2.1.1. Shorten the name.`, ); } - // Keep raw input work bounded even when folding later collapses a long - // whitespace run. The character check rejects huge values before the - // UTF-8 measurement allocates its byte copy. + // The character check refuses a huge value before UTF-8 encoding copies it. if (headerInputBytes + name.length + value.length + 4 > MAX_CUSTOM_HEADER_BYTES) { throw new MimeError('MIME_ERROR', 'email: custom headers exceed the 16 KiB limit.'); } @@ -757,9 +571,8 @@ export async function normalizeEmail(opts: { ) { throw new MimeError('MIME_ERROR', 'email: custom headers exceed the 16 KiB limit.'); } - // Match the proxy's wire-level accounting exactly: RFC 2047 expansion, - // every folded continuation line, and the terminating CRLF. encodeWord - // always returns ASCII, so the rendered string length is its byte size. + // The proxy's wire-level accounting; `encodeWord` returns ASCII, so + // length is bytes. headerBytes += foldHeader(name, encodeWord(value)).length + CRLF.length; if (headerBytes > MAX_CUSTOM_HEADER_BYTES) { throw new MimeError('MIME_ERROR', 'email: custom headers exceed the 16 KiB limit.'); @@ -783,12 +596,8 @@ export async function normalizeEmail(opts: { const cc = opts.cc ? parseAddresses(opts.cc, 'cc') : []; const bcc = opts.bcc ? parseAddresses(opts.bcc, 'bcc') : []; const replyTo = opts.replyTo ? parseAddress(opts.replyTo, 'replyTo') : undefined; - // Before any entry is normalized: the count is what bounds the loop below - // for a zero-byte attachment, which the byte total never sees. The type - // admits only an array, but that binds nothing at runtime, and the count - // is only a bound on the loop if the loop walks the array it was read - // from — so anything else is refused, as `toBytes` refuses a content of - // a type outside the contract. + // The count bounds the loop below (zero-byte entries never advance the + // byte total), but only if the loop walks the array it was read from. const attachmentList = opts.attachments ?? []; if (!Array.isArray(attachmentList)) { throw new MimeError('MIME_ERROR', 'email: attachments must be an array.'); @@ -799,12 +608,7 @@ export async function normalizeEmail(opts: { `email: too many attachments (max ${MAX_ATTACHMENTS}, got ${attachmentList.length}).`, ); } - // Sequential, not `Promise.all`: the running total has to be charged - // BEFORE each copy, or the entire set is already resident by the time - // anything looks at a size. Sequencing costs nothing measurable — every - // conversion in `toBytes` is synchronous except `Blob.arrayBuffer`, which - // is a memcpy of bytes the caller already holds, so there is no I/O here to - // overlap. + // Sequential so the running total is charged before each copy. const attachments: Array = []; let attachmentBytes = 0; for (const att of attachmentList) { @@ -821,9 +625,7 @@ export async function normalizeEmail(opts: { } attachments.push(normalized); } - // Threading headers are passed through as-is — they come from the - // trusted reply path (buildReplyMessage), not user input. We still - // reject CR/LF inside them, same as any other header value. + // Threading comes from the trusted reply path, but CR/LF are still refused. if (opts.threading?.inReplyTo) { rejectCRLF('threading.inReplyTo', opts.threading.inReplyTo); } @@ -847,23 +649,14 @@ export async function normalizeEmail(opts: { } /** - * RFC 2047 §2 caps an encoded-word at 75 characters *including* the - * `=?UTF-8?B?` prefix (10) and the `?=` suffix (2). That leaves 63 - * characters of base64, and base64 spends 4 characters per 3 input - * bytes, so 45 source bytes is the largest payload that fits — 48 would - * need 64. + * RFC 2047 §2: a 75-character encoded-word less `=?UTF-8?B?` and `?=` leaves + * 63 characters of base64, so 45 source bytes. */ const ENCODED_WORD_PAYLOAD_BYTES = 45; /** - * RFC 2047 encoded-word for non-ASCII text in headers (UTF-8, base64). - * - * Text longer than one word's payload becomes several encoded-words - * separated by a space, which `foldHeader` turns into CRLF + SPACE - * wherever a line would otherwise overrun (RFC 2047 §6.2: whitespace - * between adjacent encoded-words is dropped on decode either way). This - * keeps each word under §2's 75-character ceiling and a long subject or - * display name under RFC 5322 §2.1.1's 998-octet line limit. + * RFC 2047 B encoded-words for non-ASCII header text, space-separated so + * `foldHeader` can fold between them. */ function encodeWord(text: string): string { if (ASCII_PRINTABLE.test(text)) { @@ -881,14 +674,7 @@ function encodeWord(text: string): string { length = 0; } }; - // `for..of` iterates code points, so a surrogate pair is never split - // across two words — RFC 2047 §5 forbids splitting a multi-octet - // character between adjacent encoded-words, and half a pair would - // decode to U+FFFD. Each chunk is base64'd on its own for the same - // reason: encoding once and slicing the output would cut characters - // mid-sequence. Every character is measured and appended exactly once, - // so a character wider than the whole budget (impossible in UTF-8) - // lands alone in its own word instead of spinning. + // Code points, so no character is split across words (RFC 2047 §5). for (const char of text) { const bytes = encoder.encode(char); if (length > 0 && length + bytes.length > ENCODED_WORD_PAYLOAD_BYTES) { @@ -902,17 +688,8 @@ function encodeWord(text: string): string { } /** - * RFC 5322 §3.2.3, verbatim: - * - * specials = "(" / ")" / "<" / ">" / "[" / "]" / ":" / ";" / "@" / - * "\" / "," / "." / DQUOTE - * - * A display name is a §3.2.5 `phrase = 1*word`, `word = atom / - * quoted-string`, and `atext` is everything printable EXCEPT these, so an - * ASCII name containing any of them must be rendered as one quoted-string. - * The class below lists the thirteen in the RFC's order so it can be checked - * against the production by eye. `.` matters: an unquoted `Dr. Smith` is - * `obs-phrase` (§4.1), which §4 says MUST NOT be generated. + * RFC 5322 §3.2.3 `specials`, in the RFC's order: an ASCII display name with + * any of them must be quoted (an unquoted `Dr. Smith` is obsolete syntax). */ const RFC5322_SPECIALS = /[()<>[\]:;@\\,."]/; @@ -937,25 +714,10 @@ function formatAddressList(addrs: Array<{ email: string; name?: string }>): stri } /** - * Soft-fold a long header line. Fold points, strongest first: address-list - * separators (a `, ` outside any double-quoted string), then any remaining - * unquoted whitespace inside a part. A naive `split(/, /)` would split - * inside `"Smith, John"`, corrupting quoted display names, and folding only - * between whole parts is not enough — one recipient carrying a long encoded - * display name, or a `References:` run of message IDs, is a single part - * that can blow past RFC 5322 §2.1.1's 998-octet hard limit on its own. - * - * RFC 2047 §2 limits any header line carrying an encoded-word to 76 - * characters; RFC 5322 §2.1.1 makes 78 a SHOULD and 998 a MUST. Folding at - * 76 satisfies all three. A comma always stays on the line it terminates. - * A token longer than the budget still gets its own line, which is the - * best a WSP-only fold rule allows (§3.2.2 permits folding only at WSP). - * - * One pass, and the only thing kept per input character is the folded - * output itself: each finished line is one flat string in `lines`. Any - * per-token allocation (a slice array, token objects, `out +=`) costs about - * 130 bytes of heap per token, so a legal 3 MB subject of one-letter words - * would terminate a 128 MB isolate even though the message fits. + * Soft-fold a header at 76 columns (RFC 2047 §2): at `, ` outside quotes + * first, then at unquoted whitespace. A token longer than a line keeps its + * own line, since RFC 5322 folds only at WSP. One pass that keeps only the + * output; per-token allocation would exhaust the isolate on a large subject. */ function foldHeader(name: string, value: string): string { const line = `${name}: ${value}`; @@ -1063,22 +825,11 @@ const MAX_LINE_OCTETS = 998; /** ` filename="` + `"`: the shortest line a quoted filename parameter can fold onto. */ const QUOTED_FILENAME_LINE_OVERHEAD = 12; /** - * ` ` + `;`: the shortest line an attachment's media type can fold onto, - * the whole value taken as one token. `foldHeader` would split a value - * of several parameters at their spaces, so this bound is exact for the - * one-token media type every attachment carries in practice and - * conservative for a run of parameters that together pass the line - * limit; that is accepted, because the alternative — find the fold points - * first — is the scan this bound exists to run ahead of. + * ` ` + `;`: the shortest line the media type can fold onto as one token + * (conservative for several parameters). */ const CONTENT_TYPE_LINE_OVERHEAD = 2; -/** - * `Content-ID: `: the only line an attachment's Content-ID can sit on. The - * value is one token — `` — so `foldHeader` cannot fold it onto a - * continuation line the way it can a media type, and the field name is - * part of what the id must fit beside. Exact, not conservative: the `<` - * and `>` the builder adds around a bare id are charged at the call. - */ +/** `Content-ID: `: the id is one token and cannot fold off this line. */ const CONTENT_ID_LINE_OVERHEAD = 'Content-ID: '.length; /** ` "` + `"`: the shortest line a quoted display name can fold onto. */ const QUOTED_NAME_LINE_OVERHEAD = 3; @@ -1086,18 +837,9 @@ const QUOTED_NAME_LINE_OVERHEAD = 3; const HEADER_NAME_LINE_OVERHEAD = 1; /** - * First line of `text` that exceeds the octet limit, if any. - * - * Every line is scanned, not just the top header block: a multipart part's - * own headers (`Content-Disposition: …; filename="…"`) live inside the body - * and are just as unfoldable. Body content cannot trip this — text is - * quoted-printable re-encoded above 990 octets and base64 is emitted at a - * fixed width — so a hit is always a header. - * - * One pass over the string, counting UTF-8 octets per CRLF-delimited line, - * with NO allocation: a `split(CRLF)` would hold one substring per line, and - * a legal body of pasted logs is millions of lines. Only a CRLF pair ends a - * line; a bare CR or LF is content. + * First CRLF-delimited line over the octet limit, counted in UTF-8 without + * allocating. Every line is scanned because part headers live in the body; + * encoded body content never trips it. */ function findOverlongLine(text: string): { index: number; octets: number } | undefined { let index = 0; @@ -1130,24 +872,36 @@ function findOverlongLine(text: string): { index: number; octets: number } | und return octets > MAX_LINE_OCTETS ? { index, octets } : undefined; } -/** - * Escape a value for a quoted-string parameter (RFC 2045 §5.1 value, whose - * quoted-string comes from RFC 5322 §3.2.4). - * - * `qtext` excludes both `\\` and `"`, so each may appear only as a - * quoted-pair: a raw trailing backslash would escape the closing quote, and - * a conformant parser drops every other raw backslash. - * - * Backslash first, then quote — the other order would double-escape. - */ +/** Escape `\` and `"` as quoted-pairs (RFC 5322 §3.2.4), backslash first. */ function quoteParam(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } -function base64(bytes: Uint8Array): string { +export function base64(bytes: Uint8Array): string { return btoa(codeUnitsToString(bytes)); } +/** Decode base64; throws on invalid input. */ +export function base64Decode(value: string): Uint8Array { + const binary = atob(value); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +/** `_<16 random hex>`. */ +export function newId(prefix: string): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + let hex = ''; + for (const b of bytes) { + hex += b.toString(16).padStart(2, '0'); + } + return `${prefix}_${Date.now().toString(36)}${hex}`; +} + function base64Wrapped(bytes: Uint8Array): string { const encoded = base64(bytes); const lines: Array = []; @@ -1160,13 +914,9 @@ function base64Wrapped(bytes: Uint8Array): string { function quotedPrintable(text: string): string { // Encode as UTF-8 bytes then quote per RFC 2045. const bytes = new TextEncoder().encode(text); - // Emit into a byte buffer, not a string: `out += chunk` per byte builds a - // V8 cons-string tree whose nodes cost ~56 bytes per encoded byte and stay - // live until the message is flattened. Measured, 1 MiB of non-ASCII text - // held 57 MiB that way; a 1.9 MiB body killed a 128 MB isolate while its - // 5.9 MiB message was under the limit. Every input byte yields at most 3 - // output bytes, a soft break (3) lands at most once per 73 content bytes, - // and a lone LF becomes CRLF, so this buffer never grows. + // A byte buffer, not `out +=`, whose cons-string nodes cost ~56 bytes per + // byte. At most 3 bytes per input byte plus a soft break per 73, so it + // never grows. const out = new Uint8Array(Math.ceil(bytes.length * 3 * (76 / 73)) + 8); let n = 0; let lineLen = 0; @@ -1240,14 +990,9 @@ function codeUnitsToString(units: Uint8Array | Uint16Array): string { } /** - * Whether a body must be quoted-printable encoded: anything outside - * printable ASCII, or any line longer than 990 characters (RFC 5322 §2.1.1 - * makes 998 octets the hard limit). Only LF and CRLF end a line; a bare CR - * is a control character like any other and forces the encoding. - * - * One pass over the string with NO allocation: a `replace`/`split` shape - * holds a copy of the body plus one substring per line, and a legal 6 MB - * body of millions of two-character lines needed about 100 MB that way. + * Whether a body needs quoted-printable: anything outside printable ASCII + * (a bare CR included) or a line over 990 characters. One pass without + * allocation. */ function needsQuotedPrintable(text: string): boolean { let lineLen = 0; @@ -1275,18 +1020,9 @@ function needsQuotedPrintable(text: string): boolean { } /** - * The 7bit body: `text` with every bare LF widened to CRLF, and every CRLF - * kept. Only called once `needsQuotedPrintable` has returned false, so every - * character is printable ASCII, LF, or the CR of a CRLF — one byte each — - * and a byte buffer is exact. - * - * NOT `text.replace(/\r?\n/g, CRLF)`: V8 builds a global-regex replacement - * from a parts array that holds two or three slots per match, grown by - * doubling, so that one line cost 144 MB of heap on a legal 6 MB body of - * 3M lines and terminated a 128 MB isolate — the largest single allocation - * in the whole build, for a body that renders to 9 MB. The buffer here is - * the output size, known up front from one counting pass. A body that is - * already CRLF-only (or has no line breaks) is returned as-is. + * `text` with each bare LF widened to CRLF. Called only when every character + * is one byte, so a counting pass sizes the buffer exactly; a global-regex + * replace would allocate per line. */ function to7bitBody(text: string): string { let bare = 0; @@ -1311,16 +1047,8 @@ function to7bitBody(text: string): string { } /** - * Lower bound on the octets `text` will occupy in the rendered message, in - * one pass over the string and with NO allocation. Every UTF-8 byte yields at - * least one octet on either body path — the 7bit path copies bytes and only - * ever widens a bare LF to CRLF; the quoted-printable path emits each - * non-ASCII byte as `=XX` (3 octets) and every other byte as itself, and its - * soft breaks only add. So `ascii + 3 * nonAscii` can never overshoot the - * real size, which keeps a false rejection impossible; `buildMime`'s final - * check remains the authority. `TextEncoder.encode` would give the same count - * but at the price of a full copy, which is exactly the allocation this - * bound exists to avoid. + * Lower bound on `text`'s rendered octets, without allocating: ASCII is at + * least 1 octet and each non-ASCII UTF-8 byte is `=XX`. */ function minBodyOctets(text: string): number { let octets = 0; @@ -1426,29 +1154,10 @@ const RESERVED_HEADERS = new Set([ ]); /** - * Lower bound on the octets one header VALUE occupies once `encodeWord` (or - * `formatAddress`, for a display name) and `foldHeader` have rendered it, in - * one pass over the string and with NO allocation. - * - * `encodeWord` leaves a value alone when every character is printable ASCII - * and otherwise B-encodes ALL of it, so the two paths need different terms: - * - * - Plain: every character is at least one octet, EXCEPT whitespace. - * `foldHeader` re-joins a long value from its non-WSP tokens, so a run of - * spaces or tabs collapses to one, and a bound that charged them could - * reject a message that fits. Only the non-WSP characters count. - * - Encoded: each RFC 2047 word carries at most `ENCODED_WORD_PAYLOAD_BYTES` - * (45) UTF-8 bytes as base64 — `ceil(n / 3) * 4 >= 4n / 3` octets — inside - * 12 octets of `=?UTF-8?B?` and `?=`, so there are at least `floor(n / 45)` - * words' worth of framing. Whitespace inside the value is base64 like any - * other byte; only the single separators between words fold. - * - * Folding (`foldHeader`) can only ADD octets beyond that — a `CRLF SP` per - * line — and quoting a display name (`formatAddress`) adds the quotes and - * escapes, so neither path can render fewer octets than this returns. - * Measured, the encoded bound is 96% of the rendered size for CJK, where the - * real cost is 5 octets per character; a UTF-16 `length` would have counted - * 1 and let the isolate die on a header five times over the limit. + * Lower bound on one rendered header value, without allocating. Printable + * ASCII counts its non-WSP characters (folding may collapse WSP runs); + * anything else is B-encoded whole, at least 4/3 of its UTF-8 bytes plus 12 + * octets of framing per 45-byte word. */ function minHeaderValueOctets(value: string): number { let utf8 = 0; @@ -1494,18 +1203,9 @@ function minAddressOctets(addr: { email: string; name?: string }): number { } /** - * Lower bound on the octets of a value rendered VERBATIM — never through - * `encodeWord` — into a header `foldHeader` may still fold: `In-Reply-To`, - * `References`, and an attachment part's `Content-Type` media type and - * `Content-ID`. Every non-WSP UTF-16 code unit is at least one octet of - * UTF-8; a WSP run may collapse to a single space when the line folds, so - * none is charged. The `<>` around a Content-ID and the fold's `CRLF SP` - * only add. - * - * `minHeaderValueOctets` cannot stand in here: a non-ASCII value sends it - * down the encoded-word formula (4.8 octets per CJK character) while these - * values render as raw UTF-8 (3), so it would overshoot and could reject a - * message that fits. + * Lower bound on a value rendered verbatim (threading ids, media type, + * Content-ID): one octet per non-WSP code unit. `minHeaderValueOctets` would + * overshoot, since these are raw UTF-8, not encoded-words. */ function minVerbatimOctets(value: string): number { let octets = 0; @@ -1519,16 +1219,8 @@ function minVerbatimOctets(value: string): number { } /** - * Octets every attachment part costs before any of its values: what - * `wrapMultipart`, `renderPart` and `buildAttachmentPart` emit for a part - * whose filename, media type, disposition and content are all empty. Each - * term is emitted verbatim on every path — `foldHeader` returns a short - * line as-is and re-joins a long one from its tokens with at least one - * space between them, so the space after `Name:` and the one after `;` are - * floors, not counts — and a fold's `CRLF SP` only adds. The boundary's - * random suffix is left out: `makeBoundary` strips `+`, `/` and `=`, so it - * has no provable minimum, and `mixed` is the shortest prefix an attachment - * part can sit under. + * Framing every attachment part costs with empty values. The random boundary + * suffix has no provable minimum, so it is not counted. */ const ATTACHMENT_PART_MIN_OCTETS: number = CRLF.length + // the join between this part and the one before it @@ -1539,85 +1231,23 @@ const ATTACHMENT_PART_MIN_OCTETS: number = CRLF.length; // the blank line before the body /** - * Cap on the attachment COUNT, checked against the raw list in - * `normalizeEmail` before a single entry is normalized. - * - * Every part costs at least `ATTACHMENT_PART_MIN_OCTETS` of framing plus - * its filename twice, and `normalizeAttachment` refuses an empty filename, - * so the size projection in `buildMime` charges a part at least 119 octets - * and refuses one more part than this whatever else the message holds. - * That projection runs only after `normalizeEmail` has normalized EVERY - * entry, and `MAX_ATTACHMENT_BYTES_TOTAL` cannot bound that loop for a - * zero-byte attachment — its running total never advances — while the - * normalized copy (the object, a fresh `Uint8Array`, the `TextEncoder` - * that made it) costs about 275 bytes of live heap per entry. Measured, 2M - * empty attachments terminated a 128 MB isolate in 0.3 s instead of - * returning `MIME_ERROR`; this many normalize in 24 MB and are then refused - * by the post-render size check under the same ceiling. - * - * Derived, not chosen: it is exactly the largest count the projection can - * admit, so this cannot reject a message that would have been accepted, and - * `buildMime` remains the authority on the final size. Same class as - * `MAX_ATTACHMENT_BYTES_TOTAL` — a developer-experience guardrail on the - * app's own input, not the security boundary. + * Cap on the attachment count, checked before any entry is normalized: + * zero-byte entries never advance the byte total. It is the most parts + * `buildMime`'s size projection can admit, so it refuses nothing that fits. */ export const MAX_ATTACHMENTS: number = Math.floor( MAX_MESSAGE_BYTES / (ATTACHMENT_PART_MIN_OCTETS + 2), ); -/** - * Octets every custom header costs around its value: the `: ` after the - * name and the CRLF that ends its line. Exact, not a floor, once the name - * is counted: `buildMime` renders each non-reserved custom header through - * `foldHeader`, which emits the name verbatim, then `: ` — or, when the - * value's first token does not fit beside the name, `:` CRLF SP, which is - * more — and `join(CRLF)` ends every header line, a custom header never - * being the last of the block (the body part's own headers follow it). - */ +/** `: ` and the CRLF around a custom header value; exact once the name is counted. */ const CUSTOM_HEADER_FRAMING_OCTETS: number = ': '.length + CRLF.length; /** - * Lower bound on the octets of every header `buildMime` renders from - * caller-supplied values: `Subject`, the custom headers, the addresses in - * `From`, `To`, `Cc` and `Reply-To`, the `In-Reply-To` / `References` pair - * `replyEmail` threads through (remote-sender controlled, up to the whole - * inbound message), and each attachment part's `Content-Type`, `Content-ID` - * and — twice, once per header it is rendered into — filename. `Bcc` is - * never rendered and a reserved custom header is dropped, so neither is - * counted — charging either could reject a message that fits. The fixed - * `Name: ` and `CRLF` of the headers `buildMime` itself names — `Subject`, - * the address lists, the threading pair — are omitted for the same reason - * a lower bound may omit any term. They are NOT omitted where the caller - * controls the name or the count, because there they can be the whole - * message: - * - * - A custom header's name is rendered verbatim, and `normalizeEmail` - * bounds ONE name at under a kilobyte, not the sum, nor the count. With - * empty values the names and the `: ` CRLF around each are the entire - * size, and the framing is exact once the name is counted (see - * `CUSTOM_HEADER_FRAMING_OCTETS`), so charging both cannot reject a - * message that fits. Uncharged, 60k empty headers with 500-character - * names (a 30 MB message) would project to zero. - * - An attachment part: `normalizeAttachment` bounds ONE filename at under - * a kilobyte, not the sum, and a message is free to carry any number of - * parts, so the filename and the framing every part is wrapped in are - * the whole size of a message of empty attachments. Measured, 50k - * zero-byte attachments with legal 986-character filenames rendered to - * 106 MB — held three to four times over between the parts, their join, - * `rawText` and `raw` — and terminated a 128 MB isolate before the final - * size check could return `MIME_ERROR`. - * - * The header COUNT is deliberately not bounded here. Each header costs - * at least five octets (a one-character name, `: `, CRLF), so the count - * the projection can admit is finite, and past a few hundred thousand keys - * the caller's own object — measured at about 74 bytes per key, before - * `sendEmail` is called — is the dominant cost, which no check inside this - * module can pre-empt. Rendering does add live heap per header (about - * 150 bytes: two `Object.entries`, the `topHeaders` pair, the folded line), - * so a legal message of 500k four-character headers still does not fit a - * 128 MB isolate while one of 400k does; a bound on that would be the - * framework's own number — the platform proxy bounds bytes, not headers — - * and is left as a product decision rather than invented here. + * Lower bound on every header rendered from caller values. Bcc and reserved + * custom headers never render, and the fixed framing of Void's own headers + * is omitted. A custom header's name and framing, and each attachment part's + * framing and filename (twice), are charged: a message of many empty headers + * or attachments is all framing. */ function minHeaderOctets(email: NormalizedEmail): number { let octets = minHeaderValueOctets(email.subject); @@ -1644,10 +1274,7 @@ function minHeaderOctets(email: NormalizedEmail): number { octets += minVerbatimOctets(email.threading.references); } for (const att of email.attachments) { - // The filename sits inside a quoted-string in both `name="…"` and - // `filename="…"`, and `foldHeader` never splits a quoted-string, so - // every code unit of it — whitespace included — is at least one octet - // on each line; `quoteParam`'s escapes only add. + // The filename is quoted twice and never folds, so whitespace counts too. octets += ATTACHMENT_PART_MIN_OCTETS + 2 * att.filename.length; octets += minVerbatimOctets(att.contentType); if (att.contentId) { @@ -1670,18 +1297,9 @@ function generateMessageId(fromEmail: string): string { * generated Message-ID header so callers can surface it in the result. */ export function buildMime(email: NormalizedEmail): { raw: Uint8Array; messageId: string } { - // Project the message size BEFORE rendering anything — headers included. - // The final check below sees the message only after every header has been - // encoded and folded and every body quoted-printable encoded, all of it - // rendered into `rawText` and encoded again. Measured, that peak is about - // 11x the UTF-8 bytes of a non-ASCII body, and `encodeWord` alone holds - // over 3x the UTF-8 bytes of a non-ASCII header (its array of encoded - // words plus their join) before the fold, the join, `rawText` and `raw` - // each add another copy of the rendered header. A body several times over the - // limit, or a 20 MB subject or display name, terminated a 128 MB isolate - // instead of returning the `MIME_ERROR` the guide promises. Each term is - // a lower bound on the rendered size, so this never rejects a message - // that would have fit; the final check stays the authority. + // Project the size before rendering, which holds several copies of the + // message: an oversize body or header must fail with `MIME_ERROR`, not + // exhaust the isolate. Each term is a lower bound; the final check rules. const projected = minHeaderOctets(email) + (email.text ? minBodyOctets(email.text) : 0) + @@ -1770,23 +1388,9 @@ export function buildMime(email: NormalizedEmail): { raw: Uint8Array; messageId: const rawText = headerBlock + CRLF + CRLF + bodyPart.body; const raw = new TextEncoder().encode(rawText); - // RFC 5322 §2.1.1 makes 998 octets per line a MUST, and RFC 5321 - // §4.5.3.1.6 caps an SMTP text line at 1000 including CRLF. `foldHeader` - // can only fold at whitespace (§3.2.2), so a long token with none — - // a JWT, a signed URL, a machine-generated Message-ID — cannot be folded - // at all and leaks through both its single-chunk early return AND its - // fold loop, which still gives an over-long chunk its own line. - // - // Checked here, after rendering, rather than per field: this is the only - // place that sees every header path at once, including the header NAME - // and the `filename=` / `name=` / `Content-ID` parameters that are built - // outside `foldHeader`. Encoding the value instead of rejecting is not a - // general remedy — RFC 2047 §5 permits encoded-words only in unstructured - // fields, comments and phrases, so it is illegal in `References`, - // `In-Reply-To`, `Content-ID` and parameter values, where a receiver - // would read `=?UTF-8?B?…?=` as a literal id or filename. Truncating - // silently corrupts: a clipped `References` breaks threading with no - // signal, a clipped `Content-ID` orphans a `cid:` reference. + // RFC 5322 §2.1.1: at most 998 octets per line, and a token without + // whitespace cannot fold. Checked after rendering, the only place that sees + // every header path; encoding or truncating would corrupt ids and names. const overlong = findOverlongLine(rawText); if (overlong !== undefined) { throw new MimeError( diff --git a/packages/void/src/runtime/email/testing.ts b/packages/void/src/runtime/email/testing.ts index 6e2c38b1c..2131e45a7 100644 --- a/packages/void/src/runtime/email/testing.ts +++ b/packages/void/src/runtime/email/testing.ts @@ -194,28 +194,14 @@ async function buildSyntheticRaw(opts: InboundDeliverOptions): Promise { const raw = await buildSyntheticRaw(input); - // The managed router's ADMISSION, before anything else looks at the - // bytes (`routeInbound` in `email-router.ts`): a message over - // `MAX_INBOUND_BYTES` is rejected from `rawSize` before its body is - // read, then `buildEnvelope` refuses one with more than - // `MAX_INBOUND_HEADERS` distinct names — each with a reject the handler - // never sees, because it is never invoked. Same order, same reasons, same - // outcome: one reject, no handler call. Platform lane only — a worker on - // your own Cloudflare account (`slug: null`) has no router in front of - // it, so Cloudflare hands the handler everything it accepts. - // - // The router prefixes every reason with the zone it serves - // (`EMAIL_SENDER_DOMAIN`). It is the catch-all rule on that zone, so the - // wire recipient's domain IS that zone and the harness recovers the label - // from the address it was given; `void email` is the router's own - // fallback when the var is unset. The size check reads `byteLength`: it - // runs BEFORE `parseHeaderBlock` decodes anything. - // - // Production's `buildEnvelope` copies every wire header verbatim, so - // `message.headers` and `message.raw` agree by construction. Start from - // the bytes for the same reason: the harness's own synthetic MIME - // carries a generated `Message-ID` that `replyEmail` threads on, and a - // caller-supplied `raw:` carries the exact wire headers to deliver. - // - // A hand-rolled header split rather than postal-mime, so a deliberately - // malformed `raw:` fixture still reaches the handler instead of - // throwing PARSE_FAILED inside the harness. + // Mirrors the router's admission (`routeInbound` in `email-router.ts`): + // size, then distinct-header count, each a reject before any handler + // runs, prefixed with the zone (the recipient's domain). Own-account + // (`slug: null`) has no router. Headers come from the bytes, as + // production copies the wire headers. let headers: Headers; if (opts.slug === null) { headers = parseHeaderBlock(raw, Infinity); @@ -326,18 +290,13 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou throw e; } } - // The caller's own headers win, so an explicit override stays an escape - // hatch. Applied AFTER admission: they are not on the wire, so the - // router never counted them. + // The caller's own headers win, applied after admission: they are not on + // the wire. for (const [k, v] of new Headers(input.headers ?? {})) { headers.set(k, v); } - // Subject is NOT re-set from `input.subject`. The bytes already carry it, - // and they carry it the way the wire does: `buildMime` RFC 2047-encodes a - // non-ASCII subject, and production hands the handler that encoded form. - // Overriding with the decoded string here would make - // `message.headers.get('Subject')` differ between harness and production - // for any non-ASCII subject — the pattern the guide itself shows. + // Subject is not re-set from `input.subject`: the bytes carry the RFC + // 2047-encoded form production hands the handler. if (!headers.has('Subject') && input.subject) { headers.set('Subject', input.subject); } @@ -348,13 +307,6 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou headers.set('To', input.to); } - // Mirror production: the dispatcher strips the platform-injected - // `+` prefix from message.to before user code sees it. When the - // harness was created with an explicit `slug`, use that; otherwise - // infer it from this delivery's recipient (the part before the first - // `+`). Inference keeps existing tests with `slug+tag@domain` recipients - // working without configuration. Falls back to the verbatim address - // if no `+` is present. const slug = (() => { if (opts.slug === null) { return undefined; @@ -367,17 +319,8 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou return plus === -1 ? undefined : local.slice(0, plus); })(); - // The message the handler sees is production's own proxy, built the way - // the generated `email()` entry builds it: from an envelope and the slug. - // Everything the proxy enforces — setReject is terminal, the 100-action - // queue, the address checks (length and shape), the header-record check, the - // per-reply and per-delivery byte bounds — therefore throws here at the - // same call, with the same error, as it does after deploy. The proxy also - // makes `raw` replayable, - // as the platform lane does. - // - // `Object.fromEntries` is what the platform router's `buildEnvelope` - // does with the native message's headers. + // Production's own proxy, built as the generated `email()` entry builds + // it, so every proxy check throws here as it does after deploy. const { message, getActions } = createMessageProxy( { from: input.from, @@ -398,24 +341,12 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou }, } as unknown as ExecutionContext; - // Mirror the production dispatcher's slug-strip: on the platform the - // recipient is `+@mail.example.com` and the dispatcher - // strips through the first `+` before matching. The test harness must do - // the same so tests don't pass against routing paths that can't occur in - // production. With `slug: null` (own-account deploy) nothing is stripped - // and the full local part is matched, exactly as the dispatcher does with - // no `__VOID_PROJECT_SLUG` configured. - // Case is PRESERVED and the patterns carry the `i` flag instead, exactly - // as the generated dispatcher does (`router/compile.ts`). Lowercasing - // here would lowercase the CAPTURED groups too, so a handler under test - // would see `t-42` for `support+T-42@` while production hands it `T-42` — - // a green test asserting behavior the deployed code does not have. + // Case is preserved and patterns match with `i`, as the generated + // dispatcher does, so captured params keep their case. On the platform a + // recipient without `+` has no tag, so only `_default` can take it. const fullLocal = input.to.split('@')[0] ?? ''; const plus = fullLocal.indexOf('+'); const local = opts.slug === null ? fullLocal : plus === -1 ? '' : fullLocal.slice(plus + 1); - // Note: on the platform a recipient with no `+` (e.g. `slug@mail.example.com`) - // has no tag to match — production lands at `_default` if defined, otherwise - // no-op. We mirror that here. async function dispatch(): Promise { for (const route of compiled) { @@ -437,17 +368,8 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou await defaultHandler(message, env, ctx, info); return { handler: '_default', params: {} }; } - // Nothing matched and there is no `_default`. The generated dispatcher - // rejects here so the remote MTA gets a non-delivery report - // (`router/compile.ts`), and the harness has to mirror that or a test - // for catch-all or anti-spam behavior observes an ACCEPTED message - // where deployment bounces it. Routed through `setReject` rather than - // pushed directly, so the terminal-reject semantics stay identical. - // - // The reason is byte-for-byte the dispatcher's, built from the WIRE - // recipient with its original case — that string reaches the sender's - // DSN, and rebuilding it from the stripped or lowercased address would - // recreate the divergence class this mirroring exists to prevent. + // No match and no `_default`: the dispatcher (`router/compile.ts`) + // rejects with this exact reason, built from the wire recipient. message.setReject( `no handler matched recipient "${input.to}" — add email/_default.ts to handle unmatched mail`, ); @@ -455,10 +377,7 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou } const result = await dispatch(); - // Recorded once the handler has returned, as production enacts them: a - // handler that throws leaves nothing — the platform router rejects the - // message as a handler error, and on your own account the invocation - // fails before `enactEmailActions` runs. + // Recorded after the handler returns: a throwing handler enacts nothing. record(getActions()); deliveries.push(result); return result; @@ -473,10 +392,8 @@ export function createInboundTestHarness(opts: InboundTestHarnessOptions): Inbou return result; } - // The proxy already made `setReject` terminal — earlier forward/reply - // entries purged, later ones dropped — so the queue is exactly what the - // router would enact. A forward's headers cross the queue as the record - // the proxy validated; the capture hands them back as `Headers`. + // The proxy already made `setReject` terminal, so the queue is what the + // router would enact. function record(actions: ReadonlyArray): void { for (const action of actions) { switch (action.type) { diff --git a/packages/void/src/runtime/email/types.ts b/packages/void/src/runtime/email/types.ts index 334d82b95..a026b8e11 100644 --- a/packages/void/src/runtime/email/types.ts +++ b/packages/void/src/runtime/email/types.ts @@ -17,12 +17,11 @@ export interface Attachment { export interface SendEmailOptions { /** - * Sender envelope address. Optional — when omitted, the framework - * substitutes `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>` - * from the two platform-injected bindings (the project slug and the - * mail domain configured for this environment). Outside a deployed - * worker, or whenever either binding is missing, `sendEmail` returns - * `INVALID_FROM` — it never falls back to a baked-in domain. + * Sender envelope address. Optional — when omitted, the framework uses + * `email.from` from void.config.ts when it is set, otherwise + * `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>` on a Void + * platform. Without either, `sendEmail` returns `INVALID_FROM` — it never + * falls back to a baked-in domain. */ from?: Address; to: Address | Array
; diff --git a/packages/void/test/unit/email-dev-inbox.test.ts b/packages/void/test/unit/email-dev-inbox.test.ts index 0f6f289e2..2c7850e67 100644 --- a/packages/void/test/unit/email-dev-inbox.test.ts +++ b/packages/void/test/unit/email-dev-inbox.test.ts @@ -4,7 +4,7 @@ * Node (Vite dev server) workerd (miniflare child) * ---------------------- ------------------------- * emailDevInboxPlugin user route -> void/email - * registers the buffer sink sendEmail() -> hasActiveSink() + * owns the inbox buffer sendEmail() -> hasActiveSink() * serves GET /__void/inbox globalThis.__voidEmailSinks__ is * EMPTY here (different isolate) * diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index 7e72f120a..466eac24e 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -1244,16 +1244,14 @@ describe('harness enforces the proxy action bounds, like production', () => { expect(thrown).toBeInstanceOf(MimeError); expect((thrown as MimeError).code).toBe('INVALID_TO'); expect((thrown as MimeError).message).toBe( - `to address is ${over.length} characters, over the 320-character limit.`, + `to: address is ${over.length} characters, over the 320-character limit.`, ); expect(harness.forwards).toEqual([]); }); it('refuses a forward address over the 254-character path limit, by the sendEmail rule', async () => { - // Under the 320 total above, so `forward()`'s own pre-check passes and - // the refusal comes from `validateAddressParts` — the rule an outbound - // `to` gets, inherited rather than mirrored (`email-validator` checks - // 254 before its regex). + // Under the 320 bound above, so the refusal is `email-validator`'s 254 + // check — the rule an outbound `to` gets. let thrown: unknown; const label = 'b'.repeat(63); const over = `a@${[label, label, label, label].join('.')}`; From 74eb10bc01931f582ed41d3f80a76c1f5c5a8630 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 14:18:10 +0800 Subject: [PATCH 008/127] test(email): drop duplicate tests and trim history comments - Fold the scattered address-rule samples into one table per form and field - Drop tests that repeat proxy, mime or own-account cases the area already pins - Share one proxy send helper, route-table compiler and isolate reply runner - Cut fix-history, review-label and measurement notes to short reasons - Remove the 60 MB header child test that never reached the projection Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/test/unit/email-dev-inbox.test.ts | 159 +-- packages/void/test/unit/email-inbound.test.ts | 1040 +++-------------- packages/void/test/unit/email-mime.test.ts | 1002 ++++------------ .../void/test/unit/email-own-account.test.ts | 151 +-- packages/void/test/unit/email-send.test.ts | 402 +------ 5 files changed, 424 insertions(+), 2330 deletions(-) diff --git a/packages/void/test/unit/email-dev-inbox.test.ts b/packages/void/test/unit/email-dev-inbox.test.ts index 2c7850e67..4b764cf0b 100644 --- a/packages/void/test/unit/email-dev-inbox.test.ts +++ b/packages/void/test/unit/email-dev-inbox.test.ts @@ -1,24 +1,12 @@ /** - * The `void dev` email inbox spans TWO OS processes: - * - * Node (Vite dev server) workerd (miniflare child) - * ---------------------- ------------------------- - * emailDevInboxPlugin user route -> void/email - * owns the inbox buffer sendEmail() -> hasActiveSink() - * serves GET /__void/inbox globalThis.__voidEmailSinks__ is - * EMPTY here (different isolate) - * - * So `registerEmailSink` can never be the transport for a real `void dev` - * send. These tests drive the wire boundary instead: `sendEmail` runs with a - * raw env shaped like the one workerd sees (no sink registered), and its - * capture POST is routed into the REAL dev-inbox middleware through a fake - * `ASSETS` fetcher — which is exactly what `@cloudflare/vite-plugin` binds - * `env.ASSETS` to in dev (`{ node: (req, res) => viteDevServer.middlewares(req, res) }`). + * In `void dev`, `sendEmail` runs in workerd while the inbox lives in the Vite + * (Node) process, so these tests drive the wire boundary: the capture POST reaches + * the real dev-inbox middleware through a fake `ASSETS` fetcher. */ import { EventEmitter } from 'node:events'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { shouldInjectEmailDevInboxVar } from '../../src/index.ts'; +import { shouldInjectEmailDevInboxVar } from '../../src/plugin-binding-utils.ts'; import { emailDevInboxPlugin } from '../../src/plugin-email-dev-inbox.ts'; import { sendEmail } from '../../src/runtime/email.ts'; import { @@ -225,16 +213,6 @@ describe('dev inbox capture wire format', () => { }); }); -// --------------------------------------------------------------------------- -// The capture cap is sized for the SERIALIZED capture, not for the MIME. The -// wire format carries the raw MIME as base64 and every caller string again as -// JSON, and JSON escapes a control character to six bytes where the MIME -// spends three (`=01`, quoted-printable body) or 1.6 (base64 encoded-word -// header). So a message `buildMime` accepts — legal in production — can -// serialize to several times MAX_MESSAGE_BYTES, and a cap that ignores that -// makes `void dev` refuse what a deploy would send. -// --------------------------------------------------------------------------- - /** Serialized capture size of a message, alongside the MIME it was built from. */ async function serializedSizeOf( opts: Parameters[0], @@ -246,9 +224,8 @@ async function serializedSizeOf( } describe('dev inbox capture cap (sized for the serialized capture)', () => { - // Quoted-printable expands each control byte to `=01` plus soft breaks. - // The capture also carries JSON-escaped text and base64 MIME, so even a - // message below the 5 MiB sending limit exceeds three times that budget. + // A control byte is `=01` in the quoted-printable MIME but `\u0001` in the + // JSON text, so this legal message serializes past three MIME budgets. it('captures a legal message whose serialized capture exceeds three MIME budgets', async () => { const text = '\x01'.repeat(Math.floor(MAX_MESSAGE_BYTES / 3.15)); const server = createFakeViteServer(); @@ -261,31 +238,22 @@ describe('dev inbox capture cap (sized for the serialized capture)', () => { expect(message.subject).toBe('control-heavy'); expect(message.raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); expect(message.text).toBe(text); - // What crossed the wire: the same JSON the worker POSTed (ASCII, so its - // length is its byte size), including both expanded representations. + // ASCII JSON, so its length is its byte size. expect(serializeCapturedMessage(message).length).toBeGreaterThan(3 * MAX_MESSAGE_BYTES); }); - // The bound behind the constant: base64 raw (4/3) plus every caller string - // at its worst JSON expansion over its own MIME octets (3.75x, a control in - // an encoded-word header), plus the fields with no MIME footprint (`bcc` - // addresses, framing). Pinned against the real builder and serializer at a - // small scale — the ratio is per octet, so it holds at any size — so a new - // capture field, or a MIME path that stops rendering a string, changes - // this test before it changes the inbox. - it('is derived from MAX_MESSAGE_BYTES: base64 raw (4/3) plus caller strings at 3.75x', async () => { + // The ratio is per octet, so checking the real serializer at a small scale + // holds at any size. + it('covers base64 raw (4/3) plus caller strings at their worst JSON expansion (3.75x)', async () => { const worstExpansion = 6 / 1.6; - expect(DEV_INBOX_MAX_CAPTURE_BYTES).toBe(6 * MAX_MESSAGE_BYTES); const framing = 1024; const bound = (4 / 3 + worstExpansion) * MAX_MESSAGE_BYTES + MAX_RECIPIENTS * MAX_ADDRESS_LENGTH + framing; expect(DEV_INBOX_MAX_CAPTURE_BYTES).toBeGreaterThan(bound); - // ...and the smallest integer multiple that does. - expect(5 * MAX_MESSAGE_BYTES).toBeLessThan(bound); const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's' }; const control = '\x01'.repeat(30_000); - const encodedHeaderShapes: Array<[string, Parameters[0]]> = [ + const shapes: Array<[string, Parameters[0]]> = [ // encoded-word header: base64 plus framing (1.6) in the MIME, 6 in JSON — 3.75x ['control subject', { ...base, subject: control, text: 't' }], ['control display name', { ...base, to: [{ email: 'b@y.dev', name: control }], text: 't' }], @@ -293,8 +261,6 @@ describe('dev inbox capture cap (sized for the serialized capture)', () => { 'control custom header value', { ...base, headers: { 'X-Note': control.slice(0, 8_192) }, text: 't' }, ], - ]; - const shapes: Array<[string, Parameters[0]]> = [ // body: `=01` (3) in the MIME, `\u0001` (6) in JSON — 2x ['control text', { ...base, text: control }], // body, 7bit path: CRLF (2) in the MIME, `\r\n` (4) in JSON — exactly 2x @@ -312,21 +278,12 @@ describe('dev inbox capture cap (sized for the serialized capture)', () => { ), }, ], - ...encodedHeaderShapes, ]; for (const [label, opts] of shapes) { const { mimeBytes, jsonBytes } = await serializedSizeOf(opts); expect(mimeBytes, label).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); expect(jsonBytes, label).toBeLessThanOrEqual((4 / 3 + worstExpansion) * mimeBytes + framing); } - - // The 3.75x term is load-bearing, not slack: every encoded-header shape - // serializes past the body-only figure (4/3 + 2), which is why that - // figure could not have been the ratio. - for (const [label, opts] of encodedHeaderShapes) { - const { mimeBytes, jsonBytes } = await serializedSizeOf(opts); - expect(jsonBytes, label).toBeGreaterThan((4 / 3 + 2) * mimeBytes); - } }); }); @@ -367,7 +324,7 @@ describe('sendEmail -> dev inbox (cross-process transport)', () => { expect(messages[0].from.email).toBe('hello@acme.dev'); expect(messages[0].to[0].email).toBe('user@example.com'); expect(messages[0].html).toBe('

Hi there

'); - expect(messages[0].raw.byteLength).toBeGreaterThan(0); + expect(new TextDecoder().decode(messages[0].raw)).toContain('Subject: Cross-process hello'); const listed = await server.dispatch(authed('http://localhost/__void/inbox')); expect(subjectsInListHtml(await listed.text())).toContain('Cross-process hello'); @@ -399,45 +356,6 @@ describe('sendEmail -> dev inbox (cross-process transport)', () => { expect(message.bcc).toEqual([{ email: 'B@x.dev', name: 'Bob' }, { email: 'b@x.dev' }]); }); - it('serves the captured .eml back from the raw route', async () => { - const server = createFakeViteServer(); - const assets = fakeAssetsBinding(server); - await withRuntimeEnv(devEnv(assets), () => - sendEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 'raw-check', text: 'body text' }), - ); - const id = listDevMessages()[0].id; - const raw = await server.dispatch(authed(`http://localhost/__void/inbox/${id}/raw`)); - expect(raw.status).toBe(200); - expect(raw.headers.get('content-type')).toBe('message/rfc822'); - expect(await raw.text()).toContain('Subject: raw-check'); - }); - - it('acknowledges the capture with a marker header so a plain asset 200 is not mistaken for success', async () => { - const server = createFakeViteServer(); - const res = await server.dispatch( - authed(`http://localhost${DEV_INBOX_CAPTURE_URL}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: serializeCapturedMessage({ - id: 'dev_marker', - capturedAt: Date.now(), - raw: new Uint8Array([65]), - messageId: '', - from: { email: 'a@x.dev' }, - to: [{ email: 'b@y.dev' }], - cc: [], - bcc: [], - subject: 'marker', - headers: {}, - attachments: [], - }), - }), - ); - expect(res.status).toBe(200); - expect(res.headers.get(DEV_INBOX_ACK_HEADER)).toBe(DEV_INBOX_ACK_VALUE); - expect(listDevMessages()[0].subject).toBe('marker'); - }); - it('reports an honest error when the dev inbox is unreachable — no hang, no crash', async () => { const broken = { async fetch() { @@ -487,25 +405,6 @@ describe('sendEmail -> dev inbox (cross-process transport)', () => { expect(listDevMessages()).toHaveLength(0); }); - it('honors sendInDev: true by skipping the dev inbox entirely', async () => { - const server = createFakeViteServer(); - const assets = fakeAssetsBinding(server); - const result = await withRuntimeEnv(devEnv(assets), () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); - expect(assets.calls).toHaveLength(0); - expect(result.ok).toBe(false); - if (!result.ok && 'error' in result) { - expect(result.error.code).toBe('BINDING_MISSING'); - } - }); - it('uses the binding name the var names, not a hardcoded ASSETS', async () => { const server = createFakeViteServer(); const assets = fakeAssetsBinding(server); @@ -517,13 +416,6 @@ describe('sendEmail -> dev inbox (cross-process transport)', () => { }); }); -// --------------------------------------------------------------------------- -// Every inbox route requires the dev-trigger token. Vite's host validation -// admits any IP-literal `Host`, so under `--host` (or a shared port) the -// unauthenticated inbox handed every network peer the raw MIME — Bcc and -// attachments included — plus DELETE and a capture endpoint to plant messages. -// --------------------------------------------------------------------------- - function seeded(): CapturedMessage { return { id: 'dev_seeded', @@ -713,34 +605,13 @@ describe('dev inbox auth (dev-trigger token on every route)', () => { }); describe('shouldInjectEmailDevInboxVar (dev-only gate)', () => { - it('injects in dev when email is used and an assets binding exists', () => { - expect(shouldInjectEmailDevInboxVar('dev', true, 'ASSETS')).toBe(true); - }); - - // `prod` covers every build, `void deploy`, AND `vite preview` (which - // reports command: 'serve' but runs the built worker, whose assets binding - // is a real directory rather than a dev-server loopback). - it('never injects outside the dev server — a production wrangler.json must not carry it', () => { - expect(shouldInjectEmailDevInboxVar('prod', true, 'ASSETS')).toBe(false); - }); - - it('does not inject when the app does not use void/email', () => { - expect(shouldInjectEmailDevInboxVar('dev', false, 'ASSETS')).toBe(false); - expect(shouldInjectEmailDevInboxVar('dev', undefined, 'ASSETS')).toBe(false); - }); - it('does not inject when there is no assets binding to loop back through', () => { expect(shouldInjectEmailDevInboxVar('dev', true, undefined)).toBe(false); expect(shouldInjectEmailDevInboxVar('dev', true, '')).toBe(false); }); }); -// --------------------------------------------------------------------------- -// Buffer bounds. The dev buffer lives in the Vite dev server's NODE process, -// and it retains each message's full raw MIME plus its decoded text and html. -// A count-only cap of 100 therefore bounds the buffer at ~100 x -// MAX_MESSAGE_BYTES, i.e. gigabytes — enough to OOM `void dev` itself. -// --------------------------------------------------------------------------- +// The buffer lives in the dev server's Node process, so it is bounded by retained bytes. const MIB = 1024 * 1024; @@ -763,9 +634,7 @@ function capturedOfSize(id: string, rawBytes: number, htmlChars = 0): CapturedMe describe('dev inbox buffer bounds', () => { it('evicts on the retained-byte budget well below the count cap', () => { - // 8 MiB of raw MIME + 8 MiB of decoded html is 16 MiB charged per message, - // so the fifth push crosses the 64 MiB budget and evicts — at a count of - // five, nowhere near the 100-item cap that used to be the only bound. + // 16 MiB charged per message, so the fifth push crosses the 64 MiB budget. for (let i = 1; i <= 5; i++) { pushToDevBuffer(capturedOfSize(`msg-${i}`, 8 * MIB, 8 * MIB)); } diff --git a/packages/void/test/unit/email-inbound.test.ts b/packages/void/test/unit/email-inbound.test.ts index 466eac24e..6026f3ec3 100644 --- a/packages/void/test/unit/email-inbound.test.ts +++ b/packages/void/test/unit/email-inbound.test.ts @@ -122,56 +122,6 @@ describe('scanEmailHandlers', () => { expect(scan.addresses[0].address).toBe('support'); }); - it('compiles tagged + dynamic patterns and orders by specificity', async () => { - const root = setupRoot({ - 'email/support.ts': 'export default () => {}', - 'email/support+[ticket].ts': 'export default () => {}', - 'email/[user].ts': 'export default () => {}', - 'email/[user]+[tag].ts': 'export default () => {}', - }); - const scan = await scanEmailHandlers(root); - expect(scan.addresses.map((a) => a.address)).toEqual([ - 'support+[ticket]', - 'support', - '[user]+[tag]', - '[user]', - ]); - expect(scan.addresses.map((a) => a.pattern.kind)).toEqual([ - 'static-tag', - 'static', - 'dynamic-tag', - 'dynamic', - ]); - expect(scan.addresses[0].pattern.regexSource).toBe('^support\\+([^+@]+)$'); - expect(scan.addresses[0].pattern.paramNames).toEqual(['ticket']); - expect(scan.addresses[2].pattern.regexSource).toBe('^([^+@]+)\\+([^+@]+)$'); - expect(scan.addresses[2].pattern.paramNames).toEqual(['user', 'tag']); - }); - - it('sorts a literal tag ahead of a captured tag of the same kind', async () => { - // Both compile to `static-tag`, both match `support+vip@`, and the - // dispatcher returns on the first hit. Alphabetically `[` sorts before - // `v`, which used to put the capture first and leave the exact handler - // dead for every delivery. - const root = setupRoot({ - 'email/support+[tag].ts': 'export default () => {}', - 'email/support+vip.ts': 'export default () => {}', - }); - const scan = await scanEmailHandlers(root); - expect(scan.addresses.map((a) => a.address)).toEqual(['support+vip', 'support+[tag]']); - expect(scan.addresses.map((a) => a.pattern.kind)).toEqual(['static-tag', 'static-tag']); - }); - - it('sorts [user]+vip ahead of [user]+[tag]', async () => { - const root = setupRoot({ - 'email/[user]+[tag].ts': 'export default () => {}', - 'email/[user]+vip.ts': 'export default () => {}', - }); - const scan = await scanEmailHandlers(root); - expect(scan.addresses.map((a) => a.address)).toEqual(['[user]+vip', '[user]+[tag]']); - expect(scan.addresses.map((a) => a.pattern.kind)).toEqual(['dynamic-tag', 'dynamic-tag']); - }); - it('pins the full six-shape dispatch order', async () => { const root = setupRoot({ 'email/[user].ts': 'export default () => {}', @@ -190,6 +140,14 @@ describe('scanEmailHandlers', () => { '[user]+[tag]', '[user]', ]); + expect(scan.addresses.map((a) => a.pattern.kind)).toEqual([ + 'static-tag', + 'static-tag', + 'static', + 'dynamic-tag', + 'dynamic-tag', + 'dynamic', + ]); // The generated dispatcher returns on the first regex that matches, so // the exact handler must be the first one that matches `support+vip`. const first = scan.addresses.find((a) => @@ -299,13 +257,6 @@ describe('compileEmailPattern', () => { }); }); -describe('defineEmail', () => { - it('is an identity helper that preserves the handler', () => { - const handler = defineEmail(async () => {}); - expect(typeof handler).toBe('function'); - }); -}); - describe('parseEmail', () => { it('parses a minimal RFC 5322 message via postal-mime', async () => { const raw = @@ -330,152 +281,56 @@ describe('parseEmail', () => { expect(parsed.from?.address).toBe('a@x.dev'); expect(parsed.text?.trim()).toBe('Hi there'); }); - - it('rejects messages over the size cap before reading the stream', async () => { - const message = { - from: 'a@x.dev', - to: 'b@y.dev', - rawSize: 11 * 1024 * 1024, - raw: new ReadableStream(), - } as unknown as ForwardableEmailMessage; - await expect(parseEmail(message)).rejects.toThrow(/exceeds maximum/); - }); }); -describe('compileRouteTable (email)', () => { - const baseScan = { - routes: [], - middleware: [], - jobs: [], - queues: [], - pages: { pages: [], layouts: [], namedLayouts: [] }, - }; - // `emailDir` rides on `CompileOptions` rather than `compileRouteTable`'s - // positional list — see the note on the field in router/compile.ts. - const baseOptions = { - honoPath: 'hono', - - runtimeEnvPath: 'void/_env', - projectRoot: '/project', - homeDir: '/home/test', - reactRefresh: false, - auth: null, - nodeTarget: false, - emailDir: '/e', - }; +const COMPILE_OPTIONS = { + honoPath: 'hono', + runtimeEnvPath: 'void/_env', + projectRoot: '/project', + homeDir: '/home/test', + reactRefresh: false, + auth: null, + nodeTarget: false, + emailDir: '/e', +}; + +/** A route table whose only content is `email`, with handlers under `/e`. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function compile(email: any): string { + return compileRouteTable( + { + routes: [], + middleware: [], + jobs: [], + queues: [], + pages: { pages: [], layouts: [], namedLayouts: [] }, + email, + }, + '/r', + '/m', + '/j', + '/q', + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + COMPILE_OPTIONS as any, + ); +} +describe('compileRouteTable (email)', () => { it('emits __emailHandler with _default only', () => { - const code = compileRouteTable( - { ...baseScan, email: { addresses: [], defaultHandler: '_default.ts' } }, - '/r', - '/m', - '/j', - '/q', - null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - baseOptions as any, - ); + const code = compile({ addresses: [], defaultHandler: '_default.ts' }); expect(code).toContain('export async function __emailHandler'); expect(code).toContain('"/e/_default.ts"'); expect(code).toContain('__emailDefault'); }); - it('emits per-address dispatch via __emailPatterns regex table', () => { - const code = compileRouteTable( - { - ...baseScan, - email: { - addresses: [ - { - address: 'support', - filePath: 'support.ts', - pattern: { kind: 'static', regexSource: '^support$', paramNames: [] }, - }, - { - address: 'billing', - filePath: 'billing.ts', - pattern: { kind: 'static', regexSource: '^billing$', paramNames: [] }, - }, - ], - }, - }, - '/r', - '/m', - '/j', - '/q', - null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - baseOptions as any, - ); - expect(code).toContain('__emailPatterns'); - expect(code).toContain('^support$'); - expect(code).toContain('^billing$'); - expect(code).toContain('"/e/support.ts"'); - expect(code).toContain('"/e/billing.ts"'); - expect(code).toContain('mod.default(message, env, ctx, { params'); - }); - - it('emits param extraction for tagged + dynamic patterns', () => { - const code = compileRouteTable( - { - ...baseScan, - email: { - addresses: [ - { - address: 'support+[ticket]', - filePath: 'support+[ticket].ts', - pattern: { - kind: 'static-tag', - regexSource: '^support\\+([^+@]+)$', - paramNames: ['ticket'], - }, - }, - { - address: '[user]', - filePath: '[user].ts', - pattern: { kind: 'dynamic', regexSource: '^([^+@]+)$', paramNames: ['user'] }, - }, - ], - }, - }, - '/r', - '/m', - '/j', - '/q', - null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - baseOptions as any, - ); - expect(code).toContain('"ticket"'); - expect(code).toContain('"user"'); - expect(code).toContain('^support\\\\+([^+@]+)$'); - expect(code).toContain('^([^+@]+)$'); - }); - it('omits the email block entirely when no handlers exist', () => { - const code = compileRouteTable( - { ...baseScan, email: { addresses: [] } }, - '/r', - '/m', - '/j', - '/q', - null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - baseOptions as any, - ); + const code = compile({ addresses: [] }); expect(code).not.toContain('__emailHandler'); }); }); -/** - * Executes the GENERATED `__emailHandler` instead of asserting on its source. - * - * Every other codegen test here is string containment, which is exactly why the - * double slug strip survived: `createMessageProxy` removed the `+` prefix - * from `message.to`, the dispatcher removed it a second time, and the resulting - * empty tag matched no pattern. Both halves were individually "correct" and the - * seam between them was untested. - */ +/** Executes the generated `__emailHandler` instead of asserting on its source. */ function buildEmailDispatcher( code: string, modules: Record, @@ -497,41 +352,6 @@ function buildEmailDispatcher( } describe('generated __emailHandler dispatch (behavioural)', () => { - const baseScan = { - routes: [], - middleware: [], - jobs: [], - queues: [], - pages: { pages: [], layouts: [], namedLayouts: [] }, - }; - - const baseOptions = { - honoPath: 'hono', - - runtimeEnvPath: 'void/_env', - projectRoot: '/project', - homeDir: '/home/test', - reactRefresh: false, - auth: null, - nodeTarget: false, - emailDir: '/e', - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function compile(email: any): string { - return compileRouteTable( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { ...baseScan, email } as any, - '/r', - '/m', - '/j', - '/q', - null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - baseOptions as any, - ); - } - const SUPPORT = { addresses: [ { @@ -1049,23 +869,6 @@ describe('createInboundTestHarness', () => { expect(exact).toHaveBeenCalledTimes(1); }); - it('prefers [user]+vip over [user]+[tag]', async () => { - const exact = vi.fn(async () => {}); - const captured = vi.fn(async () => {}); - const harness = createInboundTestHarness({ - routes: { '[user]+[tag]': captured, '[user]+vip': exact }, - }); - - const vip = await harness.deliver({ from: 'u@x.dev', to: 'myslug+alice+vip@app.dev' }); - expect(vip.handler).toBe('[user]+vip'); - expect(vip.params).toEqual({ user: 'alice' }); - expect(captured).not.toHaveBeenCalled(); - - const other = await harness.deliver({ from: 'u@x.dev', to: 'myslug+alice+welcome@app.dev' }); - expect(other.handler).toBe('[user]+[tag]'); - expect(other.params).toEqual({ user: 'alice', tag: 'welcome' }); - }); - it('falls through to _default when no per-address pattern matches', async () => { const def = vi.fn(async () => {}); const harness = createInboundTestHarness({ routes: { _default: def } }); @@ -1090,46 +893,6 @@ describe('createInboundTestHarness', () => { expect(result.handler).toBeUndefined(); }); - it('walks back forward/reply when setReject fires later in the same dispatch', async () => { - // Production: setReject is terminal — forward/reply queued before it - // never execute on the real ForwardableEmailMessage. The harness - // mirrors that by popping the prior entries off the public arrays so - // assertions match what the real router would actually enact. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - const handler = defineEmail(async (message) => { - await message.forward('ops@x.dev'); - message.setReject('not allowed'); - }); - const harness = createInboundTestHarness({ routes: { _default: handler } }); - await harness.deliver({ from: 'u@x.dev', to: 'anything@app.dev' }); - expect(harness.rejects).toEqual(['not allowed']); - expect(harness.forwards).toEqual([]); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toContain('forward/reply'); - } finally { - warn.mockRestore(); - } - }); - - it('setReject is terminal: forward/reply after setReject are dropped', async () => { - // Production's createMessageProxy short-circuits forward/reply once - // setReject has fired (warns + returns). The harness must mirror - // that — without it, a buggy handler that calls setReject then - // forward passes the harness tests but in production CF only - // honors the reject and silently discards the forward. - const handler = defineEmail(async (message) => { - message.setReject('not allowed'); - await message.forward('ops@x.dev'); - await message.reply({ from: 'support@app.dev', to: 'u@x.dev' } as never); - }); - const harness = createInboundTestHarness({ routes: { _default: handler } }); - await harness.deliver({ from: 'u@x.dev', to: 'anything@app.dev' }); - expect(harness.rejects).toEqual(['not allowed']); - expect(harness.forwards).toEqual([]); - expect(harness.replies).toEqual([]); - }); - it('setReject is idempotent — subsequent setReject calls are dropped', async () => { const handler = defineEmail(async (message) => { message.setReject('first reason'); @@ -1153,11 +916,8 @@ describe('createInboundTestHarness', () => { expect(result.handler).toBe('support'); }); - // The harness is the only thing a user tests their handler against, so a - // captured tag has to arrive with the case production would give it. The - // codegen twin of this assertion is 'captures the tag parameter from the - // wire recipient, not the stripped one' above; the two must agree or a - // green test suite says nothing about the deployed behavior. + // The codegen twin is 'captures the tag parameter from the wire recipient, + // not the stripped one'; the two must agree. it('preserves the case of a captured tag, like the generated dispatcher', async () => { let seen: unknown; const harness = createInboundTestHarness({ @@ -1182,13 +942,8 @@ describe('createInboundTestHarness', () => { }); describe('harness enforces the proxy action bounds, like production', () => { - // The handler under test runs against production's own `createMessageProxy` - // in both lanes (the generated `email()` entry builds it from the envelope), - // and that proxy is where the queue cap, the address cap, the header-record - // check and the reply bounds throw. A harness that recorded every call - // instead passed a handler that fails on the 101st forward after deploy — - // fourth divergence of this shape on this feature. These pin that the - // harness throws the SAME error (class, code, message) at the same call. + // The handler runs against production's own `createMessageProxy`, so the + // harness throws the same error (class, code, message) at the same call. function harnessFor(handler: Parameters[0]) { return createInboundTestHarness({ routes: { _default: defineEmail(handler) } }); } @@ -1216,106 +971,6 @@ describe('harness enforces the proxy action bounds, like production', () => { expect(harness.forwards[99]).toEqual({ to: 'box99@example.com' }); }); - it('fails the delivery with that error when the handler does not catch it', async () => { - // Production enacts nothing from a handler that throws: the platform - // router rejects the message as a handler error, and on your own account - // the invocation fails before `enactEmailActions` runs. - const harness = harnessFor(async (message) => { - for (let i = 0; i <= 100; i++) { - await message.forward(`box${i}@example.com`); - } - }); - await expect(harness.deliver(DELIVERY)).rejects.toThrow(/handler queued more than 100 actions/); - expect(harness.forwards).toEqual([]); - expect(harness.deliveries).toEqual([]); - }); - - it('refuses a forward address over the address-length limit', async () => { - let thrown: unknown; - const over = `${'a'.repeat(320)}@example.com`; - const harness = harnessFor(async (message) => { - try { - await message.forward(over); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('INVALID_TO'); - expect((thrown as MimeError).message).toBe( - `to: address is ${over.length} characters, over the 320-character limit.`, - ); - expect(harness.forwards).toEqual([]); - }); - - it('refuses a forward address over the 254-character path limit, by the sendEmail rule', async () => { - // Under the 320 bound above, so the refusal is `email-validator`'s 254 - // check — the rule an outbound `to` gets. - let thrown: unknown; - const label = 'b'.repeat(63); - const over = `a@${[label, label, label, label].join('.')}`; - expect(over).toHaveLength(257); - const harness = harnessFor(async (message) => { - try { - await message.forward(over); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('INVALID_TO'); - expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); - expect(harness.forwards).toEqual([]); - }); - - it('refuses a forward address carrying a control character, by the sendEmail rule', async () => { - // The platform router's `isValidEmailAddress` refuses `\u0000-\u001f` - // and `\u007f`, and used to be the first thing that did: `forward()` - // recorded the address, the harness captured it as a success, and the - // router dropped just that forward after earlier actions had fired. - // Both sides now apply `email-validator`; the message names the rule - // and never echoes the byte. - let thrown: unknown; - const harness = harnessFor(async (message) => { - try { - await message.forward('a\x01b@example.com'); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('INVALID_TO'); - expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); - expect((thrown as MimeError).message).not.toContain('\x01'); - expect(harness.forwards).toEqual([]); - }); - - it('refuses a forward address carrying a colon, by the sendEmail rule', async () => { - // Same class as the control character above: `:` is an RFC 5322 - // §3.2.3 special the platform router's `isValidEmailAddress` refuses, - // and it was the one missing from the framework's address class — - // `forward('a:b@example.com')` was recorded, the harness captured it as - // a success, and the router dropped just that forward after the one - // before it had fired. - let thrown: unknown; - const harness = harnessFor(async (message) => { - await message.forward('first@example.com'); - try { - await message.forward('a:b@example.com'); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('INVALID_TO'); - expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); - expect(harness.forwards).toEqual([{ to: 'first@example.com' }]); - }); - it('refuses a forward header record with a CRLF in a value at the call, recording nothing for it', async () => { let thrown: unknown; const harness = harnessFor(async (message) => { @@ -1343,112 +998,6 @@ describe('harness enforces the proxy action bounds, like production', () => { expect(harness.forwards[1].headers?.get('X-Forwarded-By')).toBe('void-test'); }); - it('refuses a malformed forward address at the call, recording nothing for it', async () => { - // A harness that captured `Ops ` as a forward passed a - // handler whose deploy fails at native replay (Cloudflare) or loses the - // forward at the router (platform). Same error as `sendEmail`'s `to`. - let thrown: unknown; - const harness = harnessFor(async (message) => { - await message.forward('first@example.com'); - try { - await message.forward('Ops '); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('INVALID_TO'); - expect((thrown as MimeError).message).toBe(`to: ${INVALID_ADDRESS}`); - expect(harness.forwards).toEqual([{ to: 'first@example.com' }]); - }); - - it("refuses a forward header bag over the router's bounds at the call, recording nothing for it", async () => { - // `new Headers()` accepts 33 headers, so the record-validity check - // passes them, and the managed router drops the WHOLE action list on - // the count (`forwardHeadersWithinBounds`). A harness that reported - // this forward passed a handler whose managed deployment silently - // performed no actions. - let thrown: unknown; - const harness = harnessFor(async (message) => { - await message.forward('first@example.com'); - const bag = new Headers(); - for (let i = 0; i < 33; i++) { - bag.set(`X-Void-${i}`, 'v'); - } - try { - await message.forward('archive@example.com', bag); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('MIME_ERROR'); - expect((thrown as MimeError).message).toBe('forward: 33 headers, over the 32-header limit.'); - expect(harness.forwards).toEqual([{ to: 'first@example.com' }]); - }); - - it('reports the reject reason the router sends: sliced to 1000 and stripped of controls', async () => { - // The parser unfolds the encoded CRLF, but the decoded subject still - // contains a NUL. The harness must report the bounded, normalized reason - // that the managed router and native replay send to the SMTP client. - const harness = harnessFor(async (message) => { - const parsed = await parseEmail(message); - message.setReject(`unwanted: ${parsed.subject}${'!'.repeat(2000)}`); - }); - await harness.deliver({ - ...DELIVERY, - raw: - 'From: u@x.dev\r\n' + - 'To: myslug+anything@app.dev\r\n' + - 'Subject: =?UTF-8?Q?spam=0D=0ABcc:_x=00?=\r\n' + - '\r\n' + - 'body\r\n', - }); - expect(harness.rejects).toHaveLength(1); - // "unwanted: spam Bcc: x\0" is 22 characters after parsing. The window - // holds 978 "!", and normalizing the NUL to a space preserves its length. - expect(harness.rejects[0]).toBe(`unwanted: spam Bcc: x ${'!'.repeat(978)}`); - expect(harness.rejects[0]).toHaveLength(1000); - }); - - it('reply() refuses a native EmailMessage with the proxy error', async () => { - let thrown: unknown; - const native = Object.freeze({ from: 'support@app.dev', to: 'u@x.dev' }); - const harness = harnessFor(async (message) => { - try { - await message.reply(native as unknown as Parameters[0]); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).code).toBe('MIME_ERROR'); - expect((thrown as MimeError).message).toMatch(/native EmailMessage cannot be replayed/); - expect(harness.replies).toEqual([]); - }); - - it('reply() refuses a Uint8Array raw over MAX_MESSAGE_BYTES', async () => { - let thrown: unknown; - const harness = harnessFor(async (message) => { - try { - await message.reply({ - from: 'support@app.dev', - to: 'u@x.dev', - raw: new Uint8Array(MAX_MESSAGE_BYTES + 1), - } as unknown as Parameters[0]); - } catch (e) { - thrown = e; - } - }); - await harness.deliver(DELIVERY); - expect(thrown).toBeInstanceOf(MimeError); - expect((thrown as MimeError).message).toMatch(/exceeds Void's sending limit/); - expect(harness.replies).toEqual([]); - }); - it('records nothing from a handler that throws, like production', async () => { const harness = harnessFor(async (message) => { await message.forward('ops@x.dev'); @@ -1462,10 +1011,7 @@ describe('harness enforces the proxy action bounds, like production', () => { describe('harness rejects an unmatched delivery, like production', () => { // The generated dispatcher calls setReject when nothing matches and there - // is no _default, so the sender gets an NDR. The harness used to return - // silently, so a test for catch-all or anti-spam behavior could observe an - // ACCEPTED message where deployment bounces it. Same divergence class as - // the captured-tag casing bug: the harness has to mirror the dispatcher. + // is no _default, so the sender gets an NDR; the harness mirrors it. it('records the reject when nothing matches and there is no _default', async () => { const harness = createInboundTestHarness({ routes: { 'support+[ticket]': async () => {} } }); @@ -1544,13 +1090,7 @@ describe('harness rejects an unmatched delivery, like production', () => { describe('harness headers match the bytes, like production', () => { // In production `buildEnvelope` copies every wire header verbatim, so - // `message.headers` and `message.raw` agree by construction. The harness - // rebuilt `headers` from `input` alone, so its own synthetic MIME carried - // a Message-ID that its own `headers` did not expose — and `replyEmail` - // reads exactly that header, so a harness reply came out UNTHREADED while - // the same handler threaded correctly in production. Third divergence of - // this shape on this feature, after captured-param casing and the - // unmatched-delivery reject. + // `message.headers` and `message.raw` agree, and `replyEmail` threads off them. it('exposes the generated Message-ID, so replyEmail threads', async () => { let seen: string | null = null; const harness = createInboundTestHarness({ @@ -1567,9 +1107,7 @@ describe('harness headers match the bytes, like production', () => { }); it('exposes headers from a caller-supplied raw MIME', async () => { - // The `raw:` override hands the harness exact wire bytes. Ignoring their - // headers is the sharpest form of the divergence: the caller supplied - // precisely what production would receive. + // The `raw:` override hands the harness exact wire bytes. const raw = [ 'Message-ID: ', 'From: u@x.dev', @@ -1640,12 +1178,8 @@ describe('harness headers match the bytes, like production', () => { describe('admission checks mirrored from the managed router', () => { // `routeInbound` refuses a message over `MAX_INBOUND_BYTES` (from - // `rawSize`, before the body is read) or with more than - // `MAX_INBOUND_HEADERS` distinct names (in `buildEnvelope`) and never - // dispatches it. The harness invoked the handler regardless, so a handler - // that forwards an oversized message without calling `parseEmail()` was - // green here and bounced after deploy. Now: same order, same reasons, no - // handler call. Platform lane only — an own-account worker has no router. + // `rawSize`) or with more than `MAX_INBOUND_HEADERS` distinct names before + // dispatch. Platform lane only — an own-account worker has no router. const TO = 'myslug+support@app.dev'; const HEAD = `From: u@x.dev\r\nTo: ${TO}\r\nSubject: big\r\n\r\n`; @@ -1861,12 +1395,58 @@ describe('admission checks mirrored from the managed router', () => { }); }); +/** + * Builds a reply in a child Node under the tenant isolate's 128 MB heap cap + * and reports what it cost there. `headersSource` is a JS expression + * evaluated in the child, so the fixture is built inside the cap too. + */ +function replyInIsolate(headersSource: string): { + text: string; + subject: string; + ms: number; + heapDeltaMB: number; +} { + const inboundModule = pathToFileURL( + toSlash(join(import.meta.dirname, '../../src/runtime/email/inbound.ts')), + ).href; + const child = ` + const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); + const headers = ${headersSource}; + const raw = new TextEncoder().encode('body'); + const { message } = createMessageProxy( + { + from: 'sender@remote.dev', + to: 'acme+support@mail.void.cloud', + headers, + raw, + rawSize: raw.length, + }, + { slug: 'acme' }, + ); + const heap0 = process.memoryUsage().heapUsed; + const t0 = performance.now(); + const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); + const ms = performance.now() - t0; + const heapDeltaMB = (process.memoryUsage().heapUsed - heap0) / (1024 * 1024); + const text = new TextDecoder().decode(reply.raw); + console.log(JSON.stringify({ text, subject: reply.subject, ms, heapDeltaMB })); + `; + const stdout = execFileSync( + process.execPath, + ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + ); + return JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { + text: string; + subject: string; + ms: number; + heapDeltaMB: number; + }; +} + describe('reply threading survives a hostile inbound header', () => { - // `buildReplyMessage` threads the inbound `Message-ID` and `References` - // into the reply verbatim, and those are controlled by whoever sent the - // mail. The 998-octet line guard would therefore let a remote sender make - // an app's reply throw, with the app at fault for nothing. Threading is - // best-effort, so an unusable value is dropped and the reply still goes. + // A remote sender controls the threaded `Message-ID` and `References`. + // Threading is best-effort: an unusable value is dropped, the reply still goes. const HUGE = 'x'.repeat(1100); function inbound(headers: Record) { @@ -1894,52 +1474,8 @@ describe('reply threading survives a hostile inbound header', () => { expect(Math.max(...text.split('\r\n').map((l) => l.length))).toBeLessThanOrEqual(998); }); - it('survives a References chain with more tokens than Math.max takes arguments', async () => { - // ~123k spread arguments is where V8 throws RangeError (measured on - // workerd and node). 130k short ids is 780 KB, far inside - // MAX_MESSAGE_BYTES, so a remote sender can reach it. - const chain = Array.from({ length: 130_000 }, () => '').join(' '); - const message = inbound({ references: chain, 'message-id': '', subject: 'hi' }); - - const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); - - const text = new TextDecoder().decode(reply.raw); - expect(text).toContain('In-Reply-To: '); - expect(Math.max(...text.split('\r\n').map((l) => l.length))).toBeLessThanOrEqual(998); - }); - - it('survives a Subject with more tokens than Math.max takes arguments', async () => { - // The cheaper trigger: `prefixReplySubject` collapses whitespace runs, so - // 130k single-character tokens is only 260 KB of ASCII. - const subject = Array.from({ length: 130_000 }, () => 'a').join(' '); - const message = inbound({ subject }); - - const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); - - const text = new TextDecoder().decode(reply.raw); - expect(Math.max(...text.split('\r\n').map((l) => l.length))).toBeLessThanOrEqual(998); - }); - - it('drops an unusable References chain rather than failing the reply', async () => { - const message = inbound({ references: `<${HUGE}@remote.dev>`, subject: 'hi' }); - - const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); - - expect( - Math.max( - ...new TextDecoder() - .decode(reply.raw) - .split('\r\n') - .map((l) => l.length), - ), - ).toBeLessThanOrEqual(998); - }); - - // The line guard measures octets, the way the wire does, and the only - // break it may count on is SP or HTAB — the whitespace `foldHeader` folds - // at. The chain is carried when every id fits and dropped for the bare - // message-id otherwise, and the answer must arrive in one pass with no - // per-id allocation: a remote sender can put 10 MiB of ids in one header. + // The line guard measures octets, and the only break it may count on is + // SP or HTAB — the whitespace `foldHeader` folds at. const MESSAGE_ID = ''; async function replyText(references: string, messageId = MESSAGE_ID): Promise { @@ -1955,10 +1491,7 @@ describe('reply threading survives a hostile inbound header', () => { it('keeps an id that exactly fills the line, and drops one octet more', async () => { // `References: ` is 12 octets, so 986 is the widest id one line carries. - // With no Message-ID to append, the header is that one id, unfolded — - // the only shape where the guard's budget is the wire line exactly. In a - // chain every id is charged the same 12 octets although a folded - // continuation costs 1, so the guard is conservative there, never loose. + // With no Message-ID to append, the header is that one id, unfolded. const atBudget = `<${'x'.repeat(984)}>`; const overBudget = `<${'x'.repeat(985)}>`; @@ -2010,8 +1543,7 @@ describe('reply threading survives a hostile inbound header', () => { it('does not count whitespace the folder cannot fold at as a break', async () => { // `\s` matches NBSP and FF, but RFC 5322 §3.2.2 folds only at SP/HTAB and - // so does `foldHeader`. Splitting at either used to pass two fitting ids - // that then landed on one 1222-octet line, and `buildMime` threw. + // so does `foldHeader`. const id = `<${'x'.repeat(600)}@remote.dev>`; for (const separator of [' ', '\f']) { const text = await replyText(`${id}${separator}${id}`); @@ -2021,42 +1553,22 @@ describe('reply threading survives a hostile inbound header', () => { } }); - it('threads a chain at the raw cap’s scale, and every line still fits', async () => { - // 9 MiB of two-character ids is 3.1M tokens. The reply carries its newest - // 8 KiB, and the answer must not cost per token: a per-token allocation - // here was 34 bytes each, which is the isolate. - const chain = 'ab '.repeat((9 * 1024 * 1024) / 3).trimEnd(); - const text = await replyText(chain); - expect(text).toContain(`In-Reply-To: ${MESSAGE_ID}`); - expect(text).toContain('References: ab ab ab'); - expect(text).toContain(` ab ${MESSAGE_ID}`); - expect(widestLineOctets(text)).toBeLessThanOrEqual(998); - }); - - it('answers for a 10 MiB chain in one pass', async () => { - // The largest header a remote sender can deliver: MAX_MESSAGE_BYTES of - // two-character ids (3.5M tokens), with one over-long id at the very end - // so the whole chain is scanned before it is refused. `split(/\s+/)` - // materialized 3.5M tokens first — measured at 570 ms and 147 MB, which - // terminates a 128 MB isolate; the one-pass scan is 20 ms and 0 bytes. + it('answers for a chain of MAX_MESSAGE_BYTES in one pass', async () => { + // Two-character ids with one over-long id at the very end, so the whole + // chain is scanned before it is refused. const chain = 'ab '.repeat(Math.floor(MAX_MESSAGE_BYTES / 3)) + `<${HUGE}@remote.dev>`; const started = performance.now(); const text = await replyText(chain); const elapsed = performance.now() - started; expect(text).not.toContain(HUGE); expect(text).toContain(`References: ${MESSAGE_ID}`); - // 34 ms here; a loaded 4-vCPU CI runner has measured 6x slower on this - // file. The materializing form took 570 ms locally and would not fit a - // 128 MB isolate at all — the memory is the real guard, the time a proxy. + // A gross bound: time stands in for the memory the runner cannot cap. expect(elapsed).toBeLessThan(1000); }); - // The line guard bounds one id, not the chain: 10 MiB of short ids passes - // it — every id folds — and the folded reply is then over - // `MAX_MESSAGE_BYTES`, so `buildMime` threw instead of sending the reply the - // guide promises, and the handler's earlier forwards went with it. A chain - // is bounded at 8192 characters, checked on `length` alone, and past that - // the reply keeps the NEWEST ids whole, the parent's id still last. + // The line guard bounds one id, not the chain, so the chain is bounded at + // 8192 characters; past that the reply keeps the newest ids whole, the + // parent's id still last. const REFERENCES_BUDGET = 8192; /** The `References` value of a rendered reply, unfolded; `undefined` when absent. */ @@ -2069,43 +1581,11 @@ describe('reply threading survives a hostile inbound header', () => { } it('threads the newest ids of a chain over the budget, inside a 128 MB heap', () => { - // The reviewer's shape: a `References` just under the inbound cap, of - // short ids. Measured before the bound: 156 ms, 55 MB of heap, then - // `MimeError: message size 10777129 bytes exceeds Cloudflare limit`. - const inboundModule = pathToFileURL( - toSlash(join(import.meta.dirname, '../../src/runtime/email/inbound.ts')), - ).href; - const child = ` - const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); - const references = ' '.repeat(Math.floor((${MAX_MESSAGE_BYTES} - 200) / 6)).trimEnd(); - const raw = new TextEncoder().encode('body'); - const { message } = createMessageProxy( - { - from: 'sender@remote.dev', - to: 'acme+support@mail.void.cloud', - headers: { references, 'message-id': ${JSON.stringify(MESSAGE_ID)}, subject: 'hi' }, - raw, - rawSize: raw.length, - }, - { slug: 'acme' }, - ); - const heap0 = process.memoryUsage().heapUsed; - const t0 = performance.now(); - const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); - const ms = performance.now() - t0; - const heapDeltaMB = (process.memoryUsage().heapUsed - heap0) / (1024 * 1024); - console.log(JSON.stringify({ text: new TextDecoder().decode(reply.raw), ms, heapDeltaMB })); - `; - const stdout = execFileSync( - process.execPath, - ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + // A `References` just under the inbound cap, of short ids. + const result = replyInIsolate( + `{ references: ' '.repeat(Math.floor((${MAX_MESSAGE_BYTES} - 200) / 6)).trimEnd(), ` + + `'message-id': ${JSON.stringify(MESSAGE_ID)}, subject: 'hi' }`, ); - const result = JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { - text: string; - ms: number; - heapDeltaMB: number; - }; expect(result.text).toContain(`In-Reply-To: ${MESSAGE_ID}`); const references = referencesOf(result.text); expect(references).toBeDefined(); @@ -2113,7 +1593,7 @@ describe('reply threading survives a hostile inbound header', () => { expect(references!.startsWith(' ')).toBe(true); expect(references!.endsWith(` ${MESSAGE_ID}`)).toBe(true); expect(widestLineOctets(result.text)).toBeLessThanOrEqual(998); - // 4 ms and 0 MB measured; the bound leaves room for CI's slower runner. + // The bound leaves room for CI's slower runner. expect(result.ms).toBeLessThan(5000); expect(result.heapDeltaMB).toBeLessThan(16); }, 30_000); @@ -2165,7 +1645,7 @@ describe('reply threading survives a hostile inbound header', () => { it('threads the newest ids when the id no line can carry is an old one', async () => { const ids = Array.from({ length: 1000 }, (_, i) => `<${String(i).padStart(4, '0')}@r>`); - // Oldest: outside the tail, so the tail threads. Every id used to go. + // Oldest: outside the tail, so the tail threads. const oldestHuge = referencesOf(await replyText(`<${HUGE}@r> ${ids.join(' ')}`)); expect(oldestHuge).not.toContain(HUGE); expect(oldestHuge!.endsWith(` <0999@r> ${MESSAGE_ID}`)).toBe(true); @@ -2175,10 +1655,8 @@ describe('reply threading survives a hostile inbound header', () => { }); it('keeps the actions queued before a reply to a chain over the budget', async () => { - // What the throw cost: `handleEmail` returns the queue only once the - // handler resolves, so a forward recorded before the reply was lost with - // it — bounced as a "project handler error" on the platform, never - // enacted on a native message. + // `handleEmail` returns the queue only once the handler resolves, so a + // reply that threw would take the earlier forward with it. const references = ' '.repeat(Math.floor((MAX_MESSAGE_BYTES - 200) / 6)).trimEnd(); const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); const { message, getActions } = createMessageProxy( @@ -2207,25 +1685,9 @@ describe('reply threading survives a hostile inbound header', () => { expect(new TextDecoder().decode(reply.raw)).toContain(`In-Reply-To: ${MESSAGE_ID}`); }); - it('still threads a normal Message-ID', async () => { - const message = inbound({ 'message-id': '', subject: 'hi' }); - - const reply = await buildReplyMessage(message, { text: 'thanks', from: 'acme+noreply@x.dev' }); - - const text = new TextDecoder().decode(reply.raw); - expect(text).toContain('In-Reply-To: '); - expect(text).toContain('References: '); - }); - describe('a control character in a threading id', () => { - // A threading value is emitted verbatim, and `\x01` or DEL pass a WHATWG - // `Headers` (which refuses only CR, LF and NUL), so a remote sender's - // `Message-ID: ` reached the reply's `In-Reply-To` - // and `References` lines raw. An RFC 5322 §3.6.4 `msg-id` admits no - // control, so such an id is unrepresentable — dropped, as an over-long - // one is, never a throw: the reply is still sent. Unlike the line - // guard, which is about the rendered line, this is about one token, so - // a bad id in the middle of a chain loses only itself. + // `\x01` or DEL pass a WHATWG `Headers`, but an RFC 5322 §3.6.4 `msg-id` + // admits no control: such an id is dropped on its own, never a throw. // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point const rawControl = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/; @@ -2256,22 +1718,12 @@ describe('reply threading survives a hostile inbound header', () => { expect(headersOf(text)).not.toMatch(rawControl); } }); - - it('still threads a clean chain and parent exactly as before', async () => { - const text = await replyText(' '); - expect(text).toContain(`In-Reply-To: ${MESSAGE_ID}\r\n`); - expect(referencesOf(text)).toBe(` ${MESSAGE_ID}`); - }); }); }); describe('reply subject survives a hostile inbound header', () => { - // The default reply subject is decoded from the inbound `Subject`, which - // the remote sender controls. `normalizeEmail` rejects CR, LF and NUL and - // `buildMime` rejects any line over 998 octets, so a subject carrying - // either would make the app's reply throw — and in production that - // rejects the whole delivery, replies AND forwards. Like threading, the - // subject is best-effort: repair what can be repaired, fall back to + // The default reply subject is decoded from the sender's `Subject`. Like + // threading it is best-effort: repair what can be repaired, fall back to // `Re:` for what cannot, and always send the reply. const FROM = 'acme+noreply@x.dev'; @@ -2298,9 +1750,7 @@ describe('reply subject survives a hostile inbound header', () => { ); } - // The reviewer's case. `parseInt('0G', 16)` is 0, not NaN, so the old - // predicate synthesised a NUL from a malformed escape. RFC 2045 §6.7 says - // a robust decoder keeps the `=` and the following characters as-is. + // RFC 2045 §6.7: a robust decoder keeps a malformed `=` escape as-is. it('keeps a malformed Q escape literal instead of decoding half of it', async () => { const reply = await buildReplyMessage(inbound('=?UTF-8?Q?bad=0Gsubject?='), { text: 'thanks', @@ -2346,65 +1796,24 @@ describe('reply subject survives a hostile inbound header', () => { expect(reply.subject).toBe('Re: good subject'); }); - it('decodes a subject of 750k encoded-words one word at a time', async () => { - // MAX_MESSAGE_BYTES of `=?UTF-8?Q?a?=` is the largest subject a remote - // sender can deliver. `replace` with a callback collected all 750k - // matches before decoding the first — measured at 138 MB on the whole - // reply path, which is the isolate; the lazy scan needs 35 MB. The - // runner's heap is not capped, so this pins the behaviour: adjacent - // encoded-words join, the decoded subject is one 750k-character ASCII - // token no line can carry, and the reply falls back to a bare `Re:`. + it('decodes a subject of MAX_MESSAGE_BYTES of encoded-words one word at a time', async () => { + // MAX_MESSAGE_BYTES of `=?UTF-8?Q?a?=`: adjacent encoded-words join into + // one ASCII token no line can carry, so the reply falls back to `Re:`. const subject = '=?UTF-8?Q?a?= '.repeat(Math.floor(MAX_MESSAGE_BYTES / 14)); const reply = await buildReplyMessage(inbound(subject), { text: 'thanks', from: FROM }); expect(reply.subject).toBe('Re:'); }); - // The other shape of the same header: ONE encoded-word carrying the whole - // 10 MiB. The payload pattern accepts whitespace, so a folded `Subject` - // arrives (unfolded by `Headers`) as a single Q-word and the decoder sees - // every character of it. Pushing each byte onto an `Array` first - // measured 199 MB of heap for the decode alone and 330 MB peak on the reply - // path — `FATAL ERROR: Reached heap limit` under `--max-old-space-size=128`, - // which is the tenant isolate — before `buildMime`'s projection could - // refuse the subject. Decoding into a preallocated `Uint8Array` peaks at - // 10.5 MB on the same path (42 ms). The runner's own heap is not capped, - // so the pin runs the reply in a child with the isolate's cap. - it('replies to a 10 MiB single Q-encoded word inside a 128 MB heap', () => { - const inboundModule = pathToFileURL( - toSlash(join(import.meta.dirname, '../../src/runtime/email/inbound.ts')), - ).href; - const child = ` - const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); - const subject = '=?UTF-8?Q?' + 'a'.repeat(${MAX_MESSAGE_BYTES} - 20) + '?='; - const raw = new TextEncoder().encode('body'); - const { message } = createMessageProxy( - { - from: 'sender@remote.dev', - to: 'acme+support@mail.void.cloud', - headers: { subject }, - raw, - rawSize: raw.length, - }, - { slug: 'acme' }, - ); - const t0 = performance.now(); - const reply = await buildReplyMessage(message, { text: 'thanks', from: ${JSON.stringify(FROM)} }); - console.log(JSON.stringify({ subject: reply.subject, ms: performance.now() - t0 })); - `; - const stdout = execFileSync( - process.execPath, - ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, + // ONE encoded-word carrying the whole message: a folded `Subject` arrives + // (unfolded by `Headers`) as a single Q-word, so the decoder sees all of it. + it('replies to a single Q-encoded word of MAX_MESSAGE_BYTES inside a 128 MB heap', () => { + const result = replyInIsolate( + `{ subject: '=?UTF-8?Q?' + 'a'.repeat(${MAX_MESSAGE_BYTES} - 20) + '?=' }`, ); - const result = JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { - subject: string; - ms: number; - }; - // The decoded word is one 10 MiB ASCII token no line can carry, so the - // subject falls back — the same answer the 750k-word shape gets. + // The decoded word is one ASCII token no line can carry, so the subject + // falls back — the same answer the many-word shape gets. expect(result.subject).toBe('Re:'); - // 42 ms measured locally; the bound leaves room for CI's slower runner - // (about 6x), where the old `Array` shape did not finish at all. + // The bound leaves room for CI's slower runner. expect(result.ms).toBeLessThan(5000); }, 30_000); @@ -2438,64 +1847,10 @@ describe('reply subject survives a hostile inbound header', () => { expect(reply.subject).toBe(expected); }); - /** - * Build the reply in a child Node under the tenant isolate's 128 MB heap - * cap and report what it cost there. `subjectSource` is a JS expression - * evaluated in the child, so the fixture is built inside the cap too. - */ - function replyInIsolate(subjectSource: string): { - subject: string; - ms: number; - heapDeltaMB: number; - } { - const inboundModule = pathToFileURL( - join(import.meta.dirname, '../../src/runtime/email/inbound.ts'), - ).href; - const child = ` - const { buildReplyMessage, createMessageProxy } = await import(process.argv[1]); - const subject = ${subjectSource}; - const raw = new TextEncoder().encode('body'); - const { message } = createMessageProxy( - { - from: 'sender@remote.dev', - to: 'acme+support@mail.void.cloud', - headers: { subject }, - raw, - rawSize: raw.length, - }, - { slug: 'acme' }, - ); - const heap0 = process.memoryUsage().heapUsed; - const t0 = performance.now(); - const reply = await buildReplyMessage(message, { text: 'thanks', from: ${JSON.stringify(FROM)} }); - const ms = performance.now() - t0; - const heapDeltaMB = (process.memoryUsage().heapUsed - heap0) / (1024 * 1024); - console.log(JSON.stringify({ subject: reply.subject, ms, heapDeltaMB })); - `; - const stdout = execFileSync( - process.execPath, - ['--max-old-space-size=128', '--input-type=module', '-e', child, inboundModule], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, - ); - return JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { - subject: string; - ms: number; - heapDeltaMB: number; - }; - } - - // The third shape of the same header, aimed past the decoder at the - // control-character replacements: a global `replace` materializes one - // part per match before it builds the result, about 66 bytes each, so - // 5 MiB of alternating tab and letter (2.6M matches, inside - // MAX_MESSAGE_BYTES, no encoded-word for the decoder to touch) measured - // 165 MB on the first replacement alone, 191 MB and 206 ms on the reply - // path, and terminated a 128 MB isolate. The decoded subject is now - // bounded BEFORE the replacements run — 2.3 ms and 0.5 MB on the same - // path — so the pin runs in a child with the isolate's cap, where the - // old shape did not finish at all. + // Aimed past the decoder at the control-character replacements: the + // decoded subject is bounded before they run. it('replies to a 5 MiB subject of alternating tabs and letters inside a 128 MB heap', () => { - const result = replyInIsolate(`'\\ta'.repeat(${(5 * 1024 * 1024) / 2})`); + const result = replyInIsolate(`{ subject: '\\ta'.repeat(${(5 * 1024 * 1024) / 2}) }`); expect(result.subject).toBe('Re:'); expect(result.ms).toBeLessThan(200); expect(result.heapDeltaMB).toBeLessThan(64); @@ -2515,11 +1870,8 @@ describe('reply subject survives a hostile inbound header', () => { expect(over.subject).toBe('Re:'); }); - // It is the DECODED length that is bounded. B-encoded, 4096 three-octet - // characters arrive as a 19,945-character header, so a bound on the raw - // header would have refused a CJK subject a quarter that long. The - // rendered `Subject` line of that subject is the widest any kept subject - // renders to — about 20 KB, against a 10 MiB message limit. + // It is the DECODED length that is bounded: B-encoded, 4096 three-octet + // characters arrive as a 19,945-character header. it('bounds the decoded subject, not the encoded header it arrived in', async () => { // Fifteen three-octet characters fill a 45-octet encoded-word exactly, // so no character is split across words (RFC 2047 §5). @@ -2547,13 +1899,9 @@ describe('reply subject survives a hostile inbound header', () => { expect(over.subject).toBe('Re:'); }); - // The residual the previous round left open. A 10 MiB subject WITH spaces - // folds — every token is one character — so `fitsHeaderLine` passed it, - // `buildMime`'s projection (a lower bound) passed it, and the folded - // `Subject` alone rendered to 10,761,941 octets: the final size check - // threw `MimeError` out of `replyEmail()`, the throw this path exists to - // prevent. Bounded, the subject falls back and the reply is recorded. - it('replies to a 10 MiB subject that folds instead of throwing MimeError from the size check', async () => { + // A subject with spaces folds, so only the decoded-length bound keeps its + // rendered size from failing the reply. + it('replies to a subject of MAX_MESSAGE_BYTES that folds instead of throwing MimeError from the size check', async () => { const raw = new TextEncoder().encode('body'); const { message, getActions } = createMessageProxy( { @@ -2577,15 +1925,10 @@ describe('reply subject survives a hostile inbound header', () => { }); describe('parseEmail error contract', () => { - // `PARSE_FAILED` was declared in the code union but never constructed, so - // the one thing that does make postal-mime reject — a deeply nested - // message — escaped as a bare Error carrying parser internals. The union - // has to be honest: every code it names is reachable. + // Every code in `ParseEmailErrorCode` is reachable. it('wraps a raw-stream read failure as PARSE_FAILED', async () => { // An own-account delivery wraps Cloudflare's native one-shot `raw` with - // `createMessageProxy`, so the buffering read is what fails. The - // acquisition used to sit above the guarded block, and the stream error - // escaped raw. + // `createMessageProxy`, so the buffering read is what fails. const raw = new ReadableStream({ start(controller) { controller.error(new Error('stream broke mid-delivery')); @@ -2623,34 +1966,11 @@ describe('parseEmail error contract', () => { ); }); - it('still reports an oversized message as INBOUND_TOO_LARGE', async () => { - const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); - const { message } = createMessageProxy( - { - from: 'a@b.dev', - to: 'acme+x@mail.void.cloud', - headers: {}, - raw, - rawSize: 100 * 1024 * 1024, - }, - { slug: 'acme' }, - ); - - await expect(parseEmail(message)).rejects.toSatisfy( - (e: unknown) => e instanceof ParseEmailError && e.code === 'INBOUND_TOO_LARGE', - ); - }); -}); - -describe('void/email barrel exports the error classes it throws', () => { - // `package.json` exposes only `./email` and `./email/testing` — there is no - // wildcard subpath — so a class a handler author cannot reach through the - // barrel is a class they cannot `instanceof` at all, however well it is - // documented. The barrel exports two throwing entrypoints (`parseEmail`, - // `createEmail`); both error classes have to come with them. - it('re-exports ParseEmailError as the same class parseEmail throws', async () => { + // `void/email` is the only public path to the error classes, so a handler + // can `instanceof` them only through it. + it('reports an oversized message as INBOUND_TOO_LARGE, with the classes void/email exports', async () => { expect(emailBarrel.ParseEmailError).toBe(ParseEmailError); - + expect(emailBarrel.MimeError).toBe(MimeError); const raw = new TextEncoder().encode('Subject: hi\r\n\r\nbody\r\n'); const { message } = createMessageProxy( { @@ -2664,13 +1984,12 @@ describe('void/email barrel exports the error classes it throws', () => { ); await expect(parseEmail(message)).rejects.toSatisfy( - (e: unknown) => e instanceof emailBarrel.ParseEmailError && e.code === 'INBOUND_TOO_LARGE', + (e: unknown) => + e instanceof emailBarrel.ParseEmailError && + e.code === 'INBOUND_TOO_LARGE' && + /exceeds maximum/.test(e.message), ); }); - - it('re-exports MimeError as the same class createEmail throws', () => { - expect(emailBarrel.MimeError).toBe(MimeError); - }); }); describe('buildReplyMessage', () => { @@ -2716,10 +2035,7 @@ describe('buildReplyMessage', () => { await withSlug(async () => { const message = makeMessage({ Subject: 'Help me' }); const built = await buildReplyMessage(message, { text: 'sure' }); - // F3: default `from` no longer mirrors `message.to` (which was the - // platform-tagged `+@…` and is now slug-stripped to the - // bare local-part). Reply-from is anchored to the platform sender - // so the slug-pin allowlist accepts it. + // Reply-from is the platform sender, so the slug-pin allowlist accepts it. expect(built.from).toBe(`myslug+noreply@${MAIL_DOMAIN}`); expect(built.to).toBe('user@example.com'); expect(built.subject).toBe('Re: Help me'); @@ -2763,9 +2079,7 @@ describe('buildReplyMessage', () => { it('decodes ISO-8859-1 Q-encoded subjects with the actual charset', async () => { await withSlug(async () => { // =?iso-8859-1?Q?J=FCrgen?= → "Jürgen" (0xFC is 'ü' in Latin-1) - // Decoding the same bytes as UTF-8 would mojibake to a replacement - // char or a different glyph, so this test guards against the old - // "always UTF-8" fallback path. + // Decoding the same bytes as UTF-8 would mojibake. const message = makeMessage({ Subject: '=?iso-8859-1?Q?J=FCrgen?=' }); const built = await buildReplyMessage(message, { text: 'ack' }); expect(built.subject).toBe('Re: Jürgen'); @@ -3077,9 +2391,7 @@ describe('end-to-end: envelope → handler → action queue', () => { it('reply() refuses a native EmailMessage with a named error', async () => { // What workerd's `EmailMessage` looks like to JS: `from` and `to` only — - // workers-types declares nothing else and the body has no accessor, so - // the proxy cannot record it as bytes. It used to die on - // `undefined.getReader()`, since the first commit of this file. + // the body has no accessor, so the proxy cannot record it as bytes. const native = Object.freeze({ from: 'support@app.dev', to: 'sender@example.com' }); const handler = defineEmail(async (message) => { await message.reply(native as unknown as Parameters[0]); @@ -3334,12 +2646,14 @@ describe('end-to-end: envelope → handler → action queue', () => { ); it('refuses a forward address over the address-length limit', async () => { + const over = `${'a'.repeat(320)}@example.com`; const handler = defineEmail(async (message) => { - await message.forward(`${'a'.repeat(320)}@example.com`); + await message.forward(over); + }); + await expect(dispatchToHandler(makeEnvelope(), handler)).rejects.toMatchObject({ + code: 'INVALID_TO', + message: `to: address is ${over.length} characters, over the 320-character limit.`, }); - await expect(dispatchToHandler(makeEnvelope(), handler)).rejects.toThrow( - /over the 320-character limit/, - ); }); it('records a plain header record verbatim, keeping the name casing', async () => { @@ -3418,12 +2732,8 @@ describe('end-to-end: envelope → handler → action queue', () => { ])( 'refuses a forward header bag with %s at the call, recording nothing for it', async (_label, headers, expected) => { - // `new Headers()` accepts every one of these, so the validity check - // above passes them. The platform router then applies its own count - // and per-field bounds (`forwardHeadersWithinBounds`) and drops the - // WHOLE action list — a handler that recorded this forward deployed as - // one whose managed delivery silently performed no actions. The same - // bounds, on the record about to be stored, fail the handler's call. + // `new Headers()` accepts every one of these, but the platform router's + // `forwardHeadersWithinBounds` would drop the whole action list. const { message, getActions } = createMessageProxy(makeEnvelope()); await message.forward('first@example.com'); let thrown: unknown; @@ -3481,8 +2791,7 @@ describe('end-to-end: envelope → handler → action queue', () => { it('bounds a multi-megabyte control-laden reject reason before it is recorded', () => { // A reason built from the inbound message can be as large as the message - // and carry a remote sender's CRLF; both used to be recorded verbatim and, - // on your own account, replayed verbatim into the SMTP reply. + // and carry a remote sender's CRLF. const { message, getActions } = createMessageProxy(makeEnvelope()); message.setReject(`${'x'.repeat(5 * 1024 * 1024)}\r\n\x00`); const [action] = getActions(); @@ -3512,13 +2821,8 @@ describe('end-to-end: envelope → handler → action queue', () => { ['a list separator', 'a@example.com,b@example.com'], ['whitespace', 'a b@example.com'], ['a 65-character local part', `${'a'.repeat(65)}@example.com`], - // Inverted: these used to be recorded here and dropped by the platform - // router alone, which always wanted a dot in the domain. One package - // (`email-validator`) on both sides now, so the call refuses them. ['a dot-less domain', 'user@localhost'], ['a one-letter domain', 'x@y'], - // Both sides used to accept these; no dot-atom does, and an MTA refuses - // them after the handler's test reported a success. ['doubled dots in the local part', 'a..b@example.com'], ['a leading dot', '.a@example.com'], ['a trailing dot before the @', 'a.@example.com'], @@ -3528,11 +2832,7 @@ describe('end-to-end: envelope → handler → action queue', () => { ])( 'refuses a forward address with %s at the call, recording nothing for it', async (_label, to) => { - // Same class as the header record above, for the address: Cloudflare - // refuses it at native replay, the platform router before enaction. - // Checked here with the rule `sendEmail` applies to its `to` — and - // the rule the router drops on — so a handler's forward refuses - // exactly what an outbound send refuses and nothing the router loses. + // The rule `sendEmail` applies to its `to`, and the one the router drops on. const { message, getActions } = createMessageProxy(makeEnvelope()); await message.forward('first@example.com'); let thrown: unknown; diff --git a/packages/void/test/unit/email-mime.test.ts b/packages/void/test/unit/email-mime.test.ts index f8ac74733..8d416febb 100644 --- a/packages/void/test/unit/email-mime.test.ts +++ b/packages/void/test/unit/email-mime.test.ts @@ -1,6 +1,3 @@ -import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vite-plus/test'; import { buildMime, @@ -148,53 +145,8 @@ describe('normalizeEmail', () => { ).rejects.toThrow(/'to'/); }); - it('rejects malformed addresses', async () => { - await expect( - normalizeEmail({ from: 'no-at-sign', to: 'b@y.dev', subject: 's', text: 't' }), - ).rejects.toThrow(/from/); - }); - - // The shape check only required an '@' to be present anywhere, so a - // trailing, leading or doubled separator passed the preflight and was - // reported as a successful capture in the dev inbox — then failed only - // after reaching the platform. Every code below is the ADVERTISED one. - it.each(['user@', '@example.com', 'a@b@c'])( - 'rejects %s, which used to pass the preflight', - async (bad) => { - await expect( - normalizeEmail({ from: bad, to: 'b@y.dev', subject: 's', text: 't' }), - ).rejects.toThrow(/from/); - await expect( - normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), - ).rejects.toThrow(/to/); - }, - ); - - // Inverted: these used to be accepted as legal RFC 5321 mailboxes while - // the platform router refused them for the missing dot, so a handler's - // `forward('user@localhost')` was recorded here and dropped there. The - // rule is now `email-validator` on both sides, which wants a dotted - // domain with a letter-first TLD of at least two characters. - it.each(['user@localhost', 'x@y', 'a@example'])( - 'now refuses %s (no dot in the domain), as the router always did', - async (bad) => { - await expect( - normalizeEmail({ from: bad, to: 'b@y.dev', subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('from')); - await expect( - normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - }, - ); - - // RFC 5322 §2.1.1 makes 998 a MUST and §3.2.2 allows folding ONLY at - // whitespace, so a long token with no spaces cannot be folded at all — - // `Subject:\r\n ` shortens nothing. The RFC 2047 chunking added - // earlier only covers NON-ASCII values; a pure-ASCII token bypasses - // `encodeWord` entirely and leaks through two separate paths in - // `foldHeader`: the single-chunk early return, and the fold loop, which - // still gives an over-long chunk its own line. Nothing downstream - // repairs it — the proxy streams our bytes verbatim. + // RFC 5322 §2.1.1 makes 998 a MUST and §3.2.2 folds only at whitespace, + // so a long ASCII token with no spaces cannot be folded at all. const TOKEN = 'x'.repeat(1100); /** Longest physical line of the rendered message, in octets. */ @@ -237,20 +189,10 @@ describe('normalizeEmail', () => { ).toBe(998); }); - it('leaves foldable values alone', async () => { - const spaced = Array.from({ length: 200 }, (_, i) => `word${i}`).join(' '); - expect( - await longestLine({ from: 'a@x.dev', to: 'b@y.dev', subject: spaced, text: 't' }), - ).toBeLessThanOrEqual(998); - }); - it('measures a line in UTF-8 octets, not UTF-16 units, and reports its number', async () => { - // A CJK filename is rendered verbatim into ` filename="…"`: 3 octets per - // character inside 12 of framing, so 328 characters land exactly on the - // 998-octet limit and 329 are one octet over. The pre-render guard sees - // only `length` (341) and lets both through; the post-render scan must - // count octets. The line number pins the CRLF-delimited index into the - // multipart body, where the part's own headers live. + // A CJK filename costs 3 octets per character inside 12 of framing, so + // 328 characters land exactly on 998 octets and 329 are one over, though + // both pass a `length` check. const fits = '漢'.repeat(328); const { text } = await build({ from: 'a@x.dev', @@ -272,11 +214,7 @@ describe('normalizeEmail', () => { }); // A quoted-string parameter carries `\\` and `"` only as quoted-pairs - // (RFC 5322 §3.2.4 — qtext excludes both). Deleting quotes and leaving - // backslashes raw makes a trailing backslash escape the CLOSING quote, - // and silently eats embedded ones: a Windows path `C:\\Users\\bob\\r.pdf` - // reached the recipient as `C:Usersbobr.pdf`. This file's own folder - // already tracks quoted-pairs, so the emitter was contradicting it. + // (RFC 5322 §3.2.4 — qtext excludes both). it.each([ ['a trailing backslash', 'bad\\'], ['an embedded backslash', 'a\\b'], @@ -371,13 +309,8 @@ describe('normalizeEmail', () => { }); it("rejects an attachment disposition other than 'attachment' or 'inline'", async () => { - // `Attachment.disposition` is a two-literal union at compile time only. - // A JS caller, or a JSON-parsed options object, can pass any string, - // and `buildAttachmentPart` interpolates it verbatim into - // `Content-Disposition`. Unlike its neighbours it never went through - // `rejectCRLF`, so a CRLF in it rendered as a second header line inside - // the attachment part (`X-Injected: 1; filename="f.txt"`), and a blank - // line in it ended the part's header block early. + // The union is compile-time only: a JS caller can pass any string, which + // would be written verbatim into `Content-Disposition`. const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }; const message = "attachment disposition must be 'attachment' or 'inline'."; for (const disposition of [ @@ -411,26 +344,11 @@ describe('normalizeEmail', () => { expect(defaulted.attachments.map((a) => a.disposition)).toEqual(['attachment', 'inline']); }); - it('rejects whitespace inside an address', async () => { - await expect( - normalizeEmail({ from: 'a b@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), - ).rejects.toThrow(/from/); - }); - - // `MAX_MESSAGE_BYTES` used to be enforced ONLY at the end of `buildMime` — - // after every attachment had been materialized in parallel by a - // `Promise.all`, base64'd, sliced at 76 columns, joined and re-encoded. - // Peak live memory measured about 10x the attachment bytes, so a set over - // roughly 12 MiB terminated the isolate instead of returning the - // `MIME_ERROR` the docs promise. Base64 expands by `ceil(n / 3) * 4`, so - // nothing above three quarters of the message cap can ever fit anyway. + // Base64 expands by `ceil(n / 3) * 4`, so attachments above three quarters + // of the message cap can never fit. const MAX_ATTACHMENT_BYTES_TOTAL = Math.floor((MAX_MESSAGE_BYTES * 3) / 4); - /** - * A one-byte Blob wearing a declared `size`, recording whether anything - * ever read it. Shadowing beats allocating: the whole point is that the - * second attachment's bytes are never touched. - */ + /** A one-byte Blob wearing a declared `size`, recording whether anything read it. */ function fakeBlob(name: string, size: number, reads: Array): Blob { const blob = new Blob([new Uint8Array(1)]); Object.defineProperty(blob, 'size', { value: size }); @@ -460,8 +378,7 @@ describe('normalizeEmail', () => { ).rejects.toThrow( new RegExp(`exceeds Cloudflare limit of ${MAX_ATTACHMENT_BYTES_TOTAL} bytes`), ); - // The fix, stated as an assertion: the second attachment is charged from - // its DECLARED size and rejected before a single byte of it is copied. + // The second attachment is charged from its declared size, before any byte is read. expect(reads).toEqual(['first']); }); @@ -477,16 +394,8 @@ describe('normalizeEmail', () => { }); describe('attachment count', () => { - // `MAX_ATTACHMENT_BYTES_TOTAL` never advances on a zero-byte attachment, - // and the size projection in `buildMime` runs only after every entry - // has been normalized — about 275 bytes of live heap each. Measured, 2M - // empty attachments terminated a 128 MB isolate in 0.3 s instead of - // returning `MIME_ERROR`. The count is now refused before the loop, at - // the largest count the projection could admit: 119 octets per part — - // `ATTACHMENT_PART_MIN_OCTETS` plus a one-character filename rendered - // twice — under `MAX_MESSAGE_BYTES`. - const PART_MIN_OCTETS = 117 + 2; - + // Zero-byte attachments never advance the byte budget, so the count is + // refused before the loop, at the largest count the size projection admits. /** An attachment that records any read of the fields the loop touches first. */ function watchedAttachment(reads: Array, i: number): Attachment { return { @@ -501,11 +410,6 @@ describe('normalizeEmail', () => { }; } - it('is derived from the per-part floor and the message cap', () => { - expect(MAX_ATTACHMENTS).toBe(Math.floor(MAX_MESSAGE_BYTES / PART_MIN_OCTETS)); - expect(MAX_ATTACHMENTS).toBe(44_057); - }); - it('refuses one over MAX_ATTACHMENTS before normalizing any of them', async () => { const reads: Array = []; const attachments = Array.from({ length: MAX_ATTACHMENTS + 1 }, (_, i) => @@ -544,12 +448,8 @@ describe('normalizeEmail', () => { }); it('never rejects a count the size projection would have admitted', () => { - // The smallest part there is: a one-character filename, an empty - // media type and no content. One over the cap is refused by the - // projection on framing alone, so the count bound only ever refuses - // what `buildMime` was going to; exactly at the cap the projection - // admits it and the post-render check is the one to refuse the - // 12.9 MB it renders to. + // The smallest part there is. One over the cap is refused by the + // projection on framing alone; at the cap the post-render check refuses it. const part = { filename: 'a', contentType: '', @@ -605,10 +505,6 @@ describe('normalizeEmail', () => { } it('refuses more than MAX_RECIPIENTS across to, cc and bcc before parsing any of them', async () => { - // The platform proxy refuses the same count on the deduplicated list, - // but only after the tenant has parsed every entry and rendered the - // `To:` header — measured, 230k short valid addresses (a legal 4.8 MiB - // message) terminated a 128 MB isolate on the way there. const reads: Array = []; const to = Array.from({ length: MAX_RECIPIENTS - 1 }, (_, i) => watchedAddress(reads, i)); let error: unknown; @@ -647,9 +543,7 @@ describe('normalizeEmail', () => { it('refuses a quoted filename that could never fold onto a legal line, before rendering it', async () => { // ` filename="…"` is the shortest line a quoted parameter can land on: - // 12 octets plus the name. The post-render line check refuses the same - // input, but only after `quoteParam` has escaped it — measured, a - // filename of ~1.7M quote characters killed a 128 MB isolate there. + // 12 octets plus the name. const longest = 'a'.repeat(998 - 12); const { text } = await build({ from: 'a@x.dev', @@ -670,7 +564,7 @@ describe('normalizeEmail', () => { ).rejects.toThrow( /attachment filename is 987 characters; a quoted `filename=` parameter cannot be folded/, ); - // The guard is cheap on the input that used to be fatal. + // The guard refuses a huge input without escaping it. const started = performance.now(); await expect( build({ @@ -722,14 +616,9 @@ describe('normalizeEmail', () => { }); it('refuses a header name that could never sit on a legal line, before rendering it', async () => { - // A field name never folds. `Name:` alone is the shortest line it can - // sit on — what `foldHeader` emits when the value's first token does - // not fit beside a long name — so 997 characters is the longest name - // with ANY legal rendering, and only with a value that folds: `Name:` - // is 998 octets, then ` a b`. The same name over an empty value is one - // 999-octet line, which is the post-render line check's to refuse, not - // this guard's: a bound one character tighter would reject a message - // that fits. + // A field name never folds, and `Name:` alone is the shortest line it can + // sit on, so 997 characters is the longest name with any legal rendering + // (with a value that folds). Over an empty value the line check refuses it. const longest = `X${'a'.repeat(998 - 1 - 1)}`; expect(longest).toHaveLength(997); const { text } = await build({ @@ -759,10 +648,7 @@ describe('normalizeEmail', () => { headers: { [`${longest}b`]: 'a b' }, }), ).rejects.toThrow(/headers: a header name is 998 characters; a field name cannot be folded/); - // The guard is cheap on the input that used to be rendered four times - // over before the line check saw it: a 5 MiB name took 23 ms and - // 5.6 MB of heap to refuse post-render (measured); a 50 MiB one killed - // a 128 MB isolate. Refused here in 2 ms with no allocation. + // The guard refuses a huge name without rendering it. const started = performance.now(); await expect( normalizeEmail({ @@ -777,10 +663,8 @@ describe('normalizeEmail', () => { }); it('refuses an address over MAX_ADDRESS_LENGTH, with the code for its field', async () => { - // RFC 5321 §4.5.3.1: 64-octet local part + `@` + 255-octet domain is the - // sum of the component maxima — the pre-parse bound, not a deliverable - // path (the package's 254 total is that). At exactly 320 the package - // answers; one over, this one answers first, before anything is scanned. + // RFC 5321 §4.5.3.1: 64 + `@` + 255 is the pre-parse bound; the package's + // 254 is the deliverable one. At exactly 320 the package answers. const longest = `${'a'.repeat(64)}@${'b'.repeat(251)}.dev`; expect(longest).toHaveLength(MAX_ADDRESS_LENGTH); const over = `${'a'.repeat(65)}@${'b'.repeat(251)}.dev`; @@ -832,37 +716,28 @@ describe('normalizeEmail', () => { text: 't', }); expect(named.to[0].email).toBe(deliverable); + // The bound answers before the address is scanned. + expect(() => parseAddress(`a\x01${'b'.repeat(320)}@x.dev`, 'to')).toThrow( + /over the 320-character limit/, + ); }); - it('refuses an address over 254 characters (RFC 5321 §4.5.3.1.3), by the package rule', async () => { - // RFC 5321 §4.5.3.1.3: a 256-octet forward-path `` is the - // longest every receiver MUST accept, so the address is at most 254. - // The 320 total is the sum of the local-part and domain maxima, not a - // path — `a@` + a 255-octet domain is 257 characters, under the - // total-length bound, and `void dev` and the test harness once captured - // it as a success. `email-validator` checks 254 before its regex, and - // the platform router applies the same package, so both sides refuse it. + describe('address rule', () => { + // `validateAddressParts` is `email-validator`, the package the platform + // router applies too; its message names the rule and never echoes the address. const label = 'b'.repeat(63); const domain255 = [label, label, label, label].join('.'); - expect(domain255).toHaveLength(255); - const over = `a@${domain255}`; - expect(over).toHaveLength(257); - expect(over.length).toBeLessThan(MAX_ADDRESS_LENGTH); - const longest = `a@${domain255.slice(0, 252)}`; - expect(longest).toHaveLength(254); - const email = await normalizeEmail({ from: longest, to: longest, subject: 's', text: 't' }); - expect(email.to[0].email).toBe(longest); - for (const [field, code] of [ - ['to', 'INVALID_TO'], - ['from', 'INVALID_FROM'], - ['cc', 'MIME_ERROR'], - ] as const) { + + async function addressError( + field: 'to' | 'from' | 'cc' | 'bcc' | 'replyTo', + address: string | { name?: string; email: string }, + ): Promise { let error: unknown; try { await normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', - [field]: over, + [field]: address, subject: 's', text: 't', }); @@ -870,182 +745,115 @@ describe('normalizeEmail', () => { error = e; } expect(error).toBeInstanceOf(MimeError); - expect((error as MimeError).code).toBe(code); - expect((error as MimeError).message).toBe(invalidAddress(field)); + return error as MimeError; } - // Inverted: a raw IDN domain used to be accepted and counted in UTF-8 - // octets (SMTPUTF8). The package is ASCII-only, so octets and characters - // are the same thing and a raw `ü` label is refused however short; the - // same domain is accepted as punycode, which is what DNS carries. - const idnRaw = `a@${'ü'.repeat(31)}.${'b'.repeat(63)}`; - await expect( - normalizeEmail({ from: 'a@x.dev', to: idnRaw, subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - const punycode = 'a@xn--bcher-kva.example'; - const idn = await normalizeEmail({ from: 'a@x.dev', to: punycode, subject: 's', text: 't' }); - expect(idn.to[0].email).toBe(punycode); - // A label over 63 characters is refused on its own, inside the 254 total. - const longLabel = `a@${'b'.repeat(64)}.dev`; - await expect( - normalizeEmail({ from: 'a@x.dev', to: longLabel, subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - // The display-name form is bounded on the same parsed address, and the - // total-length bound still answers first for one over both. - expect(() => parseAddress(`Name <${over}>`, 'to')).toThrow(invalidAddress('to')); - expect(() => parseAddress(`${'a'.repeat(65)}@${domain255.slice(0, 200)}`, 'to')).toThrow( - invalidAddress('to'), - ); - expect(() => parseAddress(`${'a'.repeat(65)}@${domain255}`, 'to')).toThrow( - /over the 320-character limit/, + + it.each([ + ['no @', 'no-at-sign'], + ['an empty domain', 'user@'], + ['an empty local part', '@example.com'], + ['two @', 'a@b@c'], + ['whitespace', 'a b@x.dev'], + ['a comma', 'victim@example.com,attacker@evil.dev'], + ['a semicolon', 'a@x.dev;b@evil.dev'], + ['a dot-less domain', 'user@localhost'], + ['a one-letter domain', 'x@y'], + ['a domain with no TLD', 'a@example'], + ['a one-letter TLD', 'a@b.c'], + ['a TLD starting with a digit', 'a@b.1c'], + ['a TLD ending with a hyphen', 'a@b.c-'], + ['a TLD starting with a hyphen', 'a@b.-c'], + // RFC 5321 §4.5.3.1.3: a 256-octet path, so at most 254 for the address. + ['257 characters, under the 320 bound', `a@${domain255}`], + // RFC 5321 §4.5.3.1.1: at most 64 octets before the @. + ['a 65-character local part', `${'a'.repeat(65)}@x.dev`], + [ + 'a 65-character local part and 266 in total', + `${'a'.repeat(65)}@${domain255.slice(0, 200)}`, + ], + ['a 64-character domain label', `a@${'b'.repeat(64)}.dev`], + ['a raw IDN domain', 'a@exämple.com'], + ['a long raw IDN domain', `a@${'ü'.repeat(31)}.${'b'.repeat(63)}`], + ['a non-ASCII local part', 'ünicode@example.com'], + ['an accented local part', 'jöhn@example.com'], + ['a CJK local part', `${'漢'.repeat(21)}@x.dev`], + ['a quoted local part', '"a b"@example.com'], + ['a quoted @', '"a@b"@example.com'], + ['a quoted word', '"weird"@x.dev'], + ['SOH', 'a\x01b@example.com'], + ['ESC', 'a\x1bb@example.com'], + ['US', 'a\x1fb@example.com'], + ['DEL', 'a\x7fb@example.com'], + ['TAB', 'a\tb@example.com'], + // `:` is an RFC 5322 §3.2.3 special, not atext. + ['a colon in the local part', 'a:b@example.com'], + ['a colon in the domain', 'a@b:c.example.com'], + ['a leading colon', ':a@example.com'], + ['a trailing colon', 'a@example.com:'], + ['doubled dots in the local part', 'a..b@example.com'], + ['a leading dot', '.a@example.com'], + ['a dot before the @', 'a.@example.com'], + ['doubled dots in the domain', 'a@example..com'], + ['a dot after the @', 'a@.example.com'], + ['a trailing dot', 'a@example.com.'], + ])( + 'refuses an address with %s, bare, as an object and in a display name', + async (_label, bad) => { + expect((await addressError('to', bad)).message).toBe(invalidAddress('to')); + expect((await addressError('to', { name: 'Bob', email: bad })).message).toBe( + invalidAddress('to'), + ); + expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); + }, ); - }); - it('refuses a local part over 64 characters (RFC 5321 §4.5.3.1.1), by the package rule', async () => { - // RFC 5321 §4.5.3.1.1: 64 octets is the longest local part every - // receiver MUST accept. The total-length bound alone let this through - // — `'a'.repeat(65)}@x.dev` is 71 characters — so `void dev` and the - // test harness once captured it as a success. `email-validator` - // enforces the 64, and the platform router applies the same package. - const longest = `${'a'.repeat(64)}@x.dev`; - const over = `${'a'.repeat(65)}@x.dev`; - expect(over.length).toBeLessThan(MAX_ADDRESS_LENGTH); - const email = await normalizeEmail({ from: longest, to: longest, subject: 's', text: 't' }); - expect(email.to[0].email).toBe(longest); - for (const [field, code] of [ + it.each([ ['to', 'INVALID_TO'], ['from', 'INVALID_FROM'], ['cc', 'MIME_ERROR'], - ] as const) { - let error: unknown; - try { - await normalizeEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - [field]: over, - subject: 's', - text: 't', - }); - } catch (e) { - error = e; - } - expect(error).toBeInstanceOf(MimeError); - expect((error as MimeError).code).toBe(code); - expect((error as MimeError).message).toBe(invalidAddress(field)); - } - // Inverted: a non-ASCII local part (SMTPUTF8) used to be accepted and - // counted in UTF-8 octets. The package is ASCII-only, so `ünicode@` and - // a CJK local part are refused however short, on both sides. - for (const bad of ['ünicode@example.com', 'jöhn@example.com', `${'漢'.repeat(21)}@x.dev`]) { - await expect( - normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - } - // The display-name form is bounded on the same parsed address, and the - // total-length bound still answers first for an address over both. - expect(() => parseAddress(`Name <${over}>`, 'to')).toThrow(invalidAddress('to')); - expect(() => parseAddress(`${'a'.repeat(65)}@${'b'.repeat(251)}.dev`, 'to')).toThrow( - /over the 320-character limit/, - ); - }); - - describe('control characters in an address', () => { - // Every C0 control and DEL once passed the hand-written rule (which - // refused only CR, LF, NUL and what `\s` matches): `a\x01b@example.com` - // was accepted and `formatAddress` wrote the byte raw into the `To:` - // line, captured as a success under `void dev` and by the test harness, - // and dropped by the platform router after earlier actions had fired. - // `email-validator` admits only `atext`, so every control is refused, - // and the message never echoes the address. - const controls = [ - ['SOH', '\x01'], - ['ESC', '\x1b'], - ['US', '\x1f'], - ['DEL', '\x7f'], - ['TAB', '\t'], - ] as const; - - it('refuses a bare address carrying one, with the code for its field', async () => { - for (const [, ch] of controls) { - const bad = `a${ch}b@example.com`; - for (const [field, code] of [ - ['to', 'INVALID_TO'], - ['from', 'INVALID_FROM'], - ['cc', 'MIME_ERROR'], - ['bcc', 'MIME_ERROR'], - ['replyTo', 'MIME_ERROR'], - ] as const) { - let error: unknown; - try { - await normalizeEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - [field]: bad, - subject: 's', - text: 't', - }); - } catch (e) { - error = e; - } - expect(error).toBeInstanceOf(MimeError); - expect((error as MimeError).code).toBe(code); - expect((error as MimeError).message).toBe(invalidAddress(field)); - expect((error as MimeError).message).not.toContain(ch); - } - } + ['bcc', 'MIME_ERROR'], + ['replyTo', 'MIME_ERROR'], + ] as const)('refuses an invalid %s address with %s', async (field, code) => { + const error = await addressError(field, 'a..b@example.com'); + expect(error.code).toBe(code); + expect(error.message).toBe(invalidAddress(field)); }); - it('refuses it in the object and display-name forms on the same parsed address', async () => { - for (const [, ch] of controls) { - const bad = `a${ch}b@example.com`; - await expect( - normalizeEmail({ - from: 'a@x.dev', - to: { name: 'Bob', email: bad }, - subject: 's', - text: 't', - }), - ).rejects.toThrow(invalidAddress('to')); - expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); - } - // CR, LF and NUL in the object form reach the address rule directly - // (the string form is refused before it is parsed, by `rejectCRLF`): - // the package refuses them with the field's own code, so dropping the - // separate CRLF scan from the bare address lost nothing. + it('refuses CR, LF and NUL in an object address by the rule, and in a string before parsing', async () => { for (const ch of ['\r', '\n', '\0']) { - let error: unknown; - try { - await normalizeEmail({ - from: 'a@x.dev', - to: { email: `a${ch}b@example.com` }, - subject: 's', - text: 't', - }); - } catch (e) { - error = e; - } - expect(error).toBeInstanceOf(MimeError); - expect((error as MimeError).code).toBe('INVALID_TO'); - expect((error as MimeError).message).toBe(invalidAddress('to')); + const error = await addressError('to', { email: `a${ch}b@example.com` }); + expect(error.code).toBe('INVALID_TO'); + expect(error.message).toBe(invalidAddress('to')); expect(() => parseAddress(`Bob `, 'to')).toThrow( /must not contain CR, LF, or NUL/, ); } - // The length bound still answers first: the scan is over at most 320 - // characters, so an over-long address is refused before it is read. - expect(() => parseAddress(`a\x01${'b'.repeat(320)}@x.dev`, 'to')).toThrow( - /over the 320-character limit/, - ); }); - it('never renders one raw from a display name, which is encoded instead', async () => { - // A name is not an address: a control in it is a character like any - // other and `formatAddress` RFC 2047-encodes the non-printable name, - // so the byte reaches the wire only inside base64. Pinned so the - // address rule is never mistaken for the reason the header is clean. - // Every C0 control and DEL except the CR and LF that delimit lines. + it.each([ + ['a 254-character address', `a@${domain255.slice(0, 252)}`], + ['a 64-character local part', `${'a'.repeat(64)}@x.dev`], + ['a punycode IDN domain', 'a@xn--bcher-kva.example'], + ['dots between atoms', 'first.last@example.com'], + ['dots on both sides', 'a.b.c@sub.example.co.uk'], + ['a dotted domain', 'a@sub.sub.example.com'], + ['a TLD ending in a digit', 'a@b.c1'], + ['a two-letter TLD', 'a@b.co'], + ['a hyphen inside the TLD', 'a@b.c-d'], + ['a hyphenated label', 'a@b-c.example'], + ['every atext symbol after the first character', "a!#$%&'*+-/=?^_`{|}~@example.com"], + ])('accepts %s', async (_label, ok) => { + const email = await normalizeEmail({ from: ok, to: ok, subject: 's', text: 't' }); + expect(email.from.email).toBe(ok); + expect(email.to[0].email).toBe(ok); + }); + + it('never renders a control character raw from a display name, which is encoded instead', async () => { + // A name is not an address: `formatAddress` RFC 2047-encodes a + // non-printable name, so the byte reaches the wire only inside base64. // eslint-disable-next-line no-control-regex -- C0 controls and DEL are the point const rawControl = /[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/; - for (const [, ch] of controls) { + for (const ch of ['\x01', '\x1b', '\x1f', '\x7f', '\t']) { const { text } = await build({ from: 'a@x.dev', to: { name: `Bo${ch}b`, email: 'b@y.dev' }, @@ -1061,15 +869,8 @@ describe('normalizeEmail', () => { }); describe('control characters in an attachment filename', () => { - // `rejectCRLF` refused only CR, LF and NUL, and a filename is rendered - // through `quoteParam` — never `encodeWord` — so `invoice\x01.pdf` was - // written raw into both `name="…"` and `filename="…"` (measured on the - // wire: `… 69 6e 76 6f 69 63 65 01 2e 70 64 66 22`), captured as a - // success under `void dev` and by the harness, and left for the - // receiving MTA. RFC 5322 §3.2.4 `qtext` is printable US-ASCII less `"` - // and `\`; a control in a quoted-string is `obs-qtext`, which §4 says - // MUST NOT be generated. The message names the code point and never - // echoes the value. + // A filename is rendered raw inside a quoted-string, where a control is + // `obs-qtext` (RFC 5322 §3.2.4), which §4 says MUST NOT be generated. const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }; const controls = [ ['SOH', '\x01', 'U+0001'], @@ -1096,7 +897,7 @@ describe('normalizeEmail', () => { ); expect((error as MimeError).message).not.toContain(ch); } - // CR, LF and NUL still answer first, with the message they always had. + // CR, LF and NUL answer first, by `rejectCRLF`. for (const ch of ['\r', '\n', '\0']) { await expect( normalizeEmail({ ...base, attachments: [{ filename: `a${ch}b.pdf`, content: 'x' }] }), @@ -1105,11 +906,7 @@ describe('normalizeEmail', () => { }); it('accepts HTAB, which is folding white space inside a quoted-string (RFC 5322 §3.2.4), unchanged', async () => { - // `quoted-string = DQUOTE *([FWS] qcontent) [FWS] DQUOTE` and `FWS` is - // made of `WSP` (SP / HTAB), so a tab between characters is legal - // exactly as a space is; `obs-NO-WS-CTL`, the RFC's own list of the - // controls that used to be `qtext`, leaves %d9 out. Rendered as it - // was: raw, inside the quotes, in both parameters. + // `FWS` is SP / HTAB, so a tab inside a quoted-string is legal like a space. const { text } = await build({ ...base, attachments: [{ filename: 'a\tb.pdf', content: 'x' }], @@ -1119,8 +916,8 @@ describe('normalizeEmail', () => { }); it('still renders a non-ASCII filename as raw UTF-8 in the quoted parameter', async () => { - // The existing path, pinned: no RFC 2231 `filename*=` and no - // encoded-word — the UTF-8 octets go inside the quotes as they did. + // No RFC 2231 `filename*=` and no encoded-word: the UTF-8 octets go + // inside the quotes. const { raw, text } = await build({ ...base, attachments: [{ filename: 'résumé.pdf', content: 'x' }], @@ -1169,14 +966,8 @@ describe('normalizeEmail', () => { }); describe('media type syntax of an attachment', () => { - // `rejectControls` answered only for CTLs, and `buildAttachmentPart` - // writes the media type verbatim ahead of its own `; name="…"`, so - // `text/plain; x="` rendered `Content-Type: text/plain; x="; name="a.txt"` - // — captured as a success under `void dev` and by the harness, while - // Python's `email` reports `end of header inside quoted string` on that - // line and reads the builder's `name` as the VALUE of `x`. The grammar - // is RFC 2045 §5.1 `type "/" subtype *(";" parameter)`, read as a - // generator; the message names the rule and never echoes the value. + // The media type is written verbatim ahead of the builder's `; name="…"`, + // so it must match RFC 2045 §5.1 `type "/" subtype *(";" parameter)`. const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }; const SYNTAX = 'attachment contentType is not a valid media type (RFC 2045 §5.1: type/subtype, then ; attribute=value parameters).'; @@ -1230,7 +1021,7 @@ describe('normalizeEmail', () => { } }); - it('accepts every valid form and renders it as it did, byte for byte', async () => { + it('accepts every valid form and renders it verbatim', async () => { const valid = [ 'TEXT/PLAIN', // case is kept as given; matching is case-insensitive 'application/vnd.ms-excel', @@ -1251,8 +1042,7 @@ describe('normalizeEmail', () => { ...base, attachments: [{ filename: 'a.txt', content: 'x', contentType }], }); - // Verbatim, then the builder's own parameter — exactly the line the - // unchecked path rendered. + // Verbatim, then the builder's own parameter. expect(text, contentType).toContain(`Content-Type: ${contentType}; name="a.txt"\r\n`); } }); @@ -1276,18 +1066,6 @@ describe('normalizeEmail', () => { return lines.slice(start, end); } - it('pins the rendered line for three of them', async () => { - expect(await attachmentContentTypeLines('TEXT/PLAIN')).toEqual([ - 'Content-Type: TEXT/PLAIN; name="a.txt"', - ]); - expect(await attachmentContentTypeLines('text/plain; charset="utf-8"')).toEqual([ - 'Content-Type: text/plain; charset="utf-8"; name="a.txt"', - ]); - expect(await attachmentContentTypeLines('application/x-foo.bar-baz_1')).toEqual([ - 'Content-Type: application/x-foo.bar-baz_1; name="a.txt"', - ]); - }); - it('keeps the length bound in front of the scan', async () => { // "Bound before you scan": the `CONTENT_TYPE_LINE_OVERHEAD` guard // answers for an over-long value before the grammar reads it — the @@ -1339,16 +1117,9 @@ describe('normalizeEmail', () => { }); describe('Content-ID syntax of an attachment', () => { - // `rejectControls` answered only for CTLs, and `buildAttachmentPart` - // writes the Content-ID verbatim, adding `<` `>` unless the value - // already starts with `<`: `logo other` rendered ``, - // `` rendered `>` — captured as - // a success under `void dev` and by the harness, and left for the - // receiver to refuse or to fail the html's `cid:` reference against. - // The grammar is RFC 5322 §3.6.4 `msg-id`, read as a generator, with - // the `@` side and the brackets optional (RFC 2392 §2 resolves `cid:x` - // against ``, and the builder has always bracketed a bare id); the - // message names the rule and never echoes the value. + // The Content-ID is written verbatim, bracketed unless it starts with `<`, + // so it must be an RFC 5322 §3.6.4 `msg-id`; the `@` side and the + // brackets are optional (RFC 2392 §2 resolves `cid:x` against ``). const base = { from: 'a@x.dev', to: 'b@y.dev', subject: 's', html: '' }; const SYNTAX = 'attachment contentId is not a valid Content-ID (RFC 5322 §3.6.4 msg-id: an identifier of atext, "." and "@", optionally in one pair of angle brackets).'; @@ -1407,7 +1178,7 @@ describe('normalizeEmail', () => { '@b', // an `@` owes both sides 'a@', // 'lögo', // atext is US-ASCII - '', // used to be validated, then silently not rendered + '', // ]; for (const contentId of invalid) { const error = await refused(contentId); @@ -1418,9 +1189,9 @@ describe('normalizeEmail', () => { } }); - it('accepts every valid form and renders it as it did, byte for byte', async () => { + it('accepts every valid form and renders it verbatim', async () => { // Bare or bracketed, the wire form is ``; a bracketed id renders - // as given, a bare one is wrapped — exactly what the unchecked path did. + // as given, a bare one is wrapped. const valid: Array<[contentId: string, rendered: string]> = [ ['logo', ''], // the documented example: no `@` (RFC 2392 §2) ['', ''], @@ -1437,26 +1208,6 @@ describe('normalizeEmail', () => { } }); - it('keeps the documented inline-image example working verbatim', async () => { - const { text } = await build({ - from: 'Acme ', - to: 'user@example.com', - subject: 'Hello', - html: 'Acme', - attachments: [ - { - filename: 'logo.png', - content: new Uint8Array([1, 2, 3]), - contentType: 'image/png', - contentId: 'logo', - disposition: 'inline', - }, - ], - }); - expect(text).toContain('Content-ID: \r\n'); - expect(text).toContain('Acme'); - }); - it('still infers disposition from a bracketed id', async () => { // `att.contentId ? 'inline' : 'attachment'`: any id the grammar // admits is non-empty, so the default is `inline` for both forms. @@ -1497,196 +1248,21 @@ describe('normalizeEmail', () => { }); }); - describe('a colon in an address', () => { - // `:` is an RFC 5322 §3.2.3 special, not `atext`, so a bare - // `a:b@example.com` is no addr-spec without quoting — which is refused - // too. It was the one special missing from the hand-written address - // class: the address passed here, a handler's `forward()` recorded it, - // and the platform router's `isValidEmailAddress` dropped that one - // forward after earlier actions had fired. `email-validator` admits only - // `atext`, so it is refused like `,` and `;`. - const colons = ['a:b@example.com', 'a@b:c.example.com', ':a@example.com', 'a@example.com:']; - - it('refuses a bare address carrying one, with the code for its field', async () => { - for (const bad of colons) { - for (const [field, code] of [ - ['to', 'INVALID_TO'], - ['from', 'INVALID_FROM'], - ['cc', 'MIME_ERROR'], - ['bcc', 'MIME_ERROR'], - ['replyTo', 'MIME_ERROR'], - ] as const) { - let error: unknown; - try { - await normalizeEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - [field]: bad, - subject: 's', - text: 't', - }); - } catch (e) { - error = e; - } - expect(error).toBeInstanceOf(MimeError); - expect((error as MimeError).code).toBe(code); - expect((error as MimeError).message).toBe(invalidAddress(field)); - } - } - }); - - it('refuses it in the object and display-name forms on the same parsed address', async () => { - for (const bad of colons) { - await expect( - normalizeEmail({ - from: 'a@x.dev', - to: { name: 'Bob', email: bad }, - subject: 's', - text: 't', - }), - ).rejects.toThrow(invalidAddress('to')); - expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); - } - }); - - it('still accepts one in a display name, where it is quoted', async () => { - // A name is a phrase, and a phrase with a special is rendered as one - // quoted-string — the address rule is not the name rule. - const { text } = await build({ - from: 'a@x.dev', - to: { name: 'Ops: Tier 2', email: 'b@y.dev' }, - subject: 's', - text: 't', - }); - expect(unfoldHeader(text, 'To')).toBe('"Ops: Tier 2" '); - }); - }); - - describe('dot-atom shape of an address', () => { - // An RFC 5322 §3.2.3 dot-atom cannot start or end with a dot or carry - // two in a row, on either side of the `@`. The hand-written rule and - // the platform router's regex BOTH accepted every one of these — no - // divergence, just a shared hole that Cloudflare or the next MTA would - // refuse after the handler's test had reported a success. One package - // on both sides closes it. - const dots = [ - 'a..b@example.com', - '.a@example.com', - 'a.@example.com', - 'a@example..com', - 'a@.example.com', - 'a@example.com.', - ]; - - it('refuses leading, trailing and doubled dots, with the code for its field', async () => { - for (const bad of dots) { - for (const [field, code] of [ - ['to', 'INVALID_TO'], - ['from', 'INVALID_FROM'], - ['cc', 'MIME_ERROR'], - ['bcc', 'MIME_ERROR'], - ['replyTo', 'MIME_ERROR'], - ] as const) { - let error: unknown; - try { - await normalizeEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - [field]: bad, - subject: 's', - text: 't', - }); - } catch (e) { - error = e; - } - expect(error, bad).toBeInstanceOf(MimeError); - expect((error as MimeError).code).toBe(code); - expect((error as MimeError).message).toBe(invalidAddress(field)); - } - expect(() => parseAddress(`Bob <${bad}>`, 'to')).toThrow(invalidAddress('to')); - } - }); - - it('still accepts dots between atoms, on both sides', async () => { - for (const ok of [ - 'first.last@example.com', - 'a.b.c@sub.example.co.uk', - 'a@sub.sub.example.com', - ]) { - const email = await normalizeEmail({ from: ok, to: ok, subject: 's', text: 't' }); - expect(email.to[0].email).toBe(ok); - } - }); - }); - - describe('domain and local-part characters of an address', () => { - // The package's domain rule: ASCII labels, and a TLD that starts with a - // letter and is at least two characters. A quoted local part and a - // display name in the bare string stay refused, as they always were. - it('applies the TLD rule', async () => { - for (const bad of ['a@b.c', 'a@b.1c', 'a@b.c-', 'a@b.-c']) { - await expect( - normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - } - for (const ok of [ - 'a@b.c1', - 'a@b.co', - 'a@b.c-d', - 'a@xn--bcher-kva.example', - 'a@b-c.example', - ]) { - const email = await normalizeEmail({ from: 'a@x.dev', to: ok, subject: 's', text: 't' }); - expect(email.to[0].email).toBe(ok); - } - }); - - it('refuses a raw IDN domain and a quoted local part', async () => { - for (const bad of [ - 'a@exämple.com', - '"a b"@example.com', - '"a@b"@example.com', - '"weird"@x.dev', - ]) { - await expect( - normalizeEmail({ from: 'a@x.dev', to: bad, subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - } - }); - - it('pins the package quirk: a backtick may not be the FIRST local-part character', async () => { - // The first character class of `email-validator`'s regex lacks the - // backtick that every later class has. Pinned, not worked around: the - // point of one package on both sides is that the rule is whatever the - // package says, and a wrapper that patched this would be the start of - // the next hand-written rule. - await expect( - normalizeEmail({ from: 'a@x.dev', to: '`a@x.com', subject: 's', text: 't' }), - ).rejects.toThrow(invalidAddress('to')); - const email = await normalizeEmail({ - from: 'a@x.dev', - to: 'a`b@x.com', - subject: 's', - text: 't', - }); - expect(email.to[0].email).toBe('a`b@x.com'); - }); - - it('accepts every RFC 5322 atext punctuation after the first character', async () => { - const atext = "!#$%&'*+-/=?^_`{|}~"; - const ok = `a${atext}@example.com`; - const email = await normalizeEmail({ from: 'a@x.dev', to: ok, subject: 's', text: 't' }); - expect(email.to[0].email).toBe(ok); + it('accepts a colon in a display name, where it is quoted', async () => { + // A name is a phrase, and a phrase with a special is rendered as one + // quoted-string — the address rule is not the name rule. + const { text } = await build({ + from: 'a@x.dev', + to: { name: 'Ops: Tier 2', email: 'b@y.dev' }, + subject: 's', + text: 't', }); + expect(unfoldHeader(text, 'To')).toBe('"Ops: Tier 2" '); }); it('parses the display-name form in linear time', () => { - // `^\s*([^"<]+?)?\s*<` let three quantifiers share one run of - // whitespace; the backtracker tried every split, cubic in the run. - // Measured on that form: 2,000 spaces took 2 s, 4,000 took 10 s (16 s - // when the `>` is missing). A regex cannot be interrupted, so keep the - // run where a regression costs seconds, not the test's whole timeout. - // (Two-letter TLDs: the address rule wants at least two.) + // Quantifiers that share a whitespace run backtrack cubically, and a regex + // cannot be interrupted: keep the run where a regression costs seconds. const run = ' '.repeat(4000); const started = performance.now(); expect(parseAddress(`${run}a@b.cd`, 'to')).toEqual({ email: 'a@b.cd' }); @@ -1698,9 +1274,7 @@ describe('normalizeEmail', () => { expect(performance.now() - started).toBeLessThan(1000); }); - it('keeps the display-name form’s language after the regex change', () => { - // Shapes the old and new patterns were checked to agree on - // (exhaustively to length 7 over a 9-symbol alphabet); pinned here. + it('pins the language of the display-name form', () => { expect(parseAddress('a>b ', 'to')).toEqual({ email: 'c@d.ef', name: 'a>b' }); expect(parseAddress('x', 'to')).toEqual({ email: 'a@b.cd', name: 'x' }); expect(parseAddress('\tJohn\tSmith\t', 'to')).toEqual({ @@ -1751,13 +1325,8 @@ describe('normalizeEmail', () => { }); it('undoes quoted-pairs in linear time and without a per-pair allocation', () => { - // A `replace(/\\(.)/g, '$1')` holds about 60 bytes of heap per match - // before it builds its result — measured 307 MB for 10 MiB of `\"` - // pairs, past a 128 MB isolate — and nothing bounds the name before - // the decode runs: an ASCII name is bounded only on its DECODED form, - // and a non-ASCII one never is. The buffer routine peaked at 49 MB on - // the same input. Heap is not something a test can pin; time is, and - // both shapes are exercised so the output of each path is built. + // Nothing bounds the name before the decode, and heap is not something a + // test can pin; time is. Both shapes build the output of their path. const started = performance.now(); expect(() => parseAddress(`"${'\\"'.repeat(1_000_000)}" `, 'to')).toThrow( /cannot be folded/, @@ -1811,22 +1380,8 @@ describe('buildMime', () => { expect(text).toMatch(/Subject: =\?UTF-8\?B\?[^?]+\?=/); }); - it('quoted-prints non-ASCII bodies', async () => { - const { text } = await build({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 'café', - }); - expect(text).toContain('Content-Transfer-Encoding: quoted-printable'); - // 'é' = 0xC3 0xA9 in UTF-8 → =C3=A9 - expect(text).toContain('caf=C3=A9'); - }); - - // The encoding decision, pinned: only LF and CRLF end a line, a bare CR - // (or any other control character) forces quoted-printable, and a line of - // 990 printable characters is the longest that ships as 7bit. Captured - // from the regex-and-split implementation the one-pass scanner replaced. + // Only LF and CRLF end a line, a bare CR (or any other control character) + // forces quoted-printable, and 990 printable characters is the longest 7bit line. it.each<[string, string, string, string]>([ ['a bare CR', 'a\rb', 'quoted-printable', 'a\r\nb'], ['a tab', 'a\tb', 'quoted-printable', 'a\tb'], @@ -1924,21 +1479,9 @@ describe('buildMime', () => { expect(text).toContain('Content-ID: '); }); - it('rejects oversized messages', async () => { - const big = 'x'.repeat(11 * 1024 * 1024); - const email = await normalizeEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: big, - }); - expect(() => buildMime(email)).toThrow(/exceeds Cloudflare limit/); - }); - it('quotes display names containing RFC 5322 specials (comma is the worst)', async () => { // Comma in a name MUST be quoted — without quotes it splits the - // address-list field. Pre-fix, the formatAddress fallback emitted - // unquoted ASCII names, letting `Smith, John` corrupt the To header. + // address-list field. const { text } = await build({ from: { email: 'a@x.dev', name: 'Smith, John' }, to: { email: 'b@y.dev', name: 'Doe; Jane' }, @@ -1980,10 +1523,7 @@ describe('buildMime', () => { }); it('renders a quoted display name with its quoted-pairs escaped exactly once', async () => { - // `"Smith \"Bob\"" ` used to keep the raw `\"` pairs in the - // parsed name, and `formatAddress` escaped them again: the header read - // `"Smith \\\"Bob\\\""` and the recipient saw `Smith \"Bob\"`. The - // object form of the same name was right, so the two forms disagreed. + // The string and object forms of the same name render the same header. const cases: Array<[string, string, string]> = [ ['"Smith \\"Bob\\"" ', 'Smith "Bob"', '"Smith \\"Bob\\""'], ['"a\\\\b" ', 'a\\b', '"a\\\\b"'], @@ -2019,13 +1559,8 @@ describe('buildMime', () => { }); describe('a period in a display name', () => { - // `.` is one of RFC 5322 §3.2.3's `specials`, so it is not `atext` and a - // name containing one is not a `phrase` of atoms: `Dr. Smith ` - // parses only as `obs-phrase` (§4.1), which §4 says MUST NOT be - // generated. `RFC5322_SPECIALS` used to omit it, so every dotted name - // went out unquoted; a strict receiver (Python's `email` parser flags - // "period in 'phrase'", and `.` alone as an invalid phrase) sees - // obsolete syntax on an otherwise valid message. + // `.` is one of RFC 5322 §3.2.3's `specials`, so an unquoted dotted name + // parses only as `obs-phrase` (§4.1), which §4 says MUST NOT be generated. const dotted: Array<[string, string]> = [ ['Dr. Smith', '"Dr. Smith"'], ['J. R. R. Tolkien', '"J. R. R. Tolkien"'], @@ -2111,13 +1646,9 @@ describe('buildMime', () => { expect(decodeEncodedWords(from)).toBe('Dr. Müller '); }); - it('is now bounded by the quoted-string line guard, at the same edge as any other special', async () => { - // A quoted-string is one token `foldHeader` never splits, so a - // dotted name is now subject to the ` "name"` line bound in - // `validateAddressParts`: 995 characters fit a 998-octet line exactly - // (the period is `qtext`, so nothing is escaped and the bound is - // exact), 996 do not. Before the fix the same 996-character name - // rendered unquoted and folded at its spaces. + it('is bounded by the quoted-string line guard, at the same edge as any other special', async () => { + // A quoted-string is one token `foldHeader` never splits: 995 characters + // fit a 998-octet line exactly (the period is `qtext`), 996 do not. const fits = `Dr. ${'a'.repeat(998 - 3 - 4)}`; expect(fits.length).toBe(995); const { text } = await build({ @@ -2142,30 +1673,13 @@ describe('buildMime', () => { }); }); - it('rejects bare addr-spec values containing a comma (header-injection guard)', async () => { - // Without this guard, `to: "victim@a.com,attacker@b.com"` slips - // through the unquoted addr-spec path and renders as two recipients. + it('rejects an addr-spec glued to an angle-bracket address', async () => { await expect( - normalizeEmail({ - from: 'a@x.dev', - to: 'victim@example.com,attacker@evil.dev', - subject: 's', - text: 't', - }), - ).rejects.toThrow(/to/); - }); - - it('rejects bare addr-spec containing semicolon, angle, or quotes', async () => { - for (const bad of ['a@x.dev;b@evil.dev', 'a@x.dev', '"weird"@x.dev']) { - await expect( - normalizeEmail({ from: bad, to: 'b@y.dev', subject: 's', text: 't' }), - ).rejects.toThrow(/from/); - } + normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't' }), + ).rejects.toThrow(/from/); }); it('rejects unbalanced quotes around display name', async () => { - // Pre-fix the parser had two independent optional quotes, so a - // single stray `"` slipped through. await expect( normalizeEmail({ from: '"Acme ', @@ -2192,11 +1706,8 @@ describe('buildMime', () => { }); it('folds long whitespace-separated headers (e.g. References) below 78 columns', async () => { - // 30 message IDs without commas — historical bug: foldHeader only - // split at address-list separators, so the entire References line - // emitted as one ~1KB string and breached RFC 5322's 998-octet hard - // limit. With whitespace-folding, every physical line stays well - // below 78 chars and CRLF + WSP starts each continuation. + // 30 message IDs without commas: the fold must split at whitespace, not + // only at address-list separators. const ids: Array = []; for (let i = 0; i < 30; i++) { ids.push(``); @@ -2283,10 +1794,8 @@ describe('buildMime', () => { }); it('folds a long non-ASCII subject under RFC 5322 §2.1.1 (998 octets)', async () => { - // 246 CJK characters used to render as a single 1005-octet physical - // line: past RFC 5322 §2.1.1's 998-octet MUST and RFC 5321 - // §4.5.3.1.6's 1000-octet SMTP text-line limit. Nothing downstream - // repairs it — the proxy forwards these bytes verbatim. + // Unfolded, 246 CJK characters are one 1005-octet line, past RFC 5322 + // §2.1.1's 998-octet MUST. const subject = '日本語'.repeat(82); expect([...subject]).toHaveLength(246); const { raw, text } = await build({ @@ -2313,26 +1822,9 @@ describe('buildMime', () => { expect(raw.byteLength).toBeGreaterThan(0); }); - it('folds a very long non-ASCII subject (500 CJK characters)', async () => { - const subject = '確認'.repeat(250); - const { text } = await build({ - from: 'a@x.dev', - to: 'b@y.dev', - subject, - text: 't', - }); - const encoder = new TextEncoder(); - for (const line of text.split('\r\n')) { - expect(encoder.encode(line).byteLength).toBeLessThanOrEqual(998); - } - expect(decodeEncodedWords(unfoldHeader(text, 'Subject'))).toBe(subject); - }); - it('folds long non-ASCII display names in From and in address lists', async () => { - // Display names run through the same encoder, so `From:` and every - // recipient list inherits the same unbounded-line bug. The - // multi-recipient case additionally exercises the address-list fold - // branch, which never re-folded inside a single part. + // Display names run through the same encoder; the recipient list also + // exercises the address-list fold branch. const name = '株式会社ボイドゼロの営業部'.repeat(20); const { text } = await build({ from: { email: 'j@x.dev', name }, @@ -2384,9 +1876,7 @@ describe('buildMime', () => { }); it('keeps folding long ASCII header values at whitespace (control)', async () => { - // Pure-ASCII values never reach the encoder, so this pins the - // pre-existing whitespace-folding behaviour against regressions from - // the encoded-word change. + // Pure-ASCII values never reach the encoder; they fold at whitespace. const words: Array = []; for (let i = 0; i < 40; i++) { words.push(`word${i.toString().padStart(3, '0')}`); @@ -2413,12 +1903,8 @@ function bodyOf(text: string): string { } describe('buildMime body size projection', () => { - // A 4 MiB non-ASCII body is 12.6 MiB once quoted-printable encoded, so it - // can never fit. Before the projection, `buildMime` found that out only - // after encoding it — measured at ~11x the UTF-8 bytes live — and a body a - // few times over the limit terminated a 128 MB isolate instead of - // returning `MIME_ERROR`. The projection is a single allocation-free pass - // over the string, so this test is fast and the rejected string is small. + // A 4 MiB non-ASCII body is 12.6 MiB once quoted-printable encoded, so the + // projection refuses it before encoding. it('rejects a body that cannot fit before encoding it', async () => { const cjk = '漢'.repeat(Math.ceil((4 * 1024 * 1024) / 3)); const email = await normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: cjk }); @@ -2450,11 +1936,9 @@ describe('buildMime body size projection', () => { }); it('never rejects a message that fits, and leaves the final check as the authority', async () => { - // 3.3 MiB of CJK projects to ~10.38 MiB (under the cap, since the - // projection ignores soft breaks and headers) but renders to ~10.8 MiB. - // The projection must let it through; the post-render check must reject - // it. One full ~10 MB render, so it carries its own timeout (0.4 s here, - // several times that on a loaded CI runner). + // The projection ignores soft breaks and headers, so this body projects + // under the cap but renders over it: the post-render check must refuse it. + // One full render, so it carries its own timeout. const nearly = '漢'.repeat(Math.ceil((1.65 * 1024 * 1024) / 3)); const email = await normalizeEmail({ from: 'a@x.dev', @@ -2491,11 +1975,7 @@ describe('buildMime header size projection', () => { } // A non-ASCII header value is B-encoded into 45-byte words of 72 octets - // each and folded one per line: 5 octets for every CJK character, so 3M - // characters (9 MB of UTF-8) is a 15 MB header. Before the header bound, - // `buildMime` encoded, folded, joined and re-encoded all of that before - // it measured anything, and a 7M-character subject terminated a 128 MB - // isolate instead of returning `MIME_ERROR`. + // each and folded one per line: 5 octets for every CJK character. it('rejects a subject that cannot fit before encoding it', async () => { await expectProjected({ from: 'a@x.dev', @@ -2547,43 +2027,6 @@ describe('buildMime header size projection', () => { expect(text).toContain('X-Note: short note'); }); - // The runner's own heap is not capped, so the pin runs the projection in - // a child with the isolate's cap. 60k empty headers with 996-character - // names are 60 MB of names the caller already holds; rendering them held - // the folded lines, their join and `rawText` beside the names — three - // copies, 180 MB live — and died with `FATAL ERROR: Reached heap limit` - // before the size check (measured). The projection refuses them at an - // 88 MB peak, in 173 ms. - it('refuses 60 MB of header names inside a 128 MB heap', () => { - const mimeModule = pathToFileURL( - join(import.meta.dirname, '../../src/runtime/email/mime.ts'), - ).href; - const child = ` - const { normalizeEmail, buildMime } = await import(process.argv[1]); - const headers = {}; - for (let i = 0; i < 60_000; i++) headers['X' + i + 'a'.repeat(996 - 1 - String(i).length)] = ''; - const t0 = performance.now(); - let error; - try { const email = await normalizeEmail({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't', headers }); buildMime(email); } catch (e) { error = e; } - console.log(JSON.stringify({ code: error?.code, message: error?.message, ms: performance.now() - t0 })); - `; - const stdout = execFileSync( - process.execPath, - ['--max-old-space-size=128', '--input-type=module', '-e', child, mimeModule], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }, - ); - const result = JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { - code?: string; - message?: string; - ms: number; - }; - expect(result.code).toBe('MIME_ERROR'); - expect(result.message).toContain('16 KiB'); - // 173 ms measured locally; the bound leaves room for CI's slower runner - // (about 6x), where the old shape did not finish at all. - expect(result.ms).toBeLessThan(5000); - }, 30_000); - it('never charges the whitespace that folding collapses', async () => { // `foldHeader` re-joins a long value from its non-WSP tokens, so 11 MiB // of spaces renders as one. A bound that counted `length` would reject @@ -2597,11 +2040,8 @@ describe('buildMime header size projection', () => { expect(text).toContain('Subject: a b\r\n'); }); - // `replyEmail` threads `In-Reply-To` and `References` straight off the - // INBOUND message, so a remote sender controls them up to the whole raw - // message. They are rendered verbatim — never `encodeWord`ed — but folded - // and joined like any other header, so an oversized chain was rendered in - // full before the post-render check saw it. + // `replyEmail` copies the threading headers off the inbound message, so a + // remote sender controls their size. it('counts the threading headers', async () => { // 900k twelve-character ids: 10.8 MB of non-WSP before a single space. await expectProjected({ @@ -2620,17 +2060,9 @@ describe('buildMime header size projection', () => { }); }); - // An attachment part's own headers are built outside `foldHeader`'s - // encoded path: `Content-Type: ; name=""`, - // `Content-Disposition: ; filename=""` and - // `Content-ID: `. Each of the three values is charged, and - // each is now also bounded per attachment in `normalizeAttachment` (see - // 'media type syntax of an attachment' and 'Content-ID syntax of an - // attachment'), so none reaches the projection over-long through - // `normalizeEmail`. The charge stays — `buildMime` is the authority on - // the final size, whatever handed it the email — so it is pinned on a - // hand-built `NormalizedEmail`, as the attachment-count test is. - it('counts an attachment Content-ID, even though normalizeAttachment now bounds it', () => { + // `normalizeAttachment` bounds the Content-ID, so the charge is pinned on a + // hand-built email: `buildMime` is the authority on the final size. + it('counts an attachment Content-ID on a hand-built email', () => { const over = 'x'.repeat(11 * 1024 * 1024); expect(() => buildMime({ @@ -2654,13 +2086,8 @@ describe('buildMime header size projection', () => { ).toThrow(PROJECTION); }); - // `normalizeAttachment` bounds ONE filename at 986 characters, not the - // sum, and nothing bounds the part count: 50k zero-byte attachments with - // legal filenames are a 106 MB message — each filename rendered twice, - // inside 117 octets of boundary and framing — that was held three to - // four times over between the parts, their join, `rawText` and `raw`, - // and terminated a 128 MB isolate before the final size check. Every - // part is charged its filename twice plus that framing. + // `normalizeAttachment` bounds one filename, not the sum, so every part is + // charged its filename twice plus the framing around it. it('counts every attachment part: its filename twice, and the framing around it', async () => { const filename = 'a'.repeat(986); const empty = new Uint8Array(0); @@ -2673,13 +2100,12 @@ describe('buildMime header size projection', () => { text: 't', attachments: parts(25_000), }); - // The bound is exact for a part with no content: 5,012 parts project to - // 640 octets under the cap and render to 10.66 MB, so the post-render - // check — not the projection — must be the one to refuse them. + // The bound is exact for a part with no content: these project under the + // cap and render over it, so the post-render check refuses them. await expect( build({ from: 'a@x.dev', to: 'b@y.dev', subject: 's', text: 't', attachments: parts(2_506) }), ).rejects.toThrow(/message size \d+ bytes exceeds Cloudflare limit/); - // And 4,900 parts, a 10.42 MB message, build. + // And a few parts fewer build. const { raw } = await build({ from: 'a@x.dev', to: 'b@y.dev', @@ -2691,8 +2117,7 @@ describe('buildMime header size projection', () => { }); it('never rejects a References chain that fits, and never charges the whitespace it folds away', async () => { - // A 5 MB chain of short ids is a legal message: it folds at its spaces - // into 76-column lines and renders under the cap. + // A chain of short ids under the cap folds at its spaces and renders. const id = ''; const chain = Array.from( { length: Math.floor((2.5 * 1024 * 1024) / (id.length + 1)) }, @@ -2721,12 +2146,9 @@ describe('buildMime header size projection', () => { }); it('never rejects a header-heavy message that fits, and leaves the final check as the authority', async () => { - // 2.15M CJK characters project to ~10.32 MiB (4.8 octets each: the bound - // omits the fold's CRLF per word) but render to ~10.75 MB at 5 octets - // each. The projection must let it through; the post-render check must - // reject it. This is the one case that needs a full ~10 MB render (the - // property is AT the boundary), so it carries its own timeout: 0.8 s - // here, 5.4 s on a loaded 4-vCPU CI runner. + // The projection charges 4.8 octets per CJK character (it omits the fold's + // CRLF per word) against 5 rendered, so this subject projects under the cap + // but renders over it. One full render, so it carries its own timeout. const nearly = '漢'.repeat(1_075_000); const email = await normalizeEmail({ from: 'a@x.dev', @@ -2744,13 +2166,8 @@ describe('buildMime header size projection', () => { }); describe('foldHeader memory', () => { - // A legal 3 MB subject of one-letter words is 1.5M fold points. The - // earlier fold kept a slice, a `{ text, comma }` object and a cons-string - // node per token — about 65 bytes of heap per input character — and - // terminated a 128 MB isolate on a message that fit. The fold now keeps - // only its finished lines (~1 byte per character). This pins the - // rendered shape; the memory bound is measured, not asserted, because - // the test runner's heap is not the isolate's. + // A legal subject of 1.5M fold points. The runner's heap is not the + // isolate's, so this pins the rendered shape, not the memory. it('folds a legal subject of millions of one-letter words', async () => { const words = 1_500_000; const { raw, text } = await build({ @@ -2768,18 +2185,8 @@ describe('foldHeader memory', () => { }); describe('body line scan memory', () => { - // A legal 6 MB body of 3M two-character lines — pasted logs, say — renders - // to 9 MB. The encoding decision used to strip every line break with a - // global `replace` and `split` the body into an array of lines, the 7bit - // path re-joined it with another global `replace` (V8 keeps two or three - // parts-array slots per match), and the post-render line check `split` - // the whole message again: 240 MB of heap, all of it per LINE and - // invisible to any size projection, on a message that fit a 128 MB - // isolate four times over. Every pass is now one `charCodeAt` scan and - // the 7bit body is written into a buffer of its own size (measured, 32 - // MB). The memory bound is measured, not asserted, because the test - // runner's heap is not the isolate's; this pins the rendered shape and - // that the scans stay linear. + // A legal body of millions of short lines. The runner's heap is not the + // isolate's, so this pins the rendered shape and that the scans stay linear. it('builds a legal message of millions of short lines in text and html', async () => { const lines = 750_000; const started = performance.now(); @@ -2790,8 +2197,7 @@ describe('body line scan memory', () => { text: 'a\n'.repeat(lines), html: '\n'.repeat(lines), }); - // A gross-regression guard only: the previous code died here, it did not - // slow down. 0.2 s locally; a loaded CI runner is several times slower. + // A gross-regression guard only. expect(performance.now() - started).toBeLessThan(20_000); expect(raw.byteLength).toBeLessThanOrEqual(MAX_MESSAGE_BYTES); expect(raw.byteLength).toBeGreaterThan(lines * 5); @@ -2803,8 +2209,6 @@ describe('body line scan memory', () => { }); describe('quotedPrintable encoder', () => { - // Fixtures captured from the string-concatenating encoder these replace, - // so the byte-buffer encoder is pinned to the exact previous output. const cases: Array<[string, string, string]> = [ [ 'soft-breaks at 76 columns', diff --git a/packages/void/test/unit/email-own-account.test.ts b/packages/void/test/unit/email-own-account.test.ts index fd03a230c..1d3419195 100644 --- a/packages/void/test/unit/email-own-account.test.ts +++ b/packages/void/test/unit/email-own-account.test.ts @@ -11,7 +11,7 @@ * `ForwardableEmailMessage` and ignores the return value, so the * generated entry must ENACT the recorded actions on it */ -import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { describe, expect, it, vi } from 'vite-plus/test'; import { compileWorkerEntry } from '../../src/router/compile.ts'; import { sendEmail } from '../../src/runtime/email.ts'; import { @@ -256,19 +256,8 @@ describe('sendEmail (SEND_EMAIL binding)', () => { * A `ForwardableEmailMessage` as workerd hands it to `email()`: a one-shot * raw stream, a `Headers` instance, and the three native methods, each * recording its call. - * - * `verifiedDestinations` models the one refusal Cloudflare documents for - * `forward(rcptTo)` — "Forward this email message to a verified destination - * address of the account" — so a fake with the list set throws, as the real - * method does, for anything that is not on it. Left unset, every address is - * accepted, which is what the fake did before the option existed. */ -function nativeMessage(opts: { - from: string; - to: string; - mime: string; - verifiedDestinations?: ReadonlyArray; -}): { +function nativeMessage(opts: { from: string; to: string; mime: string }): { message: ForwardableEmailMessage; calls: { rejects: Array; @@ -302,9 +291,6 @@ function nativeMessage(opts: { calls.rejects.push(reason); }, async forward(to: string, fwdHeaders?: Headers) { - if (opts.verifiedDestinations && !opts.verifiedDestinations.includes(to)) { - throw bindingError('E_RECIPIENT_NOT_ALLOWED', `destination address ${to} not verified`); - } calls.forwards.push({ to, headers: fwdHeaders }); return { messageId: 'fwd' }; }, @@ -364,10 +350,6 @@ function buildHandleEmail( } describe('generated handleEmail on a native ForwardableEmailMessage', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - it('enacts setReject on the real message', async () => { const handleEmail = buildHandleEmail(async (message) => { message.setReject('no thanks'); @@ -425,11 +407,8 @@ describe('generated handleEmail on a native ForwardableEmailMessage', () => { }); it('fires nothing on the native message when a later forward carries an invalid header record', async () => { - // The plain-record form is stored verbatim, so a value `new Headers()` - // rejects used to surface only during native replay — after the first - // forward had already fired. Cloudflare then sees a failed invocation - // for a message that was partly delivered. The bad header must fail in - // the handler's own call, before anything is enacted. + // The plain-record form is stored verbatim, so a bad header must fail in + // the handler's own call, before the first forward is enacted. const handleEmail = buildHandleEmail(async (message) => { await message.forward('ops@acme.com'); // The record form is a runtime affordance (JS handlers, or a cast): @@ -448,8 +427,6 @@ describe('generated handleEmail on a native ForwardableEmailMessage', () => { (e: unknown) => e, ); - // The partial delivery is the bug: nothing may have reached the native - // message. Asserted before the error shape so a regression names it. expect(calls.forwards).toEqual([]); expect(calls.replies).toEqual([]); expect(calls.rejects).toEqual([]); @@ -457,75 +434,6 @@ describe('generated handleEmail on a native ForwardableEmailMessage', () => { expect((outcome as MimeError).message).toMatch(/^forward: invalid header/); }); - it('fires nothing on the native message when a later forward carries a malformed address', async () => { - // The address half of the case above. `forward()` used to check only the - // length, so `not-an-address` was recorded and surfaced during native - // replay — Cloudflare refuses a destination that is not a verified - // address — after the first forward had already fired. The fake models - // that refusal; the proxy must fail in the handler's own call instead. - const handleEmail = buildHandleEmail(async (message) => { - await message.forward('ops@acme.com'); - await message.forward('not-an-address'); - }); - const { message, calls } = nativeMessage({ - from: 'sender@example.com', - to: 'support@mail.acme.com', - mime: INBOUND_MIME, - verifiedDestinations: ['ops@acme.com'], - }); - - const outcome = await handleEmail(message, {}, {}).then( - () => 'resolved', - (e: unknown) => e, - ); - - expect(calls.forwards).toEqual([]); - expect(calls.replies).toEqual([]); - expect(calls.rejects).toEqual([]); - expect(outcome).toBeInstanceOf(MimeError); - expect((outcome as MimeError).code).toBe('INVALID_TO'); - expect((outcome as MimeError).message).toBe( - 'to: address is not a valid email address (ASCII local part and dotted domain, ' + - "RFC 5322 dot-atom; at most 64 characters before the '@' and 254 in total).", - ); - }); - - it('fires nothing on the native message when a later hand-built reply carries a malformed address', async () => { - // The `{ from, to, raw }` reply form bypasses `normalizeEmail`; it used to - // check only the length, so the malformed `to` reached Cloudflare's - // native `reply()` after the forward had fired. Same record-time rule - // as `forward(to)` now: the handler's own call fails, nothing replays. - const handleEmail = buildHandleEmail(async (message) => { - await message.forward('ops@acme.com'); - await message.reply({ - from: 'support@mail.acme.com', - to: 'not-an-address', - raw: new TextEncoder().encode('Subject: re\r\n\r\nhi\r\n'), - } as unknown as Parameters[0]); - }); - const { message, calls } = nativeMessage({ - from: 'sender@example.com', - to: 'support@mail.acme.com', - mime: INBOUND_MIME, - verifiedDestinations: ['ops@acme.com'], - }); - - const outcome = await handleEmail(message, {}, {}).then( - () => 'resolved', - (e: unknown) => e, - ); - - expect(calls.forwards).toEqual([]); - expect(calls.replies).toEqual([]); - expect(calls.rejects).toEqual([]); - expect(outcome).toBeInstanceOf(MimeError); - expect((outcome as MimeError).code).toBe('INVALID_TO'); - expect((outcome as MimeError).message).toBe( - 'to: address is not a valid email address (ASCII local part and dotted domain, ' + - "RFC 5322 dot-atom; at most 64 characters before the '@' and 254 in total).", - ); - }); - it('enacts replyEmail as a real EmailMessage sent from the delivered address', async () => { const handleEmail = buildHandleEmail(async (message) => { // Both reads of the one-shot native stream must work: the proxy @@ -576,24 +484,6 @@ describe('generated handleEmail on a native ForwardableEmailMessage', () => { expect(reply.from).toBe('acme+noreply@mail.void.cloud'); }); - it('enacts a reject that followed a forward as the reject alone', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const handleEmail = buildHandleEmail(async (message) => { - await message.forward('ops@acme.com'); - message.setReject('changed my mind'); - }); - const { message, calls } = nativeMessage({ - from: 'sender@example.com', - to: 'support@mail.acme.com', - mime: INBOUND_MIME, - }); - - await handleEmail(message, {}, {}); - - expect(calls.forwards).toEqual([]); - expect(calls.rejects).toEqual(['changed my mind']); - }); - it('hands the native setReject() the reason the platform router would send', async () => { // The parser unfolds the encoded CRLF, but the decoded subject still // contains a NUL. The proxy must bound and normalize the reason before @@ -622,37 +512,6 @@ describe('generated handleEmail on a native ForwardableEmailMessage', () => { expect(actions).toEqual([{ type: 'reject', reason: expected }]); }); - it('fires nothing on the native message when a later forward carries too many headers', async () => { - // Cloudflare's native `forward()` accepts any bag, so on your own account - // a 33-header forward used to go out — while the platform router drops - // the WHOLE action list for the same handler. The router's bounds now - // fail the handler's own call, before anything is enacted, in both. - const handleEmail = buildHandleEmail(async (message) => { - await message.forward('ops@acme.com'); - const bag = new Headers(); - for (let i = 0; i < 33; i++) { - bag.set(`X-Void-${i}`, 'v'); - } - await message.forward('archive@acme.com', bag); - }); - const { message, calls } = nativeMessage({ - from: 'sender@example.com', - to: 'support@mail.acme.com', - mime: INBOUND_MIME, - }); - - const outcome = await handleEmail(message, {}, {}).then( - () => 'resolved', - (e: unknown) => e, - ); - - expect(calls.forwards).toEqual([]); - expect(calls.replies).toEqual([]); - expect(calls.rejects).toEqual([]); - expect(outcome).toBeInstanceOf(MimeError); - expect((outcome as MimeError).message).toBe('forward: 33 headers, over the 32-header limit.'); - }); - it('returns actions for an authenticated receipt envelope without native enaction', async () => { const handleEmail = buildHandleEmail(async (message) => { message.setReject('spam'); @@ -677,7 +536,7 @@ describe('generated handleEmail on a native ForwardableEmailMessage', () => { * without a `+` prefix, while * provisioning still injects `__VOID_PROJECT_SLUG` unconditionally. The * generated `handleEmail` must suppress the slug so the dispatcher runs in - * no-prefix mode (Mode B item 4) — left applied, the strip would take + * no-prefix mode — left applied, the strip would take * everything up to the first `+` and `support+T-42@acme.com` would dispatch * as `T-42` to `_default` or a wrong `[user]` param. */ diff --git a/packages/void/test/unit/email-send.test.ts b/packages/void/test/unit/email-send.test.ts index c9f19f41a..b17058a4f 100644 --- a/packages/void/test/unit/email-send.test.ts +++ b/packages/void/test/unit/email-send.test.ts @@ -71,6 +71,17 @@ const PROXY_ENV_KEYS = { __PROJECT_ID: 'p_test', }; +/** Sends through `__VOID_PROXY`; `sendInDev` keeps a harness sink from capturing it. */ +function send( + proxy: ReturnType['proxy'], + to: string | Array = 'b@y.dev', + extra: Partial[0]> = {}, +): ReturnType { + return withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => + sendEmail({ from: 'a@x.dev', to, subject: 's', text: 't', sendInDev: true, ...extra }), + ); +} + describe('sendEmail (test harness)', () => { it('captures sends and returns ok with a dev id', async () => { const harness = createEmailTestHarness(); @@ -116,13 +127,8 @@ describe('sendEmail (test harness)', () => { } }); - // A captured result must be the result a deploy returns. Both production - // transports send to `uniqueRecipients()` — one envelope per address, - // matched case-insensitively and reported lowercased — while the capture - // path mapped every `to`/`cc`/`bcc` occurrence in its original case, so a - // test written against it expected four deliveries where production makes - // two. The captured MESSAGE still carries the fields as given: the inbox - // shows what was asked for, `ids` reports what would be sent. + // `ids` reports what production sends (one lowercased entry per unique + // address); the captured message keeps the fields as given. it('reports one id per unique address, the way the production transports do', async () => { const opts = { from: 'a@x.dev', @@ -238,63 +244,6 @@ describe('sendEmail (test harness)', () => { } }); - it('treats an undocumented native binding rejection as an unknown outcome', async () => { - const cause = new Error('connection closed after submission'); - const send = vi.fn().mockRejectedValue(cause); - const result = await withRuntimeEnv({ SEND_EMAIL: { send } }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 'ambiguous rejection', - text: 'body', - }), - ); - - expect(result).toMatchObject({ - ok: false, - deliveries: [ - { - recipient: 'b@y.dev', - state: 'outcome_unknown', - error: { - code: 'OUTCOME_UNKNOWN', - message: 'connection closed after submission', - cause, - }, - }, - ], - }); - }); - - it('keeps a documented native provider refusal definitive', async () => { - const cause = Object.assign(new Error('recipient is not allowed'), { - code: 'E_RECIPIENT_NOT_ALLOWED', - }); - const result = await withRuntimeEnv( - { SEND_EMAIL: { send: vi.fn().mockRejectedValue(cause) } }, - () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 'provider refusal', - text: 'body', - }), - ); - - expect(result).toMatchObject({ - ok: false, - deliveries: [ - { - recipient: 'b@y.dev', - error: { code: 'UNVERIFIED_DESTINATION', cause }, - }, - ], - }); - if (!result.ok && 'deliveries' in result) { - expect(result.deliveries[0].state).toBeUndefined(); - } - }); - it('clear() empties the harness without disposing the sink', async () => { const harness = createEmailTestHarness(); try { @@ -346,27 +295,8 @@ describe('sendEmail (error mapping)', () => { } }); - it('returns BINDING_MISSING when no sink and no __VOID_PROXY', async () => { - const result = await withRuntimeEnv({}, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); - expect(result.ok).toBe(false); - if (!result.ok && 'error' in result) { - expect(result.error.code).toBe('BINDING_MISSING'); - } - }); - it('returns INVALID_TO for an oversize recipient list before any transport is tried', async () => { - // Pre-flight: it must come back as a top-level `error`, from the - // harness (`void dev`) exactly as it would from a deployed worker — the - // proxy's own 100-recipient refusal used to be the first place a list - // like this was ever rejected, and only after the tenant had built it. + // Pre-flight: a top-level `error` from the harness, as from a deployed worker. const harness = createEmailTestHarness(); try { const result = await sendEmail({ @@ -504,9 +434,6 @@ describe('sendEmail (from defaulting)', () => { }); it('returns INVALID_FROM (never a baked-in domain) when __VOID_EMAIL_DOMAIN is missing', async () => { - // Regression guard for the whole point of the binding: with a slug but - // no configured domain, the send must fail loudly rather than quietly - // addressing the production zone. const harness = createEmailTestHarness(); try { const result = await withRuntimeEnv({ __VOID_PROJECT_SLUG: 'acme' }, () => @@ -557,8 +484,6 @@ describe('sendEmail (from defaulting)', () => { }); it('returns INVALID_FROM (not MIME_ERROR) when from is explicitly null', async () => { - // Regression: `null` previously slipped past the empty-from guard - // and was passed to parseAddress, surfacing as MIME_ERROR. const harness = createEmailTestHarness(); try { const result = await sendEmail({ @@ -607,69 +532,6 @@ describe('sendEmail (from defaulting)', () => { harness.dispose(); } }); - - it('rejects an explicit 65-character local part before capture, like the slug default', async () => { - // The slug default above refuses to BUILD a 65-octet local part, but the - // same address passed explicitly used to be captured as a success — - // under `void dev` and here — and could only bounce at an MTA. - const harness = createEmailTestHarness(); - try { - const from = await sendEmail({ - from: `${'a'.repeat(57)}+noreply@${MAIL_DOMAIN}`, - to: 'b@y.dev', - subject: 's', - text: 't', - }); - expect(from.ok).toBe(false); - if (!from.ok && 'error' in from) { - expect(from.error.code).toBe('INVALID_FROM'); - expect(from.error.message).toMatch( - /^from: address is not a valid email address \(ASCII local part/, - ); - } - const to = await sendEmail({ - from: 'a@x.dev', - to: `${'a'.repeat(65)}@y.dev`, - subject: 's', - text: 't', - }); - expect(to.ok).toBe(false); - if (!to.ok && 'error' in to) { - expect(to.error.code).toBe('INVALID_TO'); - } - expect(harness.messages).toHaveLength(0); - } finally { - harness.dispose(); - } - }); - - it('rejects a 257-character address before capture, whatever its local part', async () => { - // `a@` + a 255-octet domain is under the 320 total and the 64-octet - // local part, and used to be captured as a success — under `void dev` - // and here — when RFC 5321 §4.5.3.1.3 caps the path at 256 octets. - const label = 'b'.repeat(63); - const over = `a@${[label, label, label, label].join('.')}`; - expect(over).toHaveLength(257); - const harness = createEmailTestHarness(); - try { - const from = await sendEmail({ from: over, to: 'b@y.dev', subject: 's', text: 't' }); - expect(from.ok).toBe(false); - if (!from.ok && 'error' in from) { - expect(from.error.code).toBe('INVALID_FROM'); - expect(from.error.message).toMatch( - /^from: address is not a valid email address \(ASCII local part/, - ); - } - const to = await sendEmail({ from: 'a@x.dev', to: over, subject: 's', text: 't' }); - expect(to.ok).toBe(false); - if (!to.ok && 'error' in to) { - expect(to.error.code).toBe('INVALID_TO'); - } - expect(harness.messages).toHaveLength(0); - } finally { - harness.dispose(); - } - }); }); describe('sendEmail (proxy round trip)', () => { @@ -678,15 +540,7 @@ describe('sendEmail (proxy round trip)', () => { status: 200, body: { ok: true, ids: [{ recipient: 'b@y.dev', messageId: 'cf-1' }] }, })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(calls).toHaveLength(1); expect(calls[0].url).toBe('https://email-proxy/email/send'); const body = calls[0].body as Record; @@ -703,40 +557,12 @@ describe('sendEmail (proxy round trip)', () => { } }); - it('falls back to the locally-generated messageId when the proxy returns an empty one', async () => { - const { proxy } = fakeProxy(() => ({ - status: 200, - body: { ok: true, ids: [{ recipient: 'b@y.dev', messageId: '' }] }, - })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.ids[0].messageId).toMatch(/^<.+@x\.dev>$/); - } - }); - it('surfaces QUOTA_EXCEEDED when the proxy denies the send', async () => { const { proxy } = fakeProxy(() => ({ status: 429, body: { ok: false, error: { code: 'QUOTA_EXCEEDED', message: 'monthly cap reached' } }, })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('QUOTA_EXCEEDED'); @@ -750,15 +576,7 @@ describe('sendEmail (proxy round trip)', () => { throw new Error('network unreachable'); }, }; - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('OUTCOME_UNKNOWN'); @@ -791,15 +609,7 @@ describe('sendEmail (proxy round trip)', () => { ); }, }; - const pending = withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const pending = send(proxy); await started; await vi.advanceTimersByTimeAsync(0); @@ -830,69 +640,17 @@ describe('sendEmail (proxy round trip)', () => { ); }, }; - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result).toMatchObject({ ok: false, error: { code: 'OUTCOME_UNKNOWN' } }); await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); }); - it('passes mid-batch deliveries through unchanged', async () => { - const { proxy } = fakeProxy(() => ({ - status: 200, - body: { - ok: false, - deliveries: [ - { recipient: 'a@y.dev', ok: true, messageId: 'cf-a' }, - { - recipient: 'b@y.dev', - ok: false, - error: { code: 'UNVERIFIED_DESTINATION', message: 'b@y.dev not verified' }, - }, - { recipient: 'c@y.dev', ok: true, messageId: 'cf-c' }, - ], - }, - })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: ['a@y.dev', 'b@y.dev', 'c@y.dev'], - subject: 's', - text: 't', - sendInDev: true, - }), - ); - expect(result.ok).toBe(false); - if (!result.ok && 'deliveries' in result) { - expect(result.deliveries).toHaveLength(3); - const b = result.deliveries.find((d) => d.recipient === 'b@y.dev'); - expect(b?.ok).toBe(false); - if (b && !b.ok) { - expect(b.error.code).toBe('UNVERIFIED_DESTINATION'); - } - } - }); - it('coerces unknown error codes to UPSTREAM_ERROR', async () => { const { proxy } = fakeProxy(() => ({ status: 500, body: { ok: false, error: { code: 'WEIRD_NEW_CODE', message: 'something broke' } }, })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('UPSTREAM_ERROR'); @@ -904,15 +662,7 @@ describe('sendEmail (proxy round trip)', () => { 'keeps the upstream fallback for incomplete error fields: %j', async (error) => { const { proxy } = fakeProxy(() => ({ status: 500, body: { ok: false, error } })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result).toMatchObject({ ok: false, error: { code: 'UPSTREAM_ERROR', message: 'email: upstream send failed' }, @@ -929,15 +679,7 @@ describe('sendEmail (proxy round trip)', () => { deliveries: [{ recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }], }, })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result).toEqual({ ok: false, deliveries: [{ recipient: 'b@y.dev', ok: true, messageId: 'cf-b' }], @@ -954,15 +696,7 @@ describe('sendEmail (proxy round trip)', () => { ['primitive array body', []], ])('returns OUTCOME_UNKNOWN (no throw) for malformed proxy response: %s', async (_, payload) => { const { proxy } = fakeProxy(() => ({ status: 200, body: payload })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('OUTCOME_UNKNOWN'); @@ -992,15 +726,7 @@ describe('sendEmail (proxy round trip)', () => { ], ])('surfaces the proxy refusal text for %s', async (_, status, payload, expected) => { const { proxy } = fakeProxy(() => ({ status, body: payload })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('UPSTREAM_ERROR'); @@ -1011,15 +737,7 @@ describe('sendEmail (proxy round trip)', () => { it('still reports a flat error body on a 2xx as malformed', async () => { const { proxy } = fakeProxy(() => ({ status: 200, body: { error: 'nope' } })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('OUTCOME_UNKNOWN'); @@ -1036,15 +754,7 @@ describe('sendEmail (proxy round trip)', () => { }); }, }; - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(result.ok).toBe(false); if (!result.ok && 'error' in result) { expect(result.error.code).toBe('OUTCOME_UNKNOWN'); @@ -1062,17 +772,7 @@ describe('sendEmail (proxy round trip)', () => { }, }; }); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - cc: 'B@Y.DEV', - bcc: ['b@y.dev', 'c@y.dev'], - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy, 'b@y.dev', { cc: 'B@Y.DEV', bcc: ['b@y.dev', 'c@y.dev'] }); const recipients = (calls[0].body as { recipients: Array }).recipients; expect(recipients).toEqual(['b@y.dev', 'c@y.dev']); expect(result.ok).toBe(true); @@ -1082,20 +782,8 @@ describe('sendEmail (proxy round trip)', () => { }); }); -// The proxy answers `ok: true` only after every requested recipient was -// accepted by the allowlist AND confirmed by `SEND_EMAIL.send()`, and its -// `ids` is `deliveries.map(({ recipient, messageId }))` — one entry per -// requested address, `messageId` always a string (`''` when CF returned -// none). So on `ok: true` the list is the request's recipient set, and a -// list that does not account for every recipient is a schema-skewed or -// corrupt proxy: dropping the bad entry and still returning `ok: true` -// would tell the caller a delivery was confirmed when none was. +// ok:true must account for every requested recipient. describe('sendEmail (proxy delivery list)', () => { - const send = (proxy: ReturnType['proxy'], to: string | Array) => - withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ from: 'a@x.dev', to, subject: 's', text: 't', sendInDev: true }), - ); - it.each([ [ 'a null entry', @@ -1195,21 +883,8 @@ describe('sendEmail (proxy delivery list)', () => { }); }); -// The mid-batch `deliveries` list is built from the same per-recipient list -// as `ids`: one entry per requested address, each either -// `{ recipient, ok: true, messageId }` or `{ recipient, ok: false, error }` -// (the proxy always attaches `error` to a failed entry — both its producer -// paths do). A list that skips, repeats or invents a recipient, or an entry -// that does not state its outcome, cannot be acted on: skipping the bad entry -// would leave a recipient with no outcome, and treating an entry with no `ok` -// as failed could lead to a duplicate delivery. Such a response is a -// schema-skewed or corrupt proxy and is refused as a whole, under the same -// rules as the success path. +// A mid-batch list must state one outcome per requested recipient, or it is refused whole. describe('sendEmail (proxy mid-batch delivery list)', () => { - const send = (proxy: ReturnType['proxy'], to: string | Array) => - withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ from: 'a@x.dev', to, subject: 's', text: 't', sendInDev: true }), - ); const failed = (recipient: string) => ({ recipient, ok: false, @@ -1452,15 +1127,7 @@ describe('sendEmail (sendInDev escape hatch)', () => { status: 200, body: { ok: true, ids: [{ recipient: 'b@y.dev', messageId: 'cf-id-123' }] }, })); - const result = await withRuntimeEnv({ __VOID_PROXY: proxy, ...PROXY_ENV_KEYS }, () => - sendEmail({ - from: 'a@x.dev', - to: 'b@y.dev', - subject: 's', - text: 't', - sendInDev: true, - }), - ); + const result = await send(proxy); expect(harness.messages).toHaveLength(0); expect(calls).toHaveLength(1); expect(result.ok).toBe(true); @@ -1472,13 +1139,8 @@ describe('sendEmail (sendInDev escape hatch)', () => { } }); - // The test above hand-injects `__VOID_PROXY`, which `void dev` never - // produces: the platform adds that service binding only on a deployed - // worker, and the own-account `SEND_EMAIL` binding is deleted from the - // resolved worker config whenever `viteCommand === 'serve'`. So under a - // real dev server `sendInDev: true` bypasses the inbox and lands on - // nothing. Pin that, message text included, so re-adding a send binding - // to dev has to come past this test. + // `void dev` binds neither `__VOID_PROXY` nor `SEND_EMAIL`, so there + // `sendInDev: true` reaches no transport. it('returns BINDING_MISSING under a real `void dev` env (no transport to fall through to)', async () => { const assets = { calls: 0, From 0d608c294076b5f24a433738149cd136492e83b6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 15:26:34 +0800 Subject: [PATCH 009/127] docs(email): correct default sender and capture cap notes - Name which deploy target uses email.from versus the platform noreply sender - Restore the limit of the dev inbox capture cap for strings with no MIME footprint Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/email/dev-inbox.ts | 5 +++-- packages/void/src/runtime/email/types.ts | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/void/src/runtime/email/dev-inbox.ts b/packages/void/src/runtime/email/dev-inbox.ts index 9bc858ff5..a2af712be 100644 --- a/packages/void/src/runtime/email/dev-inbox.ts +++ b/packages/void/src/runtime/email/dev-inbox.ts @@ -73,8 +73,9 @@ export const DEV_INBOX_ACK_VALUE: string = 'captured'; /** * Capture body cap. 6x covers the base64 MIME plus the JSON copy of every - * caller string for a max-size message, so dev never refuses a send that - * production accepts. + * rendered caller string for a max-size message. Strings with no MIME + * footprint (a bcc name, a reserved header, folded-away whitespace) are not + * bounded by it, so a message padded with them is still refused. */ export const DEV_INBOX_MAX_CAPTURE_BYTES: number = 6 * MAX_MESSAGE_BYTES; diff --git a/packages/void/src/runtime/email/types.ts b/packages/void/src/runtime/email/types.ts index a026b8e11..3c910e3af 100644 --- a/packages/void/src/runtime/email/types.ts +++ b/packages/void/src/runtime/email/types.ts @@ -17,11 +17,11 @@ export interface Attachment { export interface SendEmailOptions { /** - * Sender envelope address. Optional — when omitted, the framework uses - * `email.from` from void.config.ts when it is set, otherwise - * `<__VOID_PROJECT_SLUG>+noreply@<__VOID_EMAIL_DOMAIN>` on a Void - * platform. Without either, `sendEmail` returns `INVALID_FROM` — it never - * falls back to a baked-in domain. + * Sender envelope address. Optional. When omitted, `void dev` and + * `void deploy --platform cloudflare` use `email.from` from void.config.ts, + * and a Void platform deploy uses `+noreply@`. + * Without either, `sendEmail` returns `INVALID_FROM`; it never falls back + * to a baked-in domain. */ from?: Address; to: Address | Array
; From 222713cce1b67d53a9c29b6a1291c65f87b625de Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 13:23:20 +0800 Subject: [PATCH 010/127] refactor(void): trim routing dead code, duplicate templates and comments - scanRoutes now requires the resolved inventory; drop the async scan fallbacks - routingPlugin requires getApplicationAnalysis; drop the output option and unreachable branches - emit migration routes and trigger auth through one shared helper each - merge the worker entry, queue and fetch client templates into one each - keep one asset extension list, drop unused runtime seams and generated consts - rename error renderer locals and cut long or wrong comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/application-routing.ts | 1 - packages/void/src/codegen/route-types.ts | 28 +- packages/void/src/index.ts | 2 - packages/void/src/plugin-routing.ts | 134 +----- packages/void/src/router/compile.ts | 422 +++--------------- packages/void/src/router/scan.ts | 71 +-- packages/void/src/router/websocket.ts | 14 +- packages/void/src/runtime/routing-assets.ts | 31 ++ packages/void/src/runtime/routing-errors.ts | 142 +++--- packages/void/src/runtime/routing.ts | 38 +- .../native-asset-cache-policy.test.ts | 10 +- .../resource-name-migration.test.ts | 5 +- .../integration/typed-routing-runtime.test.ts | 5 +- packages/void/test/unit/compile-auth.test.ts | 5 +- packages/void/test/unit/compile.test.ts | 90 ++-- packages/void/test/unit/fetch-worker.test.ts | 9 +- .../void/test/unit/plugin-routing.test.ts | 39 +- packages/void/test/unit/scan.test.ts | 10 +- .../void/test/unit/websocket-scan.test.ts | 4 +- 19 files changed, 319 insertions(+), 741 deletions(-) diff --git a/packages/void/src/application-routing.ts b/packages/void/src/application-routing.ts index eba8b0af3..48d7c6fb7 100644 --- a/packages/void/src/application-routing.ts +++ b/packages/void/src/application-routing.ts @@ -10,7 +10,6 @@ export async function completeApplicationAnalysis( const scan = await scanRoutes(analysis.root, { output: analysis.config.output, paths: analysis.paths, - environment: analysis.environment, inventory: { routes: analysis.routes, middleware: analysis.middleware, diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts index 5980babc1..e6a909510 100644 --- a/packages/void/src/codegen/route-types.ts +++ b/packages/void/src/codegen/route-types.ts @@ -232,30 +232,20 @@ export function generateRouteTypes( lines.push(` ${JSON.stringify(page.pattern)}: {`); - let importPath: string | null = null; - - if (page.serverPath) { - // Companion .server.ts — resolve directly - const absPath = `${pagesDir}/${page.serverPath}`; - importPath = relative(outDir, absPath); - } - + // Page actions are only detected in a companion .server file. + const importPath = relative(outDir, `${pagesDir}/${page.serverPath!}`); if (hasActions) { - if (importPath) { - const actionsImport = `(typeof import(${JSON.stringify(importPath)}))["actions"]`; - lines.push(` actions: { [K in keyof ${actionsImport}]: {`); - lines.push( - ` body: ExtractInput<${actionsImport}[K]["__validators"]> extends { body: infer B } ? B : Record;`, - ); - lines.push(' } };'); - } - } else if (importPath) { + const actionsImport = `(typeof import(${JSON.stringify(importPath)}))["actions"]`; + lines.push(` actions: { [K in keyof ${actionsImport}]: {`); + lines.push( + ` body: ExtractInput<${actionsImport}[K]["__validators"]> extends { body: infer B } ? B : Record;`, + ); + lines.push(' } };'); + } else { const importExpr = `(typeof import(${JSON.stringify(importPath)}))["action"]`; lines.push( ` body: ExtractInput<${importExpr}["__validators"]> extends { body: infer B } ? B : Record;`, ); - } else { - lines.push(' body: Record;'); } if (page.params.length > 0) { diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 5277dec3c..caf893878 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -897,7 +897,6 @@ export function voidPlugin(options?: { isAuthEnabled: () => false, devTriggerToken, headConfig: config.head, - output: config.output, prerenderPaths: config.routing?.prerender, target: config.target as 'node' | 'bun' | 'deno', // Pass the full routing section so the plugin can merge @@ -1032,7 +1031,6 @@ export function voidPlugin(options?: { getVersionMetadataBinding: () => versionMetadataBinding, devTriggerToken, headConfig: config.head, - output: config.output, prerenderPaths: config.routing?.prerender, remoteMode, // Pass the full routing section so the plugin can merge diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index a374f1eb3..5e68516ce 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; -import { analyzeApplicationSync, type ApplicationAnalysis } from './application-analysis.ts'; +import type { ApplicationAnalysis } from './application-analysis.ts'; import { completeApplicationAnalysis } from './application-routing.ts'; import { hasApiRoutes, @@ -19,7 +19,7 @@ import { findEnvFileForPaths } from './codegen/env-types.ts'; import { generateQueueTypes } from './codegen/queue-types.ts'; import { generateRouteTypes } from './codegen/route-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; -import { readConfig, getDatabaseDialect, type NotFoundHandling } from './config.ts'; +import { readConfig, getDatabaseDialect } from './config.ts'; import { collectPages, collectRevalidate, hasRevalidate } from './isr-config.ts'; import { readProjectPaths } from './project-paths.ts'; import { @@ -29,7 +29,6 @@ import { compileWorkerClient, compileNodeApp, compileNodeServer, - compileNodeClient, type CompileOptions, type SsrCompileOptions, type PagesCompileOptions, @@ -65,32 +64,16 @@ const LOAD_ID_FILTER = /^virtual:void-(routes|client-worker(?::(react|vue|svelte|solid))?|client-browser(?::(react|vue|svelte|solid))?)$/; type RoutingPluginOptions = { - getApplicationAnalysis?: ( - environment: RouteEnvironment, - refresh?: boolean, - ) => ApplicationAnalysis; + getApplicationAnalysis: (environment: RouteEnvironment, refresh?: boolean) => ApplicationAnalysis; isAuthEnabled: () => boolean; getVersionMetadataBinding?: () => string | undefined; devTriggerToken?: string; headConfig?: import('./pages/head.ts').HeadConfig; - output?: 'server' | 'static'; prerenderPaths?: Array; remoteMode?: boolean; bindingNames?: { d1?: string; kv?: string; r2?: string }; target?: 'node' | 'bun' | 'deno'; - /** - * Full `routing` section from `void.config.ts`. The plugin merges - * `_redirects` / `_headers` under Vite's `publicDir` with the config - * rules on every compile, matching the deploy-time merge order (file - * rules first, `void.config.ts` last — last match wins). - * - * The watcher also watches those two files during `vite dev` and - * re-runs the merge + invalidates the route module when either changes. - * - * The merged rules are emitted into every runtime that owns its routing. - * Managed Cloudflare builds omit them because platform dispatch owns the - * same pipeline. - */ + /** Merged with `_redirects` / `_headers` under Vite's `publicDir`; config rules take precedence. */ routing?: import('./config.ts').RoutingConfig; hasSandbox?: boolean; sandboxBindingName?: string; @@ -136,7 +119,6 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const normalizedQueuesDir = normalizePath(queuesDir); const normalizedEmailDir = normalizePath(emailDir); const normalizedPagesDir = normalizePath(pagesDir); - const normalizedSsrDir = normalizePath(ssrDir); const ssrEntryAbsPaths = new Set( [...SSR_SERVER_ENTRY_RELATIVE_PATHS, ...SSR_CLIENT_ENTRY_RELATIVE_PATHS].map((path) => normalizePath(join(ssrDir, path.split('/').pop()!)), @@ -169,14 +151,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } async function rescan(refresh = false) { - let analysis = options.getApplicationAnalysis?.(routeEnvironment, refresh); - if (!analysis) { - const config = readConfig(root); - analysis = analyzeApplicationSync(root, { - config: { ...config, output: options.output ?? config.output }, - environment: routeEnvironment, - }); - } + const analysis = options.getApplicationAnalysis(routeEnvironment, refresh); scan = (await completeApplicationAnalysis(analysis)).scan; authConfigPath = analysis.authConfigPath; const entries = validateSsrEntries(root, paths); @@ -212,7 +187,6 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ? { bindingNames: options.bindingNames, remotePath: resolveFile('#self/remote'), - envPath: resolveFile('#self/_env'), } : undefined; // If the project has env.ts, splice boot-time validation into the worker @@ -228,10 +202,10 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug helperPath: resolveFile('#self/env'), }; } + const runtimeEmailPath = resolveFile('#self/email'); const compileOptions: CompileOptions = { honoPath: resolveFile('hono'), runtimeEnvPath: resolveFile('#self/_env'), - runtimeEmailPath: resolveFile('#self/email'), emailDir, projectRoot: root, homeDir: homedir(), @@ -250,21 +224,10 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug bindingNames: options.bindingNames, }; - // Static `_redirects` / `_headers` are always merged. The compiled Hono app - // emits them for native Cloudflare, local dev, and non-CF targets. Managed - // builds strip the duplicate pipeline because dispatch owns it. - // - // `publicDir` starts at `/public` (Vite's default) and is - // re-resolved from the resolved config in `configResolved`. For - // framework-configured publicDir overrides, the re-resolve picks - // them up before the first route module load. + // Vite's default until `configResolved` reads the real value; null when `publicDir: false`. let publicDir: string | null = join(root, 'public'); function reloadFileRules(): void { - // Always run through the merge — when `publicDir === null` (user set - // `publicDir: false` in Vite config) file rules are skipped, but - // config-based `routing.redirects`/`rewrites`/`headers`/`fallbacks` still - // apply. Re-running also clears stale file rules picked up from the - // default `/public` preload before `configResolved`. + // Config rules still apply without a public dir, and stale preload rules are cleared. const merged = mergeRoutingRules({ dir: publicDir, routing: options.routing }); lintDuplicateSources(merged.redirectRules, 'redirects/rewrites (pre-asset)'); lintDuplicateSources(merged.fallbackRules, 'fallbacks (post-asset)'); @@ -398,47 +361,16 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }; } - function shouldServeSpaFallback(): boolean { - if (!scan) { - return false; - } - return resolveCurrentAssetRoutingPolicy().workerSpaFallback; - } - - /** - * The `not_found_handling` the generated worker entry must agree with. Read - * off the SAME resolved policy that produces the wrangler asset config, so - * the emitted `serveWithAssets` can never disagree with the platform switch - * it is standing in for. - */ - function currentNotFoundHandling(): NotFoundHandling { - return resolveCurrentAssetRoutingPolicy().assetConfig.not_found_handling ?? 'none'; - } - function resolveCurrentAssetRoutingPolicy(): AssetRoutingPolicy { - if (!scan) { - return resolveAssetRoutingPolicy({ - isSsr: Boolean(ssr), - hasPages: false, - hasApiRoutes: compileOptions.auth !== null, - hasDocumentRoutes: false, - hasMiddleware: false, - hasWebSockets: false, - authEnabled: compileOptions.auth !== null, - liveEnabled: options.live !== undefined, - notFound: options.routing?.notFound, - }); - } + const { pages, routes, websockets = [], middleware } = scan!; return resolveAssetRoutingPolicy({ isSsr: Boolean(ssr), - hasPages: scan.pages.pages.length > 0, + hasPages: pages.pages.length > 0, hasApiRoutes: - hasApiRoutes(scan.routes) || - hasApiRoutes(scan.websockets ?? []) || - compileOptions.auth !== null, - hasDocumentRoutes: hasDocumentRoutes(scan.routes), - hasMiddleware: scan.middleware.length > 0, - hasWebSockets: hasDocumentRoutes(scan.websockets ?? []), + hasApiRoutes(routes) || hasApiRoutes(websockets) || compileOptions.auth !== null, + hasDocumentRoutes: hasDocumentRoutes(routes), + hasMiddleware: middleware.length > 0, + hasWebSockets: hasDocumentRoutes(websockets), authEnabled: compileOptions.auth !== null, liveEnabled: options.live !== undefined, notFound: options.routing?.notFound, @@ -464,6 +396,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug /** Write the Cloudflare worker entry from the completed scan. */ function writeWorkerEntry(isr: WorkerEntryOptions['isr']): void { + const policy = resolveCurrentAssetRoutingPolicy(); writeFileIfChanged( join(outDir, 'entry.ts'), compileWorkerEntry({ @@ -476,15 +409,15 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ...(options.websocketTombstones ?? []), ], runtimeEnvPath: compileOptions.runtimeEnvPath, - runtimeEmailPath: compileOptions.runtimeEmailPath, + runtimeEmailPath, envSchemaModule: getEnvSchemaModule(), sandbox: sandboxEntry, hasEmail: hasEmailHandlers(), - serveSpaFallback: shouldServeSpaFallback(), + serveSpaFallback: policy.workerSpaFallback, live: options.live, dev: compileOptions.dev, isr, - notFound: currentNotFoundHandling(), + notFound: policy.assetConfig.not_found_handling ?? 'none', durableObjects: options.durableObjects, }), ); @@ -508,7 +441,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug remote, webSocketClasses: options.websocketTombstones, runtimeEnvPath: compileOptions.runtimeEnvPath, - runtimeEmailPath: compileOptions.runtimeEmailPath, + runtimeEmailPath, envSchemaModule: getEnvSchemaModule(), sandbox: sandboxEntry, live: options.live, @@ -533,9 +466,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug plugin.name === 'vite:react-refresh' || plugin.name === 'vite:react-swc', ); - // Vite sets `publicDir` to an empty string when the user passes - // `publicDir: false` in their config (public serving disabled). - // Fall through to `null` in that case so we skip file-based rules. + // Vite resolves `publicDir: false` to ''. const resolvedPublicDir = config.publicDir && config.publicDir.length > 0 ? config.publicDir : null; if (resolvedPublicDir !== publicDir) { @@ -546,9 +477,6 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug config(_, env) { previewing = env.isPreview === true; routeEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod'; - // The Void environment hook has selected the route-specific binding set - // before this hook runs. Keep route compilation aligned with the Worker - // entry and Cloudflare config for dev-only live streams. compileOptions.hasLive = options.live !== undefined; compileOptions.managedDispatch = !options.target && env.command === 'build' && Boolean(process.env.VOID_DEPLOY_PROJECT_ID); @@ -639,12 +567,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug if (options.target) { writeNodeEntries(options.target); } else { - // ISR gate. Only a real (non-managed) production CF `vite build` bakes - // ISR into the worker: managed `void deploy` sets VOID_DEPLOY_PROJECT_ID - // (the platform owns ISR then), dev (`command === 'serve'`) never emits - // it, and non-CF targets took the branch above. Read the command from - // `resolvedConfig` (set in configResolved, available here) — not a stale - // captured value. + // Only a non-managed production build bakes ISR in; managed deploys leave it to the platform. const buildConfig = readConfig(root); const merged = collectRevalidate(buildConfig, scan!.pages); const configuredRevalidate = buildConfig.routing?.revalidate; @@ -707,10 +630,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return compileBrowserClient(getClientAuthPath(id), fetchPath, fetchStreamPath); } if (virtualClient?.[1] === 'worker') { - if (options.target) { - return compileNodeClient(getClientAuthPath(id)); - } - return compileWorkerClient(getClientAuthPath(id)); + return compileWorkerClient(getClientAuthPath(id), Boolean(options.target)); } if (id !== VIRTUAL_ROUTES_ID) { return; @@ -757,15 +677,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug isInDirectory(normalizedPath, normalizedPagesDir) || isPotentialAuthConfigPath(normalizedPath) || isPotentialEnvFilePath(normalizedPath); - if (!sourceConventionChanged && !isInDirectory(normalizedPath, normalizedSsrDir)) { - return; - } - - if ( - !sourceConventionChanged && - isInDirectory(normalizedPath, normalizedSsrDir) && - !ssrEntryAbsPaths.has(normalizedPath) - ) { + if (!sourceConventionChanged && !ssrEntryAbsPaths.has(normalizedPath)) { return; } diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 3f008dbe5..101315d19 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -7,6 +7,7 @@ import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { resolveLayoutIds } from '../pages/layout-resolution.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; import { INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES } from '../runtime/env-raw.ts'; +import { ASSET_EXTENSIONS } from '../runtime/routing-assets.ts'; import { DEFAULT_SANDBOX_CLASS_NAME } from '../sandbox/config.ts'; import type { ScanResult } from './scan.ts'; @@ -17,7 +18,6 @@ function pushDefaultErrorPageHelpers(lines: Array, options: CompileOptio lines.push( `const __errorRenderer = __createErrorRenderer(${JSON.stringify({ dev: options.dev === true, projectRoot: options.projectRoot, homeDir: options.homeDir })});`, ); - lines.push('const serveCustomErrorPage = __errorRenderer.serveCustomPage;'); lines.push('app.onError(__errorRenderer.onError);'); } @@ -35,14 +35,7 @@ export type CompileOptions = { honoPath: string; /** Absolute path to the void/_env module */ runtimeEnvPath: string; - /** Absolute path to the void/email module (inbound `createMessageProxy`) */ - runtimeEmailPath?: string; - /** - * Absolute path to the project's top-level `email/` directory. Set when the - * app declares inbound email handlers; the route table then emits - * `__emailHandler`. Lives here rather than in `compileRouteTable`'s - * positional list so existing call sites keep binding correctly. - */ + /** Absolute path to the project's `email/` directory; enables `__emailHandler`. */ emailDir?: string; /** Absolute path to the project root */ projectRoot: string; @@ -230,46 +223,7 @@ export function compileRouteTable( } lines.push(`const app = new Hono();`); - // Known static-asset extensions — `c.rewrite()` into one of these re-enters - // the Hono router and 404s (static asset handlers run in front of the - // worker, not inside it). `.html` is deliberately excluded: it's ambiguous - // with legit route handlers at `.html` paths. - // - // The framework also lets users name route files with dotted extensions - // (e.g. `routes/feed.xml.ts` → pattern `/feed.xml`), so we filter the - // blocklist down to extensions NOT used by any registered route/page. - // Without this, `c.rewrite("/feed.xml")` to a real `feed.xml.ts` route - // would falsely throw, diverging from `routing.rewrites` which routes - // the same destination to the same handler without complaint. - const ASSET_EXTENSIONS = [ - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'avif', - 'svg', - 'ico', - 'css', - 'js', - 'mjs', - 'cjs', - 'woff', - 'woff2', - 'ttf', - 'otf', - 'eot', - 'mp4', - 'webm', - 'mp3', - 'wav', - 'pdf', - 'txt', - 'xml', - 'json', - 'wasm', - 'map', - ]; + // Route files may use dotted names (`feed.xml.ts`), so `c.rewrite()` must still reach them. const routePatterns: Array = []; for (const r of scan.routes) { routePatterns.push(r.pattern); @@ -299,6 +253,13 @@ export function compileRouteTable( // owns routing end-to-end, including native Cloudflare production builds. // Managed bundles omit it because dispatch applies the same rules first. const emitEdgeRules = !managedDispatch; + const fallbackRulesJson = JSON.stringify( + emitEdgeRules && options.fallbackRules?.length + ? options.dev + ? options.fallbackRules + : stripOriginForProd(options.fallbackRules) + : undefined, + ); if (emitEdgeRules && options.redirectRules?.length) { compileRoutingRulesMiddleware(lines, options.redirectRules, options.dev === true); } @@ -450,7 +411,7 @@ export function compileRouteTable( if (pages && scan.pages.pages.length > 0) { lines.push( - `app.notFound(__createPagesFallback({ routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${JSON.stringify(emitEdgeRules && options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)} }));`, + `app.notFound(__createPagesFallback({ routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson} }));`, ); } @@ -461,7 +422,7 @@ export function compileRouteTable( `const getClientAssetTags = __createClientAssetTags(${JSON.stringify({ entryId: ssr.clientEntryRelativePath, entryUrl: '/' + ssr.clientEntryRelativePath, kind: 'ssr', dev: options.dev === true, reactRefresh: options.reactRefresh })});`, ); lines.push( - `app.notFound(__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${JSON.stringify(emitEdgeRules && options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags }));`, + `app.notFound(__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags }));`, ); } else if ( emitEdgeRules && @@ -469,7 +430,7 @@ export function compileRouteTable( !(pages && scan.pages.pages.length > 0) ) { lines.push( - `app.notFound(__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${JSON.stringify(options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)} }));`, + `app.notFound(__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${fallbackRulesJson} }));`, ); } @@ -518,16 +479,23 @@ export function compileRouteTable( lines.push(''); } - if (scan.jobs.length > 0) { - lines.push('const __scheduledHandler = async (c) => {'); + if (scan.jobs.length > 0 || scan.queues.length > 0) { + lines.push('function __voidTriggerUnauthorized(c) {'); lines.push(' const __token = c.req.header("x-void-internal");'); lines.push(' const __expected = __getRawEnv()?.__VOID_PROXY_TOKEN;'); lines.push(' const __devToken = c.req.header("x-void-dev-trigger");'); lines.push( - ' if (__VOID_SERVE ? __devToken !== __VOID_DEV_TRIGGER_TOKEN : __expected ? __token !== __expected : __token !== __VOID_BUILD_KEY) {', + ' return __VOID_SERVE ? __devToken !== __VOID_DEV_TRIGGER_TOKEN : __expected ? __token !== __expected : __token !== __VOID_BUILD_KEY;', + ); + lines.push('}'); + lines.push(''); + } + + if (scan.jobs.length > 0) { + lines.push('const __scheduledHandler = async (c) => {'); + lines.push( + ' if (__voidTriggerUnauthorized(c)) return c.json({ error: "unauthorized" }, 401);', ); - lines.push(' return c.json({ error: "unauthorized" }, 401);'); - lines.push(' }'); lines.push(' const { cron, scheduledTime } = await c.req.json();'); lines.push(' const controller = { cron, scheduledTime, noRetry() {} };'); lines.push(' await scheduled(controller, c.env, c.executionCtx);'); @@ -593,16 +561,10 @@ export function compileRouteTable( lines.push('}'); lines.push(''); - // HTTP route — same auth policy as /__void/scheduled. lines.push('const __queueHandler = async (c) => {'); - lines.push(' const __token = c.req.header("x-void-internal");'); - lines.push(' const __expected = __getRawEnv()?.__VOID_PROXY_TOKEN;'); - lines.push(' const __devToken = c.req.header("x-void-dev-trigger");'); lines.push( - ' if (__VOID_SERVE ? __devToken !== __VOID_DEV_TRIGGER_TOKEN : __expected ? __token !== __expected : __token !== __VOID_BUILD_KEY) {', + ' if (__voidTriggerUnauthorized(c)) return c.json({ error: "unauthorized" }, 401);', ); - lines.push(' return c.json({ error: "unauthorized" }, 401);'); - lines.push(' }'); lines.push(' const payload = await c.req.json();'); lines.push(' const result = await __processQueue(payload, c.env);'); lines.push(' return c.json(result);'); @@ -611,24 +573,7 @@ export function compileRouteTable( lines.push(''); } - // Inbound email — generate __emailHandler that dispatches by recipient - // local-part. Patterns are tried in the order the scanner sorted them - // (`compareEmailRoutes` in router/email.ts: static-tag > static > - // dynamic-tag > dynamic, a literal tag before a captured one, then - // alphabetical) and the first match wins; captured groups become `info.params` - // and the handler is invoked with (message, env, ctx, info). Falls through - // to email/_default.ts when no per-address pattern matches. - // - // Slug stripping: the shared platform sender rewrites every recipient to - // `+@`, where `` is the - // project slug. User-defined handlers are written against the `` only - // (the slug is invisible to them — see design doc 0078-email-cf-native). When a slug is - // configured (`__VOID_PROJECT_SLUG`) the dispatcher strips everything up to - // and including the first `+` from the local-part before running it through - // the pattern table. Without one — a worker on the developer's own zone, or - // a relayed custom-domain envelope (`viaDomain`, suppressed by `handleEmail`) - // — the full local part is matched, so `support+T-42@zone` reaches - // `email/support+[ticket].ts` there too. + // Inbound email: the first recipient pattern in scanner order wins, then `_default`. const email = scan.email; const emailDir = options.emailDir; if (emailDir && email && (email.addresses.length > 0 || email.defaultHandler)) { @@ -656,30 +601,12 @@ export function compileRouteTable( } lines.push(''); lines.push('export async function __emailHandler(message, env, ctx, __opts) {'); - // Route on the PLATFORM (wire) recipient, not `message.to`. - // `createMessageProxy` has already removed the `+` prefix from - // `message.to` so handler authors see the address they wrote, and it - // preserves the wire form as `__voidPlatformTo`. Reading `message.to` - // here would strip the slug a SECOND time, leaving an empty tag that no - // anchored pattern can match. When no slug was injected the two are - // identical, so the fallback is exact. - // Case is PRESERVED here and the patterns carry the `i` flag instead. - // Lowercasing the address first would lowercase the captured groups too, - // silently corrupting a case-sensitive tag such as a ticket id or a token - // (`support+T-42@` would reach the handler as `t-42`). + // Match the wire recipient: `createMessageProxy` already stripped the slug from `message.to`. + // Keep the case; patterns use the `i` flag so captured tags stay intact. lines.push(' const __platformTo = message ? message.__voidPlatformTo : undefined;'); lines.push(' const __to = String((__platformTo ?? (message && message.to)) || "");'); lines.push(' const __localFull = __to.split("@")[0] || "";'); - // Strip the platform-injected `+` prefix exactly when a slug is - // CONFIGURED (`__VOID_PROJECT_SLUG`, passed in by `handleEmail`), not when - // `createMessageProxy` happened to remove one: the platform lane is - // `[+]`, so a bare `@domain` (no `+`) carries no - // `__voidPlatformTo` yet must still read as an EMPTY tag, not as the - // slug itself — otherwise `email/[user].ts` would run with `user=`. - // With no slug configured (a worker on the developer's own zone) the wire - // address IS the address the handler author wrote: match all of it. With - // the prefix present and no `+` after it there is no tag, and the empty - // string falls through to `_default`. + // With a slug configured, `[+]` routes on the tag; a bare `@` has an empty tag. lines.push(' let __local = __localFull;'); lines.push(' if (__opts && __opts.slug) {'); lines.push(' const __plus = __localFull.indexOf("+");'); @@ -712,12 +639,7 @@ export function compileRouteTable( lines.push(' }'); lines.push(' return await __dmod.default(message, env, ctx, { params: {} });'); } else { - // No-match without _default — explicitly reject so the remote MTA - // gets a non-delivery report. Silent acceptance would mark the - // message accepted on our end while never invoking any handler, - // which is invisible to the sender (no NDR) and to the recipient - // (no delivery). The reject reason is generic so user-handler - // authors can add `_default.ts` to customize. + // Reject unmatched mail so the sender gets a bounce instead of silent loss. lines.push(' if (typeof message.setReject === "function") {'); lines.push( ' message.setReject("no handler matched recipient \\"" + __to + "\\" — add email/_default.ts to handle unmatched mail");', @@ -798,76 +720,22 @@ export function compileRouteTable( } lines.push(' return null;'); lines.push('}'); - lines.push('app.post("/__void/migrate", async (c) => {'); - lines.push(' const __voidMigrationHandler = __createMigrationHandler('); - lines.push(' __voidGetMigrationEnv(),'); - lines.push(' __migrations,'); - lines.push(' __migrationDialect,'); - if (auth) { - lines.push( - ` () => runVoidAuthMigrations(new Request(c.req.url), __voidGetMigrationEnv(), __voidAuthOptions${ - auth.authSchemaPath ? ', __voidAuthMigrationSchema' : '' - }),`, - ); - } else { - lines.push(' undefined,'); - } - lines.push(' );'); - lines.push(' return __voidMigrationHandler(c.req.raw);'); - lines.push('});'); - lines.push('app.get("/__void/migrate/capabilities", async (c) => {'); - lines.push(' const __voidMigrationHandler = __createMigrationHandler('); - lines.push(' __voidGetMigrationEnv(),'); - lines.push(' __migrations,'); - lines.push(' __migrationDialect,'); - if (auth) { - lines.push( - ` () => runVoidAuthMigrations(new Request(c.req.url), __voidGetMigrationEnv(), __voidAuthOptions${ - auth.authSchemaPath ? ', __voidAuthMigrationSchema' : '' - }),`, - ); - } else { - lines.push(' undefined,'); - } - lines.push(' __migrationPlanBundle,'); - lines.push(' );'); - lines.push(' return __voidMigrationHandler(c.req.raw);'); - lines.push('});'); - lines.push('app.post("/__void/migrate/app", async (c) => {'); - lines.push(' const __voidMigrationHandler = __createMigrationHandler('); - lines.push(' __voidGetMigrationEnv(),'); - lines.push(' __migrations,'); - lines.push(' __migrationDialect,'); - lines.push(' undefined,'); - lines.push(' __migrationPlanBundle,'); - lines.push(' );'); - lines.push(' return __voidMigrationHandler(c.req.raw);'); - lines.push('});'); - lines.push('app.get("/__void/migrate/app/attempt/:attemptId", async (c) => {'); - lines.push(' const __voidMigrationHandler = __createMigrationHandler('); - lines.push(' __voidGetMigrationEnv(),'); - lines.push(' __migrations,'); - lines.push(' __migrationDialect,'); - lines.push(' undefined,'); - lines.push(' __migrationPlanBundle,'); - lines.push(' );'); - lines.push(' return __voidMigrationHandler(c.req.raw);'); - lines.push('});'); + lines.push( + `app.post("/__void/migrate", (c) => __voidCreateMigrationRequestHandler(c.req.raw, ${auth ? 'true' : 'false'}, false)(c.req.raw));`, + ); + lines.push( + `app.get("/__void/migrate/capabilities", (c) => __voidCreateMigrationRequestHandler(c.req.raw, ${auth ? 'true' : 'false'}, true)(c.req.raw));`, + ); + lines.push( + 'app.post("/__void/migrate/app", (c) => __voidCreateMigrationRequestHandler(c.req.raw, false, true)(c.req.raw));', + ); + lines.push( + 'app.get("/__void/migrate/app/attempt/:attemptId", (c) => __voidCreateMigrationRequestHandler(c.req.raw, false, true)(c.req.raw));', + ); if (auth) { - lines.push('app.post("/__void/migrate/auth", async (c) => {'); - lines.push(' const __voidMigrationHandler = __createMigrationHandler('); - lines.push(' __voidGetMigrationEnv(),'); - lines.push(' __migrations,'); - lines.push(' __migrationDialect,'); lines.push( - ` () => runVoidAuthMigrations(new Request(c.req.url), __voidGetMigrationEnv(), __voidAuthOptions${ - auth.authSchemaPath ? ', __voidAuthMigrationSchema' : '' - }),`, + 'app.post("/__void/migrate/auth", (c) => __voidCreateMigrationRequestHandler(c.req.raw, true, true)(c.req.raw));', ); - lines.push(' __migrationPlanBundle,'); - lines.push(' );'); - lines.push(' return __voidMigrationHandler(c.req.raw);'); - lines.push('});'); } lines.push(''); } else { @@ -1149,22 +1017,7 @@ function compilePageRoutes( lines.push(''); } -/** - * Generate the asset fallthrough helper shared by native Void workers and - * Class A framework wrappers. If the app returns 404, check ASSETS binding - * for static files and set cache headers. Edge caching of hashed assets - * happens at the dispatch layer (see design doc 0042). - * - * `notFound` is the RESOLVED `assetConfig.not_found_handling` for this build - * (`resolveAssetRoutingPolicy`), and it decides what happens once the asset - * binding has also answered 404. It is a separate argument from - * `serveSpaFallback` because the two are not derived from each other: the - * common worker-first app runs with `not_found_handling: 'none'` and STILL - * wants the worker-side shell fallback (that is the whole reason the flag - * exists). They are mutually exclusive in practice — `resolveAssetRoutingPolicy` - * clears `workerSpaFallback` whenever an explicit `routing.notFound` asks for - * anything other than the SPA shell. - */ +/** Asset fallthrough shared by native Void workers and Class A framework wrappers. */ export function compileAssetFallbackHelper( assetsDir = 'assets', serveSpaFallback = false, @@ -1175,31 +1028,18 @@ const serveWithAssets = __createAssetFallback(${JSON.stringify({ assetsDir, serv `; } -// Both modules are siblings under `runtime/`, so `_env` -> `email` is the only -// transform needed. Works for bare specifiers (`void/_env` -> `void/email`) and -// for resolved file paths (`/.../runtime/env.mjs` -> `/.../runtime/email.mjs`). -function deriveEmailRuntimePath(runtimeEnvPath: string): string { - return runtimeEnvPath.replace(/_?env(\.[^/]+)?$/, (_m, ext) => 'email' + (ext ?? '')); -} - export interface WorkerEntryOptions { /** Native readiness proves the actual served Worker version through this binding. */ versionMetadataBinding?: string; hasQueues?: boolean; remote?: { remotePath: string; - envPath: string; bindingNames?: { d1?: string; kv?: string; r2?: string }; }; assetsDir?: string; webSocketClasses?: Array; runtimeEnvPath?: string; - /** - * Path to the `void/email` runtime, used for `createMessageProxy` in the - * generated email handlers. Defaults to a sibling-of-`runtimeEnvPath` - * derivation, which covers both bare specifiers (`void/_env` -> `void/email`) - * and resolved file paths. - */ + /** Path to the `void/email` runtime used by the generated email handlers. */ runtimeEmailPath?: string; envSchemaModule?: { importPath: string; helperPath: string }; sandbox?: @@ -1269,7 +1109,7 @@ export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { assetsDir = 'assets', webSocketClasses = [], runtimeEnvPath = 'void/_env', - runtimeEmailPath, + runtimeEmailPath = 'void/email', envSchemaModule, sandbox, serveSpaFallback = false, @@ -1337,9 +1177,7 @@ export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { const remoteImport = remote ? `import { createRemoteEnv } from ${JSON.stringify(remote.remotePath)};\n` : ''; - // Boot-time env validation: import the user's env.ts so the schema is - // registered, plus the validator helper. The helper short-circuits after - // the first call per isolate and throws EnvValidationError on bad values. + // Importing env.ts registers the schema that `_validateEnvOnce` checks. const envSchemaImport = envSchemaModule ? `import ${JSON.stringify(envSchemaModule.importPath)};\nimport { _validateEnvOnce } from ${JSON.stringify(envSchemaModule.helperPath)};\n` : ''; @@ -1367,14 +1205,11 @@ const __filterInternalBindings = (env) => __filterBindings(env, ${JSON.stringify internalRoute: __voidHandleInternalRoute, collectPrerenderPaths: __voidCollectPrerenderPaths${versionMetadataBinding ? ', versionMetadataBinding: ' + JSON.stringify(versionMetadataBinding) : ''}${envSchemaModule ? ', validateEnv: _validateEnvOnce' : ''}${remotePath ? ', remoteEnv: (env) => ' + createRemoteEnvCall : ''}${hasEmail ? ', emailReceipt: __handleEmailReceipt' : ''}${isr ? ', isr: { deploymentId: __VOID_ISR_DEPLOYMENT_ID, handle: (request, env, ctx, render) => handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, pages: __VOID_ISR_PAGES, pathRevalidate: __VOID_ISR_PATH_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID, queryAllowlist: ' + JSON.stringify(isr.queryAllowlist) + ' }, render) }' : ''} })`; - const scheduledHandler = remotePath - ? `async function handleScheduled(controller, env, ctx) { -${envValidateCall} if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; -${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); - return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); -}` - : `async function handleScheduled(controller, env, ctx) { -${envValidateCall}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); + const remoteEnvSwap = remotePath + ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` + : ''; + const scheduledHandler = `async function handleScheduled(controller, env, ctx) { +${envValidateCall}${remoteEnvSwap}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); return withRuntimeEnv(env, () => scheduled(controller, userEnv, ctx)); }`; @@ -1382,34 +1217,13 @@ ${envValidateCall}${isrDeploymentStamp} const userEnv = __filterInternalBinding // run the same handler. Native events replay recorded actions on the real // message; receipts return bounded action frames to the platform. const emailImport = hasEmail ? ', __emailHandler' : ''; - const emailRuntimePath = runtimeEmailPath ?? deriveEmailRuntimePath(runtimeEnvPath); const emailProxyImport = hasEmail - ? `import { createMessageProxy as __createMessageProxy, enactEmailActions as __enactEmailActions, isForwardableEmailMessage as __isForwardableEmailMessage } from ${JSON.stringify(emailRuntimePath)};\nimport { EMAIL_PROTOCOL_VERSION as __EMAIL_PROTOCOL_VERSION, encodeEmailActions as __encodeEmailActions, readEmailEnvelope as __readEmailEnvelope, verifyEmailReceiptThroughProxy as __verifyEmailReceiptThroughProxy } from "void/_email-protocol";\n` + ? `import { createMessageProxy as __createMessageProxy, enactEmailActions as __enactEmailActions, isForwardableEmailMessage as __isForwardableEmailMessage } from ${JSON.stringify(runtimeEmailPath)};\nimport { EMAIL_PROTOCOL_VERSION as __EMAIL_PROTOCOL_VERSION, encodeEmailActions as __encodeEmailActions, readEmailEnvelope as __readEmailEnvelope, verifyEmailReceiptThroughProxy as __verifyEmailReceiptThroughProxy } from "void/_email-protocol";\n` : ''; - // `__VOID_PROJECT_SLUG` is the same binding the outbound `sendEmail` reads to - // default `from`. It is passed to `createMessageProxy` so the user-visible - // `message.to` is the bare `@` rather than the wire form, and to - // the generated `__emailHandler`, which routes on the wire address - // (`__voidPlatformTo`) and strips the `+` prefix iff a slug is set. - // - // Verified custom-domain receipts carry `viaDomain` and have no project - // prefix. Suppress the slug there so tags such as `support+T-42@acme.com` - // reach the matching handler intact. + // Custom-domain receipts (`viaDomain`) carry no `+` prefix, so they get no slug. const emailHandler = hasEmail - ? remotePath - ? `async function handleEmail(envelope, env, ctx) { -${envValidateCall} if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall}; - const userEnv = __filterInternalBindings(env); - const __relayed = typeof envelope.viaDomain === "string" && envelope.viaDomain.length > 0; - const __slug = __relayed ? undefined : (typeof env.__VOID_PROJECT_SLUG === "string" && env.__VOID_PROJECT_SLUG.length > 0 ? env.__VOID_PROJECT_SLUG : undefined); - const { message, getActions } = __createMessageProxy(envelope, { slug: __slug }); - await __withRawEnv(env, () => withRuntimeEnv(env, () => __emailHandler(message, userEnv, ctx, { slug: __slug }))); - const __actions = getActions(); - if (__isForwardableEmailMessage(envelope)) await __enactEmailActions(envelope, __actions); - return __actions; -}` - : `async function handleEmail(envelope, env, ctx) { -${envValidateCall} const userEnv = __filterInternalBindings(env); + ? `async function handleEmail(envelope, env, ctx) { +${envValidateCall}${remoteEnvSwap} const userEnv = __filterInternalBindings(env); const __relayed = typeof envelope.viaDomain === "string" && envelope.viaDomain.length > 0; const __slug = __relayed ? undefined : (typeof env.__VOID_PROJECT_SLUG === "string" && env.__VOID_PROJECT_SLUG.length > 0 ? env.__VOID_PROJECT_SLUG : undefined); const { message, getActions } = __createMessageProxy(envelope, { slug: __slug }); @@ -1467,15 +1281,10 @@ ${envValidateCall} const userEnv = __filterInternalBindings(env); async scheduled(controller) { return handleScheduled(controller, this.env, this.ctx); } ${queueMethodBody}${emailMethodBody}}`; - if (hasQueues) { - return `${sandboxImport}${entryImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __withRawEnv, __processQueue${emailImport}${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; -${durableObjectImports}\n${runtimeEnvImport}${emailProxyImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} -${fetchDeclaration} -${scheduledHandler} -${emailHandler} -${emailReceiptHandler} -async function __voidQueue(batch, env, ctx) { -${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` : ''}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); + const queueImport = hasQueues ? ', __processQueue' : ''; + const queueFunction = hasQueues + ? `async function __voidQueue(batch, env, ctx) { +${envValidateCall}${remoteEnvSwap}${isrDeploymentStamp} const userEnv = __filterInternalBindings(env); const messages = [...batch.messages].map(m => ({ id: m.id, timestamp: m.timestamp.toISOString(), body: m.body, attempts: m.attempts, @@ -1495,19 +1304,15 @@ ${envValidateCall}${remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${creat if (!__qr.ok) throw new Error("queue: Handler failed."); } -${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} -${liveExport} -${sandboxExport} -${durableObjectExports} -${entryClass}`; - } - return `${sandboxImport}${entryImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __withRawEnv${emailImport}${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; +` + : ''; + return `${sandboxImport}${entryImport}${liveRuntimeImport}import app, { scheduled, __voidCollectPrerenderPaths, __voidHandleInternalRoute, __withRawEnv${queueImport}${emailImport}${webSocketClasses.length > 0 ? `, ${webSocketClasses.join(', ')}` : ''}${liveRouteImport} } from 'virtual:void-routes'; ${durableObjectImports}\n${runtimeEnvImport}${emailProxyImport}${envSchemaImport}${remoteImport}${isrImport}${filterBindingsHelper}${assetHelper} ${fetchDeclaration} ${scheduledHandler} ${emailHandler} ${emailReceiptHandler} -${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} +${queueFunction}${webSocketClasses.map((name) => `export { ${name} };`).join('\n')} ${liveExport} ${sandboxExport} ${durableObjectExports} @@ -1517,13 +1322,6 @@ ${entryClass}`; /** * Generate the app module — exports the Hono app with static asset serving. * This is the entry imported by `vite preview` and can be used programmatically. - * - * When `envSchemaModule` is provided, splices boot-time env validation so it - * runs on the first request (including the prerender child process spawned - * by `prerenderPagesNode`). Mirrors `compileWorkerEntry`: a side-effect import - * registers the schema, and `_validateEnvOnce` throws `EnvValidationError` on - * bad values. Env source on Node/Bun is `process.env`; on Deno it's - * `Deno.env.toObject()`. */ export function compileNodeApp( target: 'node' | 'bun' | 'deno', @@ -1537,10 +1335,6 @@ export function compileNodeApp( ? `import { serveStatic } from "hono/deno";` : `import { serveStatic } from "@hono/node-server/serve-static";`; - // Boot-time env validation: side-effect import so defineEnv() registers the - // schema, plus the validator helper. Helper short-circuits after the first - // call per process and throws EnvValidationError on bad values. Node/Bun - // read env from `process.env`; Deno from `Deno.env.toObject()`. const envSchemaImport = envSchemaModule ? `import ${JSON.stringify(envSchemaModule.importPath)};\nimport { _validateEnvOnce } from ${JSON.stringify(envSchemaModule.helperPath)};\n` : ''; @@ -1615,75 +1409,6 @@ export { fetchStream, StreamError } from ${JSON.stringify(fetchStreamPath)}; export { auth, createAuthClient } from ${JSON.stringify(authClientPath)};`; } -/** - * Generate the server-side fetch client module for non-CF targets. - * Same as compileWorkerClient but without cloudflare:workers dependency. - * Calls app.fetch() directly (no HTTP round-trip) with ALS context forwarding. - */ -export function compileNodeClient(authClientPath: string): string { - return `import app, { requestContext } from "virtual:void-routes"; -import { interpolateRouteParams } from ${JSON.stringify(ROUTE_URL_PATH)}; -export { auth, createAuthClient } from ${JSON.stringify(authClientPath)}; - -export class FetchError extends Error { - constructor(status, statusText, data) { - super(\`fetch: Request failed with status \${status}.\`); - this.name = "FetchError"; - this.status = status; - this.statusText = statusText; - this.data = data; - } -} - -export async function fetch(path, options) { - let url = interpolateRouteParams(path, options?.params); - - if (options?.query) { - const qs = new URLSearchParams(options.query).toString(); - if (qs) url += "?" + qs; - } - - const method = options?.method ?? "GET"; - const headers = new Headers(options?.headers); - - const store = requestContext.getStore(); - if (store?.request) { - const outerReq = store.request; - if (!headers.has("cookie")) { - const cookie = outerReq.headers.get("cookie"); - if (cookie) headers.set("cookie", cookie); - } - if (!headers.has("authorization")) { - const auth = outerReq.headers.get("authorization"); - if (auth) headers.set("authorization", auth); - } - } - - if (options?.body != null && !headers.has("content-type")) { - headers.set("content-type", "application/json"); - } - - const request = new Request("http://localhost" + url, { - method, - headers, - body: options?.body != null ? JSON.stringify(options.body) : undefined, - }); - - const response = await app.fetch(request); - - if (response.status === 204) return undefined; - - const contentType = response.headers.get("content-type") ?? ""; - const data = contentType.includes("application/json") ? await response.json() : await response.text(); - - if (!response.ok) { - throw new FetchError(response.status, response.statusText, data); - } - - return data; -}`; -} - function stripOriginForProd(rules: Array): Array { return rules.map(({ origin: _origin, ...rest }) => rest); } @@ -1704,11 +1429,15 @@ function compileHeaderMiddleware(lines: Array, rules: Array) lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));'); } -export function compileWorkerClient(authClientPath: string): string { +/** Generate the server-side fetch client, which calls `app.fetch()` with the current request's credentials. */ +export function compileWorkerClient(authClientPath: string, nodeTarget = false): string { + const envImport = nodeTarget ? '' : 'import { env } from "cloudflare:workers";\n'; + const dispatch = nodeTarget + ? 'const response = await app.fetch(request);' + : 'const executionCtx = store?.executionCtx ?? { waitUntil() {}, passThroughOnException() {} };\n const response = await app.fetch(request, env, executionCtx);'; return `import app, { requestContext } from "virtual:void-routes"; import { interpolateRouteParams } from ${JSON.stringify(ROUTE_URL_PATH)}; -import { env } from "cloudflare:workers"; -export { auth, createAuthClient } from ${JSON.stringify(authClientPath)}; +${envImport}export { auth, createAuthClient } from ${JSON.stringify(authClientPath)}; export class FetchError extends Error { constructor(status, statusText, data) { @@ -1731,7 +1460,6 @@ export async function fetch(path, options) { const method = options?.method ?? "GET"; const headers = new Headers(options?.headers); - // Auto-forward cookie and authorization from the outer request via ALS const store = requestContext.getStore(); if (store?.request) { const outerReq = store.request; @@ -1749,15 +1477,13 @@ export async function fetch(path, options) { headers.set("content-type", "application/json"); } - const request = new Request("http://worker.local" + url, { + const request = new Request("${nodeTarget ? 'http://localhost' : 'http://worker.local'}" + url, { method, headers, body: options?.body != null ? JSON.stringify(options.body) : undefined, }); - // Use real executionCtx from ALS if available, otherwise provide a no-op fallback - const executionCtx = store?.executionCtx ?? { waitUntil() {}, passThroughOnException() {} }; - const response = await app.fetch(request, env, executionCtx); + ${dispatch} if (response.status === 204) return undefined; diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 38a5c00a1..72ab2ff67 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -1,23 +1,19 @@ import { join } from 'pathslash'; -import { glob, globSync } from 'tinyglobby'; -import { scanJobs, type JobDefinition } from '../crons/scan.ts'; +import { globSync } from 'tinyglobby'; +import type { JobDefinition } from '../crons/scan.ts'; import { scanPages } from '../pages/scan.ts'; import type { PageScanResult } from '../pages/types.ts'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; -import { scanQueues, type QueueDefinition } from '../queues/scan.ts'; +import type { QueueDefinition } from '../queues/scan.ts'; import { detectHttpExports } from './detect-exports.ts'; -import { scanEmailHandlers, type EmailHandlersScan } from './email.ts'; +import type { EmailHandlersScan } from './email.ts'; import { isRouteEnabledForEnvironment, parseRouteFilename, type RouteDefinition, type RouteEnvironment, } from './parse-filename.ts'; -import { - isWebSocketPatternFile, - scanWebSocketRoutes, - type WebSocketRouteDefinition, -} from './websocket.ts'; +import { isWebSocketPatternFile, type WebSocketRouteDefinition } from './websocket.ts'; export type ScanResult = { routes: Array; @@ -74,57 +70,24 @@ function compareRouteSpecificity(a: RouteDefinition, b: RouteDefinition): number return aSegments.length - bSegments.length; } -/** - * Scan routes/ and middleware/ directories and return a structured manifest. - * Files starting with _ are ignored. - */ +/** Add pages and HTTP methods to the inventories already resolved for this application phase. */ export async function scanRoutes( root: string, - options?: { + options: { output?: 'server' | 'static'; - paths?: ProjectPaths; - environment?: RouteEnvironment; - /** Reuse the inventories already resolved for this application phase. */ - inventory?: Omit; + paths: ProjectPaths; + inventory: Omit; }, ): Promise { - const paths = options?.paths ?? readProjectPaths(root); - const [routeFiles, websockets, middlewareFiles, jobs, queues, pages, email] = await Promise.all([ - options?.inventory ? [] : glob(ROUTE_PATTERN, { cwd: paths.routesDir, absolute: false }), - options?.inventory?.websockets ?? scanWebSocketRoutes(root, paths, options?.environment), - options?.inventory - ? [] - : glob(ROUTE_PATTERN, { cwd: paths.middlewareDir, absolute: false }).catch( - () => [] as Array, - ), - options?.inventory?.jobs ?? scanJobs(root, paths.cronsDir), - options?.inventory?.queues ?? scanQueues(root, paths.queuesDir), - scanPages(root, { output: options?.output, paths }), - // `email/` is a source convention like `crons/` and `queues/`, so it lives - // under `sourceRoot` — which is `root` when no `sourceDir` is configured. - // `scanEmailHandlers` joins its own `email` segment, so this resolves to - // `paths.emailDir`. - options?.inventory?.email ?? scanEmailHandlers(paths.sourceRoot), - ]); - - const routesDir = paths.routesDir; - - // Parse routes, filtering out files starting with _ - const parsedRoutes = - options?.inventory?.routes ?? - routeFiles - .filter((f) => !isWebSocketPatternFile(f)) - .filter((f) => !f.split('/').some((s) => s.startsWith('_'))) - .map((file) => parseRouteFilename(file)) - .filter((route) => isRouteEnabledForEnvironment(route, options?.environment)); - const routes = parsedRoutes - .map((route) => ({ ...route, methods: detectHttpExports(join(routesDir, route.filePath)) })) + const { paths, inventory } = options; + const pages = await scanPages(root, { output: options.output, paths }); + const routes = inventory.routes + .map((route) => ({ + ...route, + methods: detectHttpExports(join(paths.routesDir, route.filePath)), + })) .sort(compareRouteSpecificity); - - // Parse middleware, sorted by numeric prefix - const middleware = options?.inventory?.middleware ?? parseMiddlewareDefinitions(middlewareFiles); - - return { routes, websockets, middleware, jobs, queues, pages, email }; + return { ...inventory, routes, pages }; } export function scanMiddlewareSync( diff --git a/packages/void/src/router/websocket.ts b/packages/void/src/router/websocket.ts index 73796005a..735c4f96f 100644 --- a/packages/void/src/router/websocket.ts +++ b/packages/void/src/router/websocket.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'pathslash'; -import { glob, globSync } from 'tinyglobby'; +import { globSync } from 'tinyglobby'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import { isIdentifier, @@ -165,18 +165,6 @@ function parseWebSocketFiles( return routes; } -export async function scanWebSocketRoutes( - root: string, - paths: ProjectPaths = readProjectPaths(root), - environment?: RouteEnvironment, -): Promise> { - const pattern = `**/*.{${HANDLER_EXTENSIONS.join(',')}}`; - const files = await glob(pattern, { cwd: paths.routesDir, absolute: false }).catch( - () => [] as Array, - ); - return parseWebSocketFiles(root, files, paths, environment); -} - export function scanWebSocketRoutesSync( root: string, paths: ProjectPaths = readProjectPaths(root), diff --git a/packages/void/src/runtime/routing-assets.ts b/packages/void/src/runtime/routing-assets.ts index 09a91747d..edf598c0e 100644 --- a/packages/void/src/runtime/routing-assets.ts +++ b/packages/void/src/runtime/routing-assets.ts @@ -1,6 +1,37 @@ import type { DevCssAsset } from '../pages/types.ts'; import type { RuntimeEnvironment } from './routing-worker.ts'; +// `c.rewrite()` cannot reach files the asset layer serves. `.html` is excluded: routes may use it. +export const ASSET_EXTENSIONS: Array = [ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'avif', + 'svg', + 'ico', + 'css', + 'js', + 'mjs', + 'cjs', + 'woff', + 'woff2', + 'ttf', + 'otf', + 'eot', + 'mp4', + 'webm', + 'mp3', + 'wav', + 'pdf', + 'txt', + 'xml', + 'json', + 'wasm', + 'map', +]; + export type ClientAssetTags = { css: string; preloads: string; diff --git a/packages/void/src/runtime/routing-errors.ts b/packages/void/src/runtime/routing-errors.ts index 1cb82d18d..060c41714 100644 --- a/packages/void/src/runtime/routing-errors.ts +++ b/packages/void/src/runtime/routing-errors.ts @@ -21,7 +21,7 @@ export type ErrorRenderer = { ) => Promise; onError: (error: Error, context: Context) => Promise | Response; }; -const __VOID_ERROR_PAGE_FONT_STACK = +const ERROR_PAGE_FONT_STACK = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; const errorStack = Error as unknown as { captureStackTrace?: (error: Error, constructor?: Function) => void; @@ -30,10 +30,8 @@ const errorStack = Error as unknown as { /** Error rendering is shared by generated Pages apps; production never includes exception details. */ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRenderer { - const __VOID_DEV = options.dev === true; - const __VOID_PROJECT_ROOT = options.projectRoot; - const __VOID_HOME_DIR = options.homeDir; - function __voidEscapeHtml(value: unknown): string { + const dev = options.dev === true; + function escapeHtml(value: unknown): string { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') @@ -41,7 +39,7 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere .replaceAll('"', '"'); } - function __voidShouldServeHtmlErrorPage(request: Request): boolean { + function shouldServeHtmlErrorPage(request: Request): boolean { if (request.method !== 'GET' && request.method !== 'HEAD') { return false; } @@ -60,18 +58,18 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return !accept || accept.includes('text/html') || accept.includes('*/*'); } - function __voidNormalizeFsPath(value: unknown): string { + function normalizeFsPath(value: unknown): string { return typeof value === 'string' ? value.replaceAll('\\', '/') : ''; } - function __voidIsAbsolutePath(value: string): boolean { + function isAbsolutePath(value: string): boolean { return value.startsWith('/') || /^[A-Za-z]:\//.test(value); } - function __voidDisplayPath(value: unknown): string { - const path = __voidNormalizeFsPath(value); - const homeDir = __voidNormalizeFsPath(__VOID_HOME_DIR); - const projectRoot = __voidNormalizeFsPath(__VOID_PROJECT_ROOT); + function toDisplayPath(value: unknown): string { + const path = normalizeFsPath(value); + const homeDir = normalizeFsPath(options.homeDir); + const projectRoot = normalizeFsPath(options.projectRoot); if (!path) { return ''; } @@ -86,9 +84,9 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return path; } - function __voidEditorHref(value: unknown, line: number, column: number): string { - const path = __voidNormalizeFsPath(value); - if (!__voidIsAbsolutePath(path)) { + function editorHref(value: unknown, line: number, column: number): string { + const path = normalizeFsPath(value); + if (!isAbsolutePath(path)) { return ''; } return ( @@ -101,14 +99,14 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere ); } - function __voidCreateStackFrame( + function createStackFrame( functionName: string | null | undefined, file: unknown, line: unknown, column: unknown, raw: string, ): StackFrame | null { - const normalizedFile = __voidNormalizeFsPath(file); + const normalizedFile = normalizeFsPath(file); const lineNumber = Number(line); const columnNumber = Number(column); if (!normalizedFile || !Number.isFinite(lineNumber) || lineNumber <= 0) { @@ -120,21 +118,21 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere file: normalizedFile, line: lineNumber, column: safeColumn, - displayPath: __voidDisplayPath(normalizedFile), - href: __voidEditorHref(normalizedFile, lineNumber, safeColumn), + displayPath: toDisplayPath(normalizedFile), + href: editorHref(normalizedFile, lineNumber, safeColumn), raw: raw || '', }; } - function __voidParseFrameLocation(value: string): StackFrame | null { + function parseFrameLocation(value: string): StackFrame | null { const match = value.match(/^(.*):(\d+):(\d+)$/); if (!match) { return null; } - return __voidCreateStackFrame('', match[1], Number(match[2]), Number(match[3]), value); + return createStackFrame('', match[1], Number(match[2]), Number(match[3]), value); } - function __voidParseStackFrames(stack: string): Array { + function parseStackFrames(stack: string): Array { if (!stack) { return []; } @@ -147,16 +145,16 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere let frame = null; let match = line.match(/^at\s+(?:async\s+)?(.+?)\s+\((.+)\)$/); if (match) { - const location = __voidParseFrameLocation(match[2]); + const location = parseFrameLocation(match[2]); frame = location ? { ...location, functionName: match[1] } : null; } else { match = line.match(/^at\s+(.+)$/); if (match) { - frame = __voidParseFrameLocation(match[1]); + frame = parseFrameLocation(match[1]); } else { match = line.match(/^(.*?)@(.+)$/); if (match) { - const location = __voidParseFrameLocation(match[2]); + const location = parseFrameLocation(match[2]); frame = location ? { ...location, functionName: match[1] } : null; } } @@ -168,13 +166,13 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return frames; } - function __voidGetStackFrames(error: unknown): Array { + function getStackFrames(error: unknown): Array { if (!(error instanceof Error)) { return []; } if (!error.stack && typeof errorStack.captureStackTrace === 'function') { try { - errorStack.captureStackTrace(error, __voidGetStackFrames); + errorStack.captureStackTrace(error, getStackFrames); } catch { // Ignore runtimes that reject captureStackTrace on thrown errors. } @@ -191,7 +189,7 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere const line = callSite.getLineNumber?.(); const column = callSite.getColumnNumber?.(); const functionName = callSite.getFunctionName?.() || callSite.getMethodName?.() || ''; - const frame = __voidCreateStackFrame( + const frame = createStackFrame( functionName, fileName || '', line, @@ -214,16 +212,16 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere } finally { errorStack.prepareStackTrace = originalPrepareStackTrace; } - return __voidParseStackFrames(typeof error.stack === 'string' ? error.stack : ''); + return parseStackFrames(typeof error.stack === 'string' ? error.stack : ''); } - function __voidDescribeThrownValue(value: unknown): ErrorDetails { + function describeThrownValue(value: unknown): ErrorDetails { if (value instanceof Error) { return { name: value.name || 'Error', message: value.message || '', stack: typeof value.stack === 'string' ? value.stack : '', - frames: __voidGetStackFrames(value), + frames: getStackFrames(value), }; } if (typeof value === 'string') { @@ -242,15 +240,15 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return { name: 'Thrown value', message, stack: '', frames: [] }; } - function __voidRenderStack(entry: ErrorDetails): string { + function renderStack(entry: ErrorDetails): string { if (Array.isArray(entry.frames) && entry.frames.length > 0) { const items = entry.frames .map((frame) => { const name = frame.functionName || '(anonymous)'; const location = frame.displayPath + ':' + frame.line + ':' + frame.column; - const body = `${__voidEscapeHtml(name)}${__voidEscapeHtml(location)}`; + const body = `${escapeHtml(name)}${escapeHtml(location)}`; if (frame.href) { - return `${body}`; + return `${body}`; } return `
${body}
`; }) @@ -260,17 +258,17 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere if (!entry.stack) { return ''; } - return `
Stack trace
${__voidEscapeHtml(entry.stack)}
`; + return `
Stack trace
${escapeHtml(entry.stack)}
`; } - function __voidCollectErrorChain(error: unknown): Array { + function collectErrorChain(error: unknown): Array { const chain = []; const seen = new Set(); let current = error; let depth = 0; while (current != null && depth < 8 && !seen.has(current)) { seen.add(current); - const entry = __voidDescribeThrownValue(current); + const entry = describeThrownValue(current); chain.push({ label: depth === 0 ? 'Error' : 'Cause ' + depth, ...entry, @@ -299,18 +297,18 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return chain; } - function __voidRenderDevErrorOverlay(status: number, title: string, error: unknown): string { - const chain = __voidCollectErrorChain(error); + function renderDevErrorOverlay(status: number, title: string, error: unknown): string { + const chain = collectErrorChain(error); const errorCountLabel = chain.length + (chain.length === 1 ? ' Error' : ' Errors'); const sections = chain .map((entry, index) => { const heading = entry.name || (index === 0 ? title : 'Cause'); const message = entry.message || title; - const stack = __voidRenderStack(entry); + const stack = renderStack(entry); if (index === 0) { - return `

${__voidEscapeHtml(message)}

${stack}
`; + return `

${escapeHtml(message)}

${stack}
`; } - return `
${__voidEscapeHtml(entry.label)}

${__voidEscapeHtml(heading)}

${__voidEscapeHtml(message)}

${stack}
`; + return `
${escapeHtml(entry.label)}

${escapeHtml(heading)}

${escapeHtml(message)}

${stack}
`; }) .join(''); return ` -`; +`; } - function __voidInjectDevErrorOverlay(html: string, overlay: string): string { + function injectDevErrorOverlay(html: string, overlay: string): string { return /<\/body\s*>/i.test(html) ? html.replace(/<\/body\s*>/i, overlay + '') : html + overlay; } - function __voidGetErrorAssetCss(error: unknown): string { + function getErrorAssetCss(error: unknown): string { const tags = error && (typeof error === 'object' || typeof error === 'function') && @@ -562,31 +560,29 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere : ''; } - function __voidInjectErrorAssetCss(html: string, css: string): string { + function injectErrorAssetCss(html: string, css: string): string { if (!css) { return html; } return /<\/head\s*>/i.test(html) ? html.replace(/<\/head\s*>/i, css + '') : css + html; } - function __voidRenderErrorPage(status: number, title: string, error?: unknown): string { + function renderErrorPage(status: number, title: string, error?: unknown): string { const heading = status + ': ' + title; const prefix = status + ': '; const overlay = - __VOID_DEV && status === 500 && error - ? __voidRenderDevErrorOverlay(status, title, error) - : ''; + dev && status === 500 && error ? renderDevErrorOverlay(status, title, error) : ''; return ` - ${__voidEscapeHtml(heading)} - ${__voidGetErrorAssetCss(error)} + ${escapeHtml(heading)} + ${getErrorAssetCss(error)}