Skip to content
Open
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
55 changes: 36 additions & 19 deletions packages/deploy-cloudflare/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,26 @@ import type { CloudflareCommandResult } from './execute.ts';

const MAX_DIAGNOSTIC_LENGTH = 4096;
const MAX_DIAGNOSTIC_LINES = 24;
const ERROR_BOX =
/(?:^|\n)[ \t]*┌[ \t]*(?:APIError|Error)\b[^\n]*\n(?:[ \t]*│[^\n]*\n)*[ \t]*└[^\n]*/i;
// cf draws upload progress, binding tables, config diffs and upload conflicts as log
// lines: a guide symbol, or its fallback for terminals without Unicode, then two spaces.
// These lines can show binding values. cf printed them on stdout before 1.0.0-beta.21,
// and on stderr since.
const LOG_LINE = /^[├│◆◇●▲■└|*o•!x—](?: {2}|$)/;
const UPLOAD_CONFLICT =
/^[ \t]*(?:\S[ \t]+)?(Aborting the upload operation because of conflicts\..*)$/m;
const SCRIPT_API_ORIGIN =
/^[ \t]*(?:\S[ \t]+)?(You are about to upload a Worker that was last updated via the script API\.)/m;

/** Keep the uploader's error, excluding stdout binding tables and redacting common credential formats. */
function withoutLogLines(output: string): string {
return output
.split('\n')
.filter((line) => !LOG_LINE.test(line))
.join('\n');
}

/** Keep the uploader's error, excluding cf's log lines and redacting common credential formats. */
export function cloudflareCommandDiagnostic(result: CloudflareCommandResult): string | undefined {
const stderr = stripVTControlCharacters(result.stderr).replace(/\r\n?/g, '\n');
const stdout = stripVTControlCharacters(result.stdout).replace(/\r\n?/g, '\n');
Expand All @@ -17,29 +35,28 @@ export function cloudflareCommandDiagnostic(result: CloudflareCommandResult): st
}
}
if (message === stderr && !/(?:^|\n)[ \t]*(?:[✘×X][ \t]+)?\[ERROR\]/i.test(stderr)) {
// cf renders API failures as a box on stdout while config warnings go to
// stderr. Only copy the error box; stdout may also contain binding values.
const box =
/(?:^|\n)[ \t]*┌[ \t]*(?:APIError|Error)\b[^\n]*\n(?:[ \t]*│[^\n]*\n)*[ \t]*└[^\n]*/i.exec(
stdout,
);
// Copy only an error box on stdout, the upload conflict without the config diff
// that explains it, or stderr without cf's log lines.
const box = ERROR_BOX.exec(stdout);
const conflict = UPLOAD_CONFLICT.exec(`${stdout}\n${stderr}`);
if (box) {
message = box[0].trim();
} else if (conflict) {
message = [SCRIPT_API_ORIGIN.exec(`${stdout}\n${stderr}`)?.[1], conflict[1]]
.filter(Boolean)
.join('\n');
} else {
const lines = stdout.split('\n');
const abort = lines.find((line) =>
line.startsWith('■ Aborting the upload operation because of conflicts.'),
);
if (abort) {
const reason = 'You are about to upload a Worker that was last updated via the script API.';
message = [lines.includes(`▲ ${reason}`) ? reason : '', abort.slice('■ '.length)]
.filter(Boolean)
.join('\n');
}
// Keep an error box on stderr whole: its bars look like log lines.
const stderrBox = ERROR_BOX.exec(stderr);
message = stderrBox
? withoutLogLines(stderr.slice(0, stderrBox.index)) +
stderrBox[0] +
withoutLogLines(stderr.slice(stderrBox.index + stderrBox[0].length))
: withoutLogLines(stderr);
}
}
// Upload progress and bindings are printed on stdout. Never echo that whole stream
// when no error block was found, even when the process failed without stderr.
// Never echo stdout, or cf's log lines on stderr, when no error block was found,
// even when the process failed without stderr.
message = stripVTControlCharacters(message)
.split(/\n\s*(?:🪵|Logs (?:were )?written|If you think this is a bug)/)[0]!
.trim();
Expand Down
86 changes: 86 additions & 0 deletions packages/deploy-cloudflare/test/unit/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,92 @@ describe('Cloudflare command diagnostics', () => {
).toBe('┌ APIError\n│ [10000] Worker upload failed.\n└');
});

