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
11 changes: 10 additions & 1 deletion packages/boxel-cli/src/commands/realm/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../../lib/profile-manager';
import { unpublishRealm } from './unpublish';
import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
import { describeFetchError } from '../../lib/describe-fetch-error';

const DEFAULT_TIMEOUT_MS = 300_000;
const READINESS_POLL_INTERVAL_MS = 1000;
Expand Down Expand Up @@ -193,7 +194,7 @@ async function waitForPublishedRealmReady(
}
lastError = `HTTP ${response.status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
lastError = describeFetchError(error);
}
let remaining = timeoutMs - (Date.now() - startedAt);
if (remaining <= 0) break;
Expand Down Expand Up @@ -276,6 +277,14 @@ export function registerPublishCommand(realm: Command): void {
console.error(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
);
// Node's fetch surfaces the actual transport error (ECONNRESET,
// TLS failure, undici socket error, etc.) on `error.cause`. Print
// it so opaque "fetch failed" messages don't strand the caller.
// `!= null` rather than a truthy check so we don't drop
// falsy-but-defined causes (`''`, `0`, `false`, `NaN`).
if (err instanceof Error && err.cause != null) {
console.error(`${FG_RED}Caused by:${RESET}`, err.cause);
}
process.exit(1);
}
},
Expand Down
5 changes: 2 additions & 3 deletions packages/boxel-cli/src/commands/realm/unpublish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ProfileManager,
} from '../../lib/profile-manager';
import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors';
import { describeFetchError } from '../../lib/describe-fetch-error';

export interface UnpublishOptions {
/**
Expand Down Expand Up @@ -67,9 +68,7 @@ export async function unpublishRealm(
return {
publishedRealmURL: normalized,
unpublished: false,
error: `Failed to reach realm server: ${
err instanceof Error ? err.message : String(err)
}`,
error: `Failed to reach realm server: ${describeFetchError(err)}`,
};
}

Expand Down
25 changes: 25 additions & 0 deletions packages/boxel-cli/src/lib/describe-fetch-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Node's `fetch` error surface is shallow: the outer error is always
// `TypeError: fetch failed`, and the *real* reason (ECONNRESET, TLS
// failure, undici socket error, etc.) lives on `error.cause`. Inline
// both when summarizing a failed fetch for log output or for embedding
// in a result string returned from a higher-level operation, so that
// opaque "fetch failed" lines don't reach the operator without context.
//
// `error.cause != null` rather than a truthy check so we don't drop
// falsy-but-defined causes (`''`, `0`, `false`, `NaN`). `!= null`
// matches both `null` and `undefined` — i.e., the absence markers —
// and lets every explicit value through.
//
// For user-facing CLI output where the full nested Error (including
// stack frames) is useful, prefer logging `err` and `err.cause` as
// separate console.error arguments so Node pretty-prints them. This
// helper is for the case where the output needs to be a single string.
export function describeFetchError(error: unknown): string {
let msg = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.cause != null) {
let cause = error.cause;
let causeMsg = cause instanceof Error ? cause.message : String(cause);
return `${msg} (caused by: ${causeMsg})`;
}
return msg;
}
56 changes: 56 additions & 0 deletions packages/boxel-cli/tests/lib/describe-fetch-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { describeFetchError } from '../../src/lib/describe-fetch-error';

describe('describeFetchError', () => {
it('returns the message for a plain Error without a cause', () => {
let err = new Error('boom');
expect(describeFetchError(err)).toBe('boom');
});

it('returns the string form for a non-Error value', () => {
expect(describeFetchError('plain string')).toBe('plain string');
expect(describeFetchError(42)).toBe('42');
expect(describeFetchError(null)).toBe('null');
expect(describeFetchError(undefined)).toBe('undefined');
});

it('appends an Error cause with a (caused by: …) suffix', () => {
// Build via assignment to sidestep the ErrorOptions TS lib target.
let socketErr = new Error('ECONNRESET: socket hang up');
let fetchErr = new TypeError('fetch failed') as TypeError & {
cause?: unknown;
};
fetchErr.cause = socketErr;
expect(describeFetchError(fetchErr)).toBe(
'fetch failed (caused by: ECONNRESET: socket hang up)',
);
});

it('renders a non-Error cause via String()', () => {
let err = new Error('outer') as Error & { cause?: unknown };
err.cause = { code: 'ENOTFOUND' };
expect(describeFetchError(err)).toBe('outer (caused by: [object Object])');
});

it('preserves falsy-but-defined causes that a truthy check would drop', () => {
// The behavior this guards is the difference between `error.cause`
// (truthy check, drops falsy values) and `error.cause != null`
// (preserves any explicit value). Verifies the four falsy
// primitives a Promise.reject could plausibly carry.
for (let cause of ['', 0, false, NaN]) {
let err = new Error('outer') as Error & { cause?: unknown };
err.cause = cause;
expect(describeFetchError(err)).toBe(
`outer (caused by: ${String(cause)})`,
);
}
});

it('omits the (caused by: …) suffix for null or undefined causes', () => {
for (let cause of [null, undefined]) {
let err = new Error('outer') as Error & { cause?: unknown };
err.cause = cause;
expect(describeFetchError(err)).toBe('outer');
}
});
});
Loading