Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Full-stack framework for Cloudflare Workers, powered by Vite.

The `void` package on npm has these previous versions: '0.0.1', '0.1.0', '0.1.2', '0.1.3', '0.1.5', '0.1.6', '1.0.0', '1.0.1', '1.0.2', '3.0.0', '3.0.1'. We have unpublished these versions but they cannot be published again. In package release workflows, make sure to avoid / skip these versions for `packages/void`.

Deploy hosted production before publishing the npm packages: the CLI checks the platform version. See [operations](./platform/meta/operations.md#github-actions).

## Best Practices That Must Be Followed

- **Do not use Python for repository scripts or automation**, including inline CI scripts and ad-hoc maintenance commands. Use Node.js with TypeScript/JavaScript, or shell/PowerShell where appropriate.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/platform/installation/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Repair recreates missing infrastructure that the installer owns. It does not res

### Upgrade the platform

Keep the platform runtime current when updating the Void CLI. If a developer's CLI reports that project lookup requires an upgrade, upgrade the platform before retrying.
Keep the platform runtime current when updating the Void CLI. The CLI requires platform version 0.23.0 or newer. If a developer's CLI reports that the platform is older than that, upgrade the platform before retrying.

Use the installed CLI's packaged runtime to upgrade:

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Files, running processes, and listening servers last only as long as the contain

Sandboxes require [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans) and Containers access. A managed platform's runtime token needs Account / Containers: Edit and Account / Cloudchamber: Edit.

`void deploy --platform cloudflare` builds native images and deploys the Sandbox alongside the application. `void deploy` on a connected Void Platform applies the platform's Sandbox concurrency and runtime limits. Upgrade the platform before deploying an application built with this Sandbox API.
`void deploy --platform cloudflare` builds native images and deploys the Sandbox alongside the application. `void deploy` on a connected Void Platform applies the platform's Sandbox concurrency and runtime limits. The platform must run version 0.23.0 or newer.

## Moving from Sandbox SDK 0.x

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/self-hosted-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ A Void platform lets your team deploy apps into a shared Cloudflare account. As

If you're deploying an app for yourself, [deploy directly to Cloudflare](/integrations/cloudflare#deploy-to-your-own-cloudflare-account). You don't need to install a platform first.

The Void CLI works with platforms on version 0.23.0 or newer. On an older platform, commands stop with an error. Ask the platform administrator to run [`void platform upgrade`](/guide/platform/installation/maintenance#upgrade-the-platform). Signing in still works.

The core platform supports GitHub, Google, generic OIDC, and Cloudflare Access login, CLI deploys, D1, KV, R2, Queues, cron jobs, Workers AI, WebSockets, SSE, ISR, routing, logs, and rollback.

The core installation includes managed Sandboxes and an email gateway; email is enabled when you configure a shared mail domain and zone. The user dashboard, GitHub builds and webhooks, build Containers, and custom project domains aren't part of the core installation. Source-built platforms that add the optional GitHub services should follow the [isolated webhook ingress setup](/guide/platform/development/runtime#optional-github-webhook-ingress-for-access-protected-apis) when Access protects the API.
Expand Down
1 change: 1 addition & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ generated entries must preserve the module-boundary rules below.

- **Cloudflare session switching**: Explicit `void cloudflare login` forces a fresh browser sign-in, while automatic authentication reuses valid sessions. Adopted OAuth sessions persist the keyring preference so later compatible CLI logins update the same encrypted store. Environment API credentials block explicit browser login rather than masking it. Status reports identity/auth method and the pinned account separately; login never rewrites the project target. Keep credentials out of all diagnostics.

- **Minimum platform version**: `MIN_PLATFORM_VERSION` in `src/cli/client.ts` is the oldest Void platform release the CLI supports. `PlatformClient` checks public discovery once per process and base URL and keeps every outcome. A release version below the minimum, or a 404 from discovery (a platform that predates it), stops the command. `"hosted"` (a hosted deploy without `PLATFORM_VERSION`) and other non-release strings (custom or development builds) pass, as do unusable answers (network errors, a disabled platform, Access login pages, invalid documents), which leave the real request to report its own error. Hosted deploys set `PLATFORM_VERSION` from `packages/platform/package.json` (`platform/scripts/platform-version.ts`). Only login opts out. Operator, installer, and management commands do not use `PlatformClient`, so they keep working on older runtimes. Do not add CLI fallbacks for platforms older than the minimum. When a server change ships in a release, raise the minimum to that release and delete the fallbacks marked for it in the same change.
- **Authentication commands**: `void auth login|status|logout` follows the saved `.void/project.json` deployment platform, or prompts when unset; headless calls need `--platform` or an explicit command. `void account` always targets a Void platform account, and `void cloudflare` always targets Cloudflare. Keep `void auth whoami|link|token` as compatibility shortcuts; `link` and `token` belong only to Void accounts. Auth selection never changes the deployment preference.

- **Repeatable framework wrappers**: `generateWrapper` recognizes current and legacy wrapper entries and preserves the original application across `--skip-build` runs. Compile handlers before replacing the entry, stage the wrapper for an atomic rename, and copy a new original only after an actual new build. A missing or recursively wrapped original requires a rebuild; readiness alone cannot validate the application entry.
Expand Down
3 changes: 0 additions & 3 deletions packages/void/src/cli/auth-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ export async function runAuthLink(root: string, connectionId?: string): Promise<
}
const target = await selectPlatformLoginMethod(platform, connectionId);
const existing = await new PlatformClient(token, apiUrl).whoami();
if (!existing.id) {
throw new Error('auth: This platform does not support account linking.');
}
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) =>
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/cli/build-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ function isBuildLogComplete(page: BuildLogsResponse): boolean {
if (!page.terminal) {
return false;
}
if (page.logsComplete === null || page.logsComplete === undefined) {
// `null` means the server cannot prove completion.
if (page.logsComplete === null) {
throw new Error(
'This platform cannot confirm that the final build logs were captured. Ask its administrator to upgrade the platform runtime, or read the retained logs without --follow.',
);
Expand Down
188 changes: 153 additions & 35 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isCloudflareAccessChallenge,
} from './cf-access.ts';
import type { CliLogger } from './log-file.ts';
import { readPlatformDiscoveryResponse } from './platform-discovery.ts';
import {
formatPlatformApiError,
parsePlatformErrorBody,
Expand Down Expand Up @@ -43,6 +44,77 @@ export function readCliVersion(): string | null {

const VOID_CLI_VERSION = readCliVersion();

/** The oldest Void platform release this CLI supports. */
export const MIN_PLATFORM_VERSION = '0.23.0';

const RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;

/**
* Version checks keyed by platform base URL. Every outcome (pass, inconclusive,
* or too old) is kept for the rest of the process, so polling commands probe
* discovery once.
*/
const platformVersionChecks = new Map<string, Promise<void>>();

/**
* What public discovery says about the platform version:
* - `version`: the reported `platformVersion`
* - `missing`: discovery answered 404, so the platform predates discovery
* - `unknown`: no usable answer (network error, timeout, disabled platform,
* Access login page, or an invalid document)
*/
type PlatformVersionProbe =
| { kind: 'version'; version: string }
| { kind: 'missing' }
| { kind: 'unknown' };

/**
* Returns whether `version` is older than MIN_PLATFORM_VERSION, or null when
* it is not a release version. A prerelease sorts before its release.
*/
function isOlderThanMinimumPlatform(version: string): boolean | null {
const actual = RELEASE_VERSION.exec(version);
const minimum = RELEASE_VERSION.exec(MIN_PLATFORM_VERSION)!;
if (!actual) {
return null;
}
for (const part of [1, 2, 3]) {
const difference = Number(actual[part]) - Number(minimum[part]);
if (difference !== 0) {
return difference < 0;
}
}
return actual[4] !== undefined && minimum[4] === undefined;
}

const UPGRADE_PLATFORM_ACTION =
'Ask its administrator to upgrade the platform runtime with `void platform upgrade`.';

function assertSupportedPlatform(apiUrl: string, probe: PlatformVersionProbe): void {
if (probe.kind === 'unknown') {
// Let the real request run and report its own error.
return;
}
if (probe.kind === 'missing') {
// Every Void platform release serves discovery, so a 404 there means a
// platform that predates it, and therefore predates the minimum.
throw new Error(
`The Void platform at ${apiUrl} does not report its version, but this CLI requires ${MIN_PLATFORM_VERSION} or later. ${UPGRADE_PLATFORM_ACTION}`,
);
}
const { version } = probe;
// Installed and hosted platforms report their release version. "hosted"
// comes from a hosted deployment that does not set PLATFORM_VERSION yet.
// Any other value that is not a release version comes from a custom or
// development build and cannot be ordered. Both pass.
if (version === 'hosted' || isOlderThanMinimumPlatform(version) !== true) {
return;
}
throw new Error(
`The Void platform at ${apiUrl} runs version ${version}, but this CLI requires ${MIN_PLATFORM_VERSION} or later. ${UPGRADE_PLATFORM_ACTION}`,
);
}

export { parsePlatformErrorBody } from './platform-errors.ts';
export type { PlatformErrorPayload } from './platform-errors.ts';

Expand Down Expand Up @@ -114,7 +186,7 @@ export type ProjectResponse = {
type: string;
created_at: string;
custom_domain: string | null;
role?: ProjectRole;
role: ProjectRole;
owner?: {
id: string;
login?: string;
Expand Down Expand Up @@ -300,7 +372,7 @@ export type BuildLogsResponse = {
lastSeq: number;
status: string;
terminal: boolean;
logsComplete?: boolean | null;
logsComplete: boolean | null;
external: string | null;
};

Expand Down Expand Up @@ -446,6 +518,7 @@ export type DomainResponse = {
};

export type DomainStatusResponse = DomainResponse & {
cname_target: string;
ssl_status: string;
verification_errors?: Array<string>;
cf_status?: string;
Expand All @@ -470,7 +543,6 @@ export type GithubConnection = {
project_id: string;
installation_id: string;
repo_full_name: string;
repository_id: string | null;
branch: string;
build_executor: 'container' | 'github_actions';
workflow_path: string;
Expand Down Expand Up @@ -523,6 +595,10 @@ const DEPLOY_OBSERVATION_DEADLINE_MS = 30 * 60_000;
const DEPLOY_OBSERVATION_BACKOFF_MS = [1000, 2000, 5000, 10_000, 30_000];
const DEPLOY_EVENT_MAX_BYTES = 1024 * 1024;

// Heartbeats carry no `seq`. Heartbeat `status` and the observation page
// `heartbeat` come from commit 21d4ec46, which 0.23.0 does not ship; keep them
// optional until MIN_PLATFORM_VERSION includes it. `recoveryAt` is optional:
// it is sent only while an execution lease exists.
type SequencedDeployEvent = DeployEvent & { seq?: number };
class DeploymentProtocolError extends Error {}
class DeploymentObservationTimeoutError extends Error {
Expand Down Expand Up @@ -579,17 +655,26 @@ export class PlatformClient {
private readonly authHeaders: Record<string, string>;
private readonly accessHeaders: Record<string, string>;
private readonly cliLog?: CliLogger;
private readonly checkPlatformVersion: boolean;
readonly apiUrl: string;

/**
* `checkPlatformVersion: false` skips the minimum platform version check.
* Only login uses it, so users can still sign in to an older platform.
*/
constructor(
token: string,
options?: string | { apiUrl?: string; root?: string; cliLog?: CliLogger },
options?:
| string
| { 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.apiUrl = this.baseUrl;
ensureCloudflaredToken(this.baseUrl);
Expand All @@ -608,6 +693,55 @@ export class PlatformClient {
if (new URL(url).origin !== this.baseUrl) {
throw new Error('Platform credentials cannot be sent to another origin.');
}
if (this.checkPlatformVersion) {
await this.ensureSupportedPlatform();
}
return this.send(url, options);
}

/**
* Checks the platform version once per process and base URL. Concurrent
* first requests share one check, and its outcome is kept for later requests.
*/
private ensureSupportedPlatform(): Promise<void> {
const apiUrl = this.baseUrl;
const cached = platformVersionChecks.get(apiUrl);
if (cached) {
return cached;
}
const check = this.probePlatformVersion().then((probe) =>
assertSupportedPlatform(apiUrl, probe),
);
platformVersionChecks.set(apiUrl, check);
return check;
}

/** Reads the platform version from public discovery, without credentials. */
private async probePlatformVersion(): Promise<PlatformVersionProbe> {
let response: Response;
try {
response = await this.send(`${this.baseUrl}/.well-known/void-platform`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
} catch {
// A network error or timeout says nothing about the version. The real
// request reports its own error if the platform is unreachable.
return { kind: 'unknown' };
}
if (response.status === 404) {
await response.body?.cancel();
return { kind: 'missing' };
}
try {
const discovery = await readPlatformDiscoveryResponse(this.baseUrl, response);
return { kind: 'version', version: discovery.platformVersion };
} catch {
return { kind: 'unknown' };
}
}

private async send(url: string | URL, options: RequestInit): Promise<Response> {
// A service-token pair is long-lived and was captured before managed deploy
// scrubs the parent environment. User tokens can be renewed in the browser,
// so keep checking for a newer environment value when no service pair was
Expand Down Expand Up @@ -711,11 +845,6 @@ export class PlatformClient {
const res = await this.request(`${this.baseUrl}/projects/choices`, {
headers: { ...this.authHeaders },
});
if (res.status === 404) {
throw new Error(
'This platform does not support project lookup. Ask its administrator to upgrade the platform runtime.',
);
}
if (!res.ok) {
await throwPlatformApiError('Failed to list project choices', res);
}
Expand All @@ -741,11 +870,6 @@ export class PlatformClient {
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.status === 404) {
throw new Error(
'This platform does not support project lookup. Ask its administrator to upgrade the platform runtime.',
);
}
if (!res.ok) {
await throwPlatformApiError('Failed to resolve project', res);
}
Expand Down Expand Up @@ -1229,22 +1353,22 @@ export class PlatformClient {

const receipt = res.headers.get('x-void-deployment-id');
const observationDeadline = Date.now() + DEPLOY_OBSERVATION_DEADLINE_MS;
if (receipt !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(receipt)) {
if (receipt === null || !/^[A-Za-z0-9_-]{1,128}$/.test(receipt)) {
void res.body?.cancel().catch(() => {});
throw new Error('Invalid deployment receipt from platform.');
throw new Error(
receipt === null
? 'Platform returned no deployment receipt.'
: 'Invalid deployment receipt from platform.',
);
}
let cursor = 0;
let terminal = false;
// Receipt-aware platforms own execution independently of this observer.
// Capturing the ID before the first byte also permits recovery when start
// was lost. Older platforms retain their original stream-only behavior.
// The platform owns execution independently of this observer. Capturing
// the ID before the first byte also permits recovery when start was lost.
if (!res.body) {
if (receipt) {
yield { event: 'start', deploymentId: receipt };
yield* this.followDeploymentEvents(projectId, receipt, cursor, observationDeadline);
return;
}
throw new Error('Platform returned no deployment event stream.');
yield { event: 'start', deploymentId: receipt };
yield* this.followDeploymentEvents(projectId, receipt, cursor, observationDeadline);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
Expand All @@ -1257,9 +1381,6 @@ export class PlatformClient {
let streamFinished = false;
let readTimer: ReturnType<typeof setTimeout> | undefined;
const read = async () => {
if (!receipt) {
return reader.read();
}
const remaining = observationDeadline - Date.now();
if (remaining <= 0) {
throw new DeploymentObservationTimeoutError(receipt);
Expand Down Expand Up @@ -1301,20 +1422,18 @@ export class PlatformClient {
lastEventName = event.event;
cliLog?.info('deploy_event', { eventCount, event: lastEventName, raw: line.slice(0, 200) });
// The receipt already supplied start, but still advance its sequence.
if (receipt && event.event === 'start') {
if (event.event === 'start') {
return null;
}
terminal = event.event === 'done' || event.event === 'error';
return event;
};

try {
if (receipt) {
yield { event: 'start', deploymentId: receipt };
}
yield { event: 'start', deploymentId: receipt };
while (true) {
const { done, value } = await read();
if (receipt && Date.now() >= observationDeadline) {
if (Date.now() >= observationDeadline) {
throw new DeploymentObservationTimeoutError(receipt);
}
if (done) {
Expand Down Expand Up @@ -1365,7 +1484,6 @@ export class PlatformClient {
// Parse/protocol errors are not transient transport failures. Never hide
// them behind polling, and never replay a POST that yielded a receipt.
if (
!receipt ||
err instanceof DeploymentObservationTimeoutError ||
err instanceof DeploymentProtocolError ||
err instanceof SyntaxError ||
Expand All @@ -1382,7 +1500,7 @@ export class PlatformClient {
}
reader.releaseLock();
}
if (receipt && !terminal) {
if (!terminal) {
yield* this.followDeploymentEvents(projectId, receipt, cursor, observationDeadline);
}
}
Expand Down
Loading
Loading