it('keeps an error box and its advice on stderr without the binding table before it', () => {
expect(
cloudflareCommandDiagnostic({
status: 1,
stdout: '',
// cf 1.0.0-beta.21 and later print upload progress and bindings on stderr.
stderr: [
'├ Upload',
'│ Your Worker has access to the following bindings:',
'│ Binding Resource',
'│ env.PRIVATE_VALUE ("do-not-echo") Environment Variable',
'| env.ASCII_VALUE ("do-not-echo") Environment Variable',
'│',
'',
'┌ APIError',
'│ [10000] Authentication error [code: 10000]',
'│',
'│ 403 Forbidden',
'└',
'',
'→ Check your API token permissions',
].join('\n'),
}),
).toBe(
'┌ APIError\n│ [10000] Authentication error [code: 10000]\n│\n│ 403 Forbidden\n└\n\n→ Check your API token permissions',
);
expect(
cloudflareCommandDiagnostic({
status: 1,
stdout: '',
stderr: '├ Upload\n│ env.PRIVATE_VALUE ("do-not-echo")\nUpload stopped.',
}),
).toBe('Upload stopped.');
expect(
cloudflareCommandDiagnostic({
status: 1,
stdout: '',
stderr: '├ Upload\n│ env.PRIVATE_VALUE ("do-not-echo")\n◆ Version upload complete',
}),
).toBeUndefined();
});

it('reports upload conflicts from either stream without the config diff', () => {
const abort =
'Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag';
const reason = 'You are about to upload a Worker that was last updated via the script API.';
const scriptApi = [
'├ Upload',
`▲ ${reason}`,
'│ Edits that have been made via the script API will be overridden by your local code and config.',
`■ ${abort}`,
'◆ Version upload complete',
].join('\n');
// cf 1.0.0-beta.21 and later print upload messages on stderr, earlier releases on stdout.
for (const [stdout, stderr] of [
['', scriptApi],
[scriptApi, ''],
// Without a Unicode terminal, cf prints fallback symbols.
['', scriptApi.replace('▲', '!').replace('■', 'x').replace('│', '|').replace('◆', '*')],
]) {
expect(cloudflareCommandDiagnostic({ status: 1, stdout: stdout!, stderr: stderr! })).toBe(
`${reason}\n${abort}`,
);
}
expect(
cloudflareCommandDiagnostic({
status: 1,
stdout: '',
stderr: [
'├ Upload',
'▲ The local configuration being used (generated from your local configuration file) differs from the remote configuration of your Worker set via the Cloudflare Dashboard:',
'│ {',
'│ vars: {',
'│ - REMOTE_VALUE: "do-not-echo"',
`│ + LOCAL_VALUE: "${abort} do-not-echo"`,
'│ }',
'│ }',
'│',
'│ Uploading the Worker will override the remote configuration with your local one.',
`■ ${abort}`,
'◆ Version upload complete',
].join('\n'),
}),
).toBe(abort);
});

it('retains plain stderr and process-spawn failures', () => {
expect(
cloudflareCommandDiagnostic({ status: 1, stdout: '', stderr: 'Unknown argument: strict' }),
Expand Down
2 changes: 1 addition & 1 deletion packages/void/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@
"jsonc-parser": "^3.3.1",
"mime": "^4.1.1",
"miniflare": "catalog:",
"minimatch": "^10.2.6",
"minimatch": "^11.0.3",
"mysql2": "^3.24.6",
"pg": "^8.23.1",
"postal-mime": "catalog:",
Expand Down
19 changes: 12 additions & 7 deletions packages/void/src/cli/cf-command-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ export function isD1Bookmark(value: unknown): value is string {
return typeof value === 'string' && /^[0-9a-f]+(?:-[0-9a-f]+)+$/i.test(value);
}

/** Read the version ID and URLs that cf prints after a prebuilt upload. */
export function readCfUploadOutput(
stdout: string,
): Record<'versionId' | 'previewUrl' | 'workersDevUrl', string | undefined> {
/**
* Read the version ID and URLs that cf prints after a prebuilt upload: on stdout before
* cf 1.0.0-beta.21, and on stderr since.
*/
export function readCfUploadOutput(result: {
stdout: string;
stderr: string;
}): Record<'versionId' | 'previewUrl' | 'workersDevUrl', string | undefined> {
const output = `${result.stdout}\n${result.stderr}`;
return {
versionId: stdout.match(/(?:Worker|Current) Version ID:\s*([0-9a-f]{8}-[0-9a-f-]{27})/i)?.[1],
previewUrl: stdout.match(/Version Preview(?: Alias)? URL:\s*(https:\/\/\S+)/i)?.[1],
workersDevUrl: stdout.match(/(https:\/\/[a-z0-9.-]+\.workers\.dev(?:\/[^\s]*)?)/i)?.[1],
versionId: output.match(/(?:Worker|Current) Version ID:\s*([0-9a-f]{8}-[0-9a-f-]{27})/i)?.[1],
previewUrl: output.match(/Version Preview(?: Alias)? URL:\s*(https:\/\/\S+)/i)?.[1],
workersDevUrl: output.match(/(https:\/\/[a-z0-9.-]+\.workers\.dev(?:\/[^\s]*)?)/i)?.[1],
};
}
5 changes: 4 additions & 1 deletion packages/void/src/cli/cf-version-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,10 @@ async function prepareContainerMetadata(options: {
if (pushed.status !== 0 || pushed.signal || pushed.error) {
throw new Error('Cloudflare Container image push failed.');
}
const matches = [...pushed.stdout.matchAll(/Pushed image:\s*(\S+)/g)];
// cf prints this on stdout before 1.0.0-beta.21, and on stderr since.
const matches = [
...`${pushed.stdout}\n${pushed.stderr}`.matchAll(/Pushed image:\s*(\S+)/g),
];
const remote = matches.length === 1 ? matches[0][1] : '';
if (
!remote.startsWith(`registry.cloudflare.com/${options.accountId}/`) ||
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/cli/cf-version-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ export function createCfVersionRunner(options: {
if (record(result.data) && typeof result.data.versionId === 'string') {
return result;
}
const { versionId, previewUrl, workersDevUrl } = readCfUploadOutput(result.stdout);
const { versionId, previewUrl, workersDevUrl } = readCfUploadOutput(result);
return {
...result,
data: versionId
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/cli/cloudflare-operation-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,7 @@ async function workerChanges(): Promise<Result> {
} catch {
/* Human upload presentation. */
}
const printed = readCfUploadOutput(called.stdout);
const printed = readCfUploadOutput(called);
const versionId = uploaded.version_id ?? uploaded.id ?? uploaded.versionId ?? printed.versionId;
if (typeof versionId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(versionId)) {
throw new Error('Cloudflare did not return the uploaded Worker version ID.');
Expand Down
5 changes: 4 additions & 1 deletion packages/void/test/unit/cf-build-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,10 @@ it(
});
expect(built.workers.default.config.manifest?.mainModule).toBe('index.js');
expect(built.workers.default.assetsDir).toBeDefined();
expect(expectCfDryRun(prepared.directory).stdout).toContain('--dry-run: exiting now.');
const dryRun = expectCfDryRun(prepared.directory);
// cf 1.0.0-beta.21 and later print upload messages on stderr.
expect(dryRun.stdout).toBe('');
expect(dryRun.stderr).toContain('--dry-run: exiting now.');
},
);
expect(existsSync(disposed.directory)).toBe(false);
Expand Down
Loading