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
52 changes: 50 additions & 2 deletions packages/deploy-core/src/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,43 @@ export type DeploySourceMetadata = {
gitRef: string | null;
};

/** JSON payload accepted by the managed deployment API. */
/** One Worker module uploaded to R2 before the deployment is finalized. */
export type DeployWorkerModule = {
/** Module path relative to the Worker output, the same key `workerFiles` uses. */
name: string;
/** Byte length of the module. */
size: number;
/** SHA-256 of the module bytes as 64 lowercase hex characters. */
sha256: string;
};

/** Request body of `POST /projects/:id/deploy/worker-upload-urls`. */
export type DeployWorkerUploadRequest = {
modules: Array<DeployWorkerModule>;
};

/** Upload URLs for each requested module, in request order. */
export type DeployWorkerUploadResponse = {
id: string;
urls: Array<{ url: string; headers: Record<string, string> }>;
};

/** Worker modules already uploaded through `worker-upload-urls`, named by its upload `id`. */
export type DeployWorkerInput = {
id: string;
modules: Array<DeployWorkerModule>;
};

/**
* JSON payload accepted by the managed deployment API. Worker modules travel
* either inline as `workerFiles` or uploaded beforehand as `workerInput`,
* never both.
*/
export type DeployUploadPayload = {
manifest: DeployManifest;
assetManifest: Record<string, DeployAssetManifestEntry>;
workerFiles?: Record<string, string>;
workerInput?: DeployWorkerInput;
} & DeploySourceMetadata & {
preflight: 'true' | null;
};
Expand Down Expand Up @@ -61,15 +93,31 @@ export function createDeployArtifact(input: {
};
}

/** Convert the canonical artifact into the stable hosted-platform wire shape. */
/**
* Convert the canonical artifact into the stable hosted-platform wire shape.
* Pass `workerInput` when the Worker modules were uploaded beforehand; the
* artifact must then carry no inline `workerFiles`.
*/
export function createDeployUploadPayload(
artifact: DeployArtifact,
metadata: DeploySourceMetadata & { preflight: 'true' | null },
workerInput?: DeployWorkerInput,
): DeployUploadPayload {
if (workerInput && artifact.workerFiles) {
throw new Error('A deployment sends its Worker modules inline or as uploads, not both.');
}
return {
manifest: { ...artifact.manifest, bindings: { ...artifact.manifest.bindings } },
assetManifest: { ...artifact.assetManifest },
...(artifact.workerFiles ? { workerFiles: { ...artifact.workerFiles } } : {}),
...(workerInput
? {
workerInput: {
id: workerInput.id,
modules: workerInput.modules.map((module) => ({ ...module })),
},
}
: {}),
...metadata,
};
}
4 changes: 4 additions & 0 deletions packages/deploy-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ export type {
DeployAssetUploadRequest,
DeploySourceMetadata,
DeployUploadPayload,
DeployWorkerInput,
DeployWorkerModule,
DeployWorkerUploadRequest,
DeployWorkerUploadResponse,
} from './artifact.ts';
export {
DEPLOY_ARTIFACT_VERSION,
Expand Down
34 changes: 34 additions & 0 deletions packages/deploy-core/test/unit/artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,38 @@ describe('deployment artifacts', () => {
});
expect(payload).not.toHaveProperty('artifactVersion');
});

it('names uploaded Worker modules by workerInput instead of carrying their bytes', () => {
const metadata = {
source: 'cli',
commit: null,
repositoryUrl: null,
commitUrl: null,
gitRef: null,
preflight: null,
};
const workerInput = {
id: 'upload-1',
modules: [{ name: 'index.js', size: 18, sha256: 'a'.repeat(64) }],
};
const payload = createDeployUploadPayload(
createDeployArtifact({ manifest, assetManifest: {} }),
metadata,
workerInput,
);

expect(payload.workerInput).toEqual(workerInput);
expect(payload).not.toHaveProperty('workerFiles');
expect(() =>
createDeployUploadPayload(
createDeployArtifact({
manifest,
assetManifest: {},
workerFiles: { 'index.js': 'ZXhwb3J0IGRlZmF1bHQge30=' },
}),
metadata,
workerInput,
),
).toThrow('not both');
});
});
32 changes: 28 additions & 4 deletions packages/platform/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export type PlatformDiscoveryDocument = Readonly<{
capabilities: PlatformCapabilities;
displayName: string;
dashboardUrl?: string;
/**
* The API accepts Worker modules uploaded to R2 through
* `POST /projects/:id/deploy/worker-upload-urls`. Older platforms omit it.
*/
directWorkerUpload?: boolean;
installationId: string | null;
platformVersion: string;
proxyUrl: string;
Expand All @@ -54,11 +59,24 @@ function normalizeOrigin(value: string, name: 'apiUrl' | 'proxyUrl'): string {
return url.origin;
}

type PlatformDiscoveryInput = Omit<
PlatformDiscoveryDocument,
'directWorkerUpload' | 'schemaVersion' | 'usageProtocolVersion'
> & {
usageProtocolVersion?: 0 | 1 | 2;
};

/**
* Builds the document a platform serves at `/.well-known/void-platform`.
* Every platform built from this source accepts direct Worker uploads.
*/
export function createPlatformDiscoveryDocument(
input: Omit<PlatformDiscoveryDocument, 'schemaVersion' | 'usageProtocolVersion'> & {
usageProtocolVersion?: 0 | 1 | 2;
},
input: PlatformDiscoveryInput,
): PlatformDiscoveryDocument {
return { ...normalizeDiscoveryDocument(input), directWorkerUpload: true };
}

function normalizeDiscoveryDocument(input: PlatformDiscoveryInput): PlatformDiscoveryDocument {
if (!input.displayName.trim()) {
throw new Error('displayName must not be empty.');
}
Expand Down Expand Up @@ -195,7 +213,11 @@ export function parsePlatformDiscoveryDocument(value: unknown): PlatformDiscover
};
const apiUrl = string('apiUrl');
const proxyUrl = string('proxyUrl');
return createPlatformDiscoveryDocument({
const directWorkerUpload = document.directWorkerUpload;
if (directWorkerUpload !== undefined && typeof directWorkerUpload !== 'boolean') {
throw new Error('directWorkerUpload must be a boolean.');
}
const parsed = normalizeDiscoveryDocument({
apiUrl,
applicationDomain: string('applicationDomain'),
...(document.applicationRouting !== undefined
Expand All @@ -213,6 +235,8 @@ export function parsePlatformDiscoveryDocument(value: unknown): PlatformDiscover
proxyUrl,
usageProtocolVersion: parseUsageProtocolVersion(document.usageProtocolVersion),
});
// Report only what the platform said: an older platform omits the field.
return directWorkerUpload === undefined ? parsed : { ...parsed, directWorkerUpload };
}

function parseAuthentication(value: unknown): PlatformAuthenticationDiscovery {
Expand Down
21 changes: 21 additions & 0 deletions packages/platform/test/unit/auth-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,24 @@ describe('authentication discovery compatibility', () => {
expect(JSON.stringify(parsed.authentication)).not.toContain('internal.example.com');
});
});

describe('direct Worker upload discovery', () => {
it('advertises direct Worker uploads on every document this platform serves', () => {
for (const input of [base, modern]) {
const discovery = createPlatformDiscoveryDocument(input);
expect(discovery.directWorkerUpload).toBe(true);
expect(parsePlatformDiscoveryDocument(discovery)).toEqual(discovery);
}
});

it('reports only what an older platform said', () => {
const { directWorkerUpload: _, ...older } = createPlatformDiscoveryDocument(base);
expect(parsePlatformDiscoveryDocument(older)).not.toHaveProperty('directWorkerUpload');
expect(
parsePlatformDiscoveryDocument({ ...older, directWorkerUpload: false }).directWorkerUpload,
).toBe(false);
expect(() => parsePlatformDiscoveryDocument({ ...older, directWorkerUpload: 'yes' })).toThrow(
'directWorkerUpload must be a boolean.',
);
});
});
108 changes: 91 additions & 17 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import {
type ProjectPermission,
} from './platform-errors.ts';
import { normalizePlatformApiUrl } from './platform-registry.ts';
import type { DeployAssetUploadRequest, DeployEvent, DeployUploadPayload } from './types.ts';
import type {
DeployAssetUploadRequest,
DeployEvent,
DeployUploadPayload,
DeployWorkerModule,
DeployWorkerUploadRequest,
DeployWorkerUploadResponse,
} from './types.ts';

// Null omits `X-Void-CLI-Version`: the platform answers an unparseable version with 426.
function readCliVersion(): string | null {
Expand All @@ -34,21 +41,22 @@ export const MIN_PLATFORM_VERSION = '1.1.11';
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.
* Discovery probes 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>>();
const platformVersionProbes = new Map<string, Promise<PlatformVersionProbe>>();

/**
* What public discovery says about the platform version:
* - `version`: the reported `platformVersion`
* - `version`: the reported `platformVersion`, and whether the platform
* accepts Worker modules uploaded straight to R2
* - `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: 'version'; version: string; directWorkerUpload: boolean }
| { kind: 'missing' }
| { kind: 'unknown' };

Expand Down Expand Up @@ -661,19 +669,34 @@ export class PlatformClient {

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

private platformProbe(): Promise<PlatformVersionProbe> {
const cached = platformVersionProbes.get(this.baseUrl);
if (cached) {
return cached;
}
const check = this.probePlatformVersion().then((probe) =>
assertSupportedPlatform(apiUrl, probe),
);
platformVersionChecks.set(apiUrl, check);
return check;
const probe = this.probePlatformVersion();
platformVersionProbes.set(this.baseUrl, probe);
return probe;
}

/**
* Whether discovery says the platform accepts Worker modules uploaded to R2.
* False when discovery omits the field or gave no usable answer.
*/
async supportsDirectWorkerUpload(): Promise<boolean> {
let probe = await this.platformProbe();
if (probe.kind === 'unknown') {
// One failed probe must not send a large Worker inline. Ask again,
// without replacing the outcome the version check keeps.
probe = await this.probePlatformVersion();
}
return probe.kind === 'version' && probe.directWorkerUpload;
}

/** Reads the platform version from public discovery, without credentials. */
Expand All @@ -695,7 +718,11 @@ export class PlatformClient {
}
try {
const discovery = await readPlatformDiscoveryResponse(this.baseUrl, response);
return { kind: 'version', version: discovery.platformVersion };
return {
kind: 'version',
version: discovery.platformVersion,
directWorkerUpload: discovery.directWorkerUpload === true,
};
} catch {
return { kind: 'unknown' };
}
Expand Down Expand Up @@ -1117,6 +1144,53 @@ export class PlatformClient {
return json.urls;
}

/**
* Requests one presigned R2 PUT per Worker module, in `modules` order. The
* platform refuses a Worker over Cloudflare's size limit here (413
* `worker_too_large`), before any bytes are uploaded.
*/
async requestWorkerUploadUrls(
projectId: string,
modules: Array<DeployWorkerModule>,
): Promise<DeployWorkerUploadResponse> {
const url = `${this.baseUrl}/projects/${projectId}/deploy/worker-upload-urls`;
this.cliLog?.info('worker_upload_urls_post', { url, moduleCount: modules.length });

const request: DeployWorkerUploadRequest = { modules };
const res = await this.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authHeaders,
},
body: JSON.stringify(request),
});
this.cliLog?.info('worker_upload_urls_response', {
status: res.status,
cfRay: res.headers.get('cf-ray'),
});

if (!res.ok) {
await throwPlatformApiError('Failed to request worker upload URLs', res, 'deploy');
}

// Module i uploads to urls[i], so a short or malformed list must not start.
const json = (await res.json()) as Partial<DeployWorkerUploadResponse> | null;
if (
typeof json?.id !== 'string' ||
!json.id ||
!Array.isArray(json.urls) ||
json.urls.length !== modules.length ||
!json.urls.every(
(entry) =>
typeof entry?.url === 'string' && typeof entry.headers === 'object' && !!entry.headers,
)
) {
throw new Error('Invalid worker upload URLs from platform.');
}
return { id: json.id, urls: json.urls };
}

async *finalizeDeploy(
projectId: string,
payload: ManifestOnlyDeployPayload,
Expand Down
Loading
Loading