Skip to content

Commit c80b747

Browse files
authored
fix(web-shared): hydrate FatalError/RetryableError and Error subclasses in o11y (vercel#1942)
* fix(web-shared): hydrate FatalError/RetryableError and Error subclasses in o11y The web o11y reviver set was missing entries for the recently-added serialization types (FatalError, RetryableError, the built-in Error subclasses, AggregateError, DOMException), causing devalue.unflatten to throw "Unknown type X" and the UI to surface "Failed to load resource details" whenever a step or run failed with one of these error types. Adds the missing revivers to getWebRevivers() and a regression test that round-trips real values through the runtime's dehydrateStepError back through the web reviver set. * fix(web-shared): address review feedback on error revivers - Pass `cause` through ErrorOptions to the subclass constructor instead of assigning afterwards, matching `getCommonRevivers` in core. This gives the resulting `cause` property the same engine-set, non-enumerable semantics as a freshly thrown Error in the consumer realm. - Guard `RetryableError.retryAfter` against missing/undefined values from older runtime payloads — without it, `new Date(undefined)` produces an Invalid Date rather than the property being absent. Add a defensive test that drives the reviver directly with a payload missing the field.
1 parent 1d4f83a commit c80b747

5 files changed

Lines changed: 264 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/web-shared": patch
3+
---
4+
5+
Fix "Unknown type FatalError" / "Failed to load resource details" in the o11y UI by adding the missing reviver entries (`FatalError`, `RetryableError`, the built-in `Error` subclasses, `AggregateError`, and `DOMException`) to `getWebRevivers()` so it stays in sync with the runtime reducer set.

packages/web-shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@types/node": "catalog:",
7272
"@types/react": "19",
7373
"@types/react-dom": "19",
74+
"@workflow/errors": "workspace:*",
7475
"@workflow/tsconfig": "workspace:*",
7576
"ai": "catalog:",
7677
"typescript": "catalog:",

packages/web-shared/src/lib/hydration.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,60 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer {
5858
// Web revivers (browser-safe, no Buffer dependency)
5959
// ---------------------------------------------------------------------------
6060

61+
/**
62+
* Build a reviver for one of the built-in `Error` subclasses (e.g.
63+
* `TypeError`, `RangeError`). The constructor for the named subclass is
64+
* resolved off `globalThis` at call time so the produced instance has the
65+
* correct prototype chain in the consumer realm. Falls back to a generic
66+
* `Error` (with `name` set) if the global isn't available, which keeps the
67+
* o11y UI rendering even on exotic browsers.
68+
*
69+
* `cause` is passed through `ErrorOptions` to the constructor when present,
70+
* matching `getCommonRevivers` in `@workflow/core` so the resulting `cause`
71+
* property has the same semantics (non-enumerable, set by the engine) as a
72+
* freshly thrown Error in the consumer realm. The `'cause' in value` check
73+
* preserves the distinction between "no cause" and "cause is undefined".
74+
*/
75+
function makeWebErrorSubclassReviver(
76+
name:
77+
| 'EvalError'
78+
| 'RangeError'
79+
| 'ReferenceError'
80+
| 'SyntaxError'
81+
| 'TypeError'
82+
| 'URIError'
83+
) {
84+
return (value: { message: string; stack?: string; cause?: unknown }) => {
85+
const opts = 'cause' in value ? { cause: value.cause } : undefined;
86+
const Ctor = (globalThis as Record<string, any>)[name] as
87+
| ErrorConstructor
88+
| undefined;
89+
let error: Error;
90+
if (typeof Ctor === 'function') {
91+
error = new Ctor(value.message, opts);
92+
} else {
93+
// Fallback path: no built-in subclass available (exotic env). Construct
94+
// a plain Error with the right `name` and copy `cause` manually since
95+
// the base Error constructor is what we actually called.
96+
error = Object.assign(new Error(value.message, opts), { name });
97+
}
98+
if (value.stack !== undefined) error.stack = value.stack;
99+
return error;
100+
};
101+
}
102+
61103
/**
62104
* Get the web-specific revivers for hydrating serialized data.
63105
*
64106
* Uses `atob()` for base64 decoding (no Node.js Buffer dependency).
65107
* All types are revived as real instances (Date, Map, Set, URL,
66108
* URLSearchParams, Headers, Error, etc.).
109+
*
110+
* NOTE: this set must mirror the keys in `SerializableSpecial` (see
111+
* `@workflow/core/serialization/types`). Any reducer key added on the
112+
* serialization side that isn't covered here will cause `devalue.unflatten`
113+
* to throw `Unknown type X`, which `hydrateResourceIO` swallows and
114+
* surfaces as a "Failed to load resource details" banner in the o11y UI.
67115
*/
68116
export function getWebRevivers(): Revivers {
69117
function reviveArrayBuffer(value: string): ArrayBuffer {
@@ -83,10 +131,93 @@ export function getWebRevivers(): Revivers {
83131
BigUint64Array: (value: string) =>
84132
new BigUint64Array(reviveArrayBuffer(value)),
85133
Date: (value) => new Date(value),
134+
135+
// Error family. The reducer side (see
136+
// `packages/core/src/serialization/reducers/common.ts`) emits a tagged
137+
// entry for each built-in Error subclass plus the workflow-specific
138+
// `FatalError` / `RetryableError` and `AggregateError`. Without
139+
// matching revivers here, `devalue.unflatten` throws "Unknown type X"
140+
// — which surfaces in the web o11y UI as "Failed to load resource
141+
// details: Unknown type FatalError".
86142
Error: (value) => {
143+
const opts = 'cause' in value ? { cause: value.cause } : undefined;
144+
const error = new Error(value.message, opts);
145+
error.name = value.name;
146+
if (value.stack !== undefined) error.stack = value.stack;
147+
return error;
148+
},
149+
EvalError: makeWebErrorSubclassReviver('EvalError'),
150+
RangeError: makeWebErrorSubclassReviver('RangeError'),
151+
ReferenceError: makeWebErrorSubclassReviver('ReferenceError'),
152+
SyntaxError: makeWebErrorSubclassReviver('SyntaxError'),
153+
TypeError: makeWebErrorSubclassReviver('TypeError'),
154+
URIError: makeWebErrorSubclassReviver('URIError'),
155+
AggregateError: (value) => {
156+
const opts = 'cause' in value ? { cause: value.cause } : undefined;
157+
const Ctor = (
158+
globalThis as { AggregateError?: AggregateErrorConstructor }
159+
).AggregateError;
160+
const error =
161+
typeof Ctor === 'function'
162+
? new Ctor(value.errors, value.message, opts)
163+
: Object.assign(new Error(value.message, opts), {
164+
name: 'AggregateError',
165+
errors: value.errors,
166+
});
167+
if (value.stack !== undefined) error.stack = value.stack;
168+
return error;
169+
},
170+
// `FatalError` and `RetryableError` are not built-in browser globals,
171+
// so we can't resolve a constructor from globalThis. The web o11y UI
172+
// doesn't need `instanceof FatalError` to pass (no user code runs
173+
// here) — it just needs `name`, `message`, `stack`, and any extra
174+
// enumerable fields to render. Construct a plain `Error` with `name`
175+
// set; ObjectInspector reads `constructor.name` for the displayed
176+
// class label, but we don't have the real class, so we emit a tagged
177+
// Error whose `name` field carries the class identity. This matches
178+
// how the existing base `Error` reviver presents unknown subclasses.
179+
FatalError: (value) => {
180+
const opts = 'cause' in value ? { cause: value.cause } : undefined;
181+
const error = new Error(value.message, opts);
182+
error.name = 'FatalError';
183+
if (value.stack !== undefined) error.stack = value.stack;
184+
return error;
185+
},
186+
RetryableError: (value) => {
187+
const opts = 'cause' in value ? { cause: value.cause } : undefined;
188+
const error = new Error(value.message, opts) as Error & {
189+
retryAfter?: Date;
190+
};
191+
error.name = 'RetryableError';
192+
if (value.stack !== undefined) error.stack = value.stack;
193+
// `retryAfter` is serialized as an epoch ms number (see the runtime
194+
// RetryableError reducer for the rationale around realm-safety).
195+
// Rehydrate as a Date so o11y consumers can render it directly.
196+
// Guard against payloads from older runtime versions that predate
197+
// the field — without this check, `new Date(undefined)` would
198+
// produce an Invalid Date rather than omitting the property.
199+
if (value.retryAfter != null) {
200+
error.retryAfter = new Date(value.retryAfter);
201+
}
202+
return error;
203+
},
204+
DOMException: (value) => {
205+
// Modern browsers and Node 18+ expose `DOMException` on globalThis.
206+
// `AbortController.abort()` with no argument synthesizes one as the
207+
// signal's reason, so this is a common payload for any aborted step.
208+
const G = globalThis as { DOMException?: typeof DOMException };
209+
if (typeof G.DOMException === 'function') {
210+
const e = new G.DOMException(value.message, value.name);
211+
if (value.stack !== undefined) e.stack = value.stack;
212+
if ('cause' in value) (e as { cause?: unknown }).cause = value.cause;
213+
return e;
214+
}
87215
const error = new Error(value.message);
88216
error.name = value.name;
89-
error.stack = value.stack;
217+
if (value.stack !== undefined) error.stack = value.stack;
218+
if ('cause' in value) {
219+
(error as Error & { cause?: unknown }).cause = value.cause;
220+
}
90221
return error;
91222
},
92223
Float32Array: (value: string) => new Float32Array(reviveArrayBuffer(value)),
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { dehydrateStepError } from '@workflow/core/serialization';
2+
import { hydrateData } from '@workflow/core/serialization-format';
3+
import { FatalError, RetryableError } from '@workflow/errors';
4+
import { describe, expect, it } from 'vitest';
5+
import { getWebRevivers } from '../src/lib/hydration.js';
6+
7+
/**
8+
* The web reviver set must mirror every key in `SerializableSpecial` (see
9+
* `packages/core/src/serialization/types.ts`). When the runtime reducer set
10+
* adds a new tagged type — historically `FatalError`, `RetryableError`,
11+
* the built-in `Error` subclasses, `DOMException`, etc. — the web set must
12+
* grow in lockstep. Otherwise `devalue.unflatten` throws `"Unknown type X"`,
13+
* which `hydrateResourceIO` swallows and surfaces as a "Failed to load
14+
* resource details" banner in the o11y UI.
15+
*
16+
* These tests round-trip real values through the runtime's
17+
* `dehydrateStepError` (the production code path that produces the wire
18+
* bytes for `step_failed.error` etc.) and then through the web reviver
19+
* set, so they catch divergence on either side.
20+
*/
21+
22+
const REVIVERS = getWebRevivers();
23+
24+
/** Run a real value through the production wire path with no encryption. */
25+
async function roundTrip<T>(value: unknown): Promise<T> {
26+
const wire = await dehydrateStepError(value, 'run_test', undefined);
27+
return hydrateData(wire, REVIVERS) as T;
28+
}
29+
30+
describe('getWebRevivers — error family', () => {
31+
it('hydrates a base Error', async () => {
32+
const original = new Error('boom', { cause: 'because' });
33+
const revived = await roundTrip<Error & { cause?: unknown }>(original);
34+
expect(revived).toBeInstanceOf(Error);
35+
expect(revived.name).toBe('Error');
36+
expect(revived.message).toBe('boom');
37+
expect(revived.cause).toBe('because');
38+
});
39+
40+
// biome-ignore format: visual alignment
41+
const subclasses = [
42+
['EvalError', EvalError],
43+
['RangeError', RangeError],
44+
['ReferenceError', ReferenceError],
45+
['SyntaxError', SyntaxError],
46+
['TypeError', TypeError],
47+
['URIError', URIError],
48+
] as const;
49+
50+
it.each(subclasses)('hydrates %s as a real instance', async (name, Ctor) => {
51+
const revived = await roundTrip<Error>(new Ctor('boom'));
52+
expect(revived).toBeInstanceOf(Ctor);
53+
expect(revived.name).toBe(name);
54+
expect(revived.message).toBe('boom');
55+
});
56+
57+
it('hydrates an AggregateError with its `errors` array intact', async () => {
58+
const original = new AggregateError(
59+
[new Error('a'), new Error('b')],
60+
'all failed'
61+
);
62+
const revived = await roundTrip<AggregateError>(original);
63+
expect(revived).toBeInstanceOf(AggregateError);
64+
expect(revived.message).toBe('all failed');
65+
expect(revived.errors).toHaveLength(2);
66+
expect((revived.errors[0] as Error).message).toBe('a');
67+
expect((revived.errors[1] as Error).message).toBe('b');
68+
});
69+
70+
it('hydrates a DOMException with name preserved', async () => {
71+
// `DOMException` is the value seen by web o11y when
72+
// `AbortController.abort()` is called with no argument and that
73+
// signal.reason crosses a step boundary — the same code path that
74+
// surfaced "Unknown type FatalError" before the symmetric web revivers
75+
// landed.
76+
const revived = await roundTrip<Error>(
77+
new DOMException('aborted', 'AbortError')
78+
);
79+
expect(revived.message).toBe('aborted');
80+
expect(revived.name).toBe('AbortError');
81+
});
82+
83+
it('hydrates a FatalError with name="FatalError"', async () => {
84+
// Regression test for the screenshot bug: "Unknown type FatalError"
85+
// surfaced from devalue.unflatten when the symmetric web reviver was
86+
// missing. Round-trips a real `FatalError` through the runtime wire
87+
// path so any divergence between the reducer and reviver is caught.
88+
const revived = await roundTrip<Error>(new FatalError('cannot retry'));
89+
expect(revived).toBeInstanceOf(Error);
90+
expect(revived.name).toBe('FatalError');
91+
expect(revived.message).toBe('cannot retry');
92+
});
93+
94+
it('hydrates a RetryableError with retryAfter as a Date', async () => {
95+
const retryAt = new Date('2025-01-01T00:00:00.000Z');
96+
const revived = await roundTrip<Error & { retryAfter: Date }>(
97+
new RetryableError('try again', { retryAfter: retryAt })
98+
);
99+
expect(revived.name).toBe('RetryableError');
100+
expect(revived.message).toBe('try again');
101+
// `retryAfter` is wire-encoded as an epoch ms number for realm-safety
102+
// (see the runtime RetryableError reducer); the web reviver must
103+
// rehydrate it back into a Date.
104+
expect(revived.retryAfter).toBeInstanceOf(Date);
105+
expect(revived.retryAfter.toISOString()).toBe(retryAt.toISOString());
106+
});
107+
108+
it('omits retryAfter when missing from the payload (older runtime)', () => {
109+
// Defensive path: a payload produced by an older runtime that predates
110+
// the `retryAfter` field would be missing it. The reviver must not
111+
// produce `new Date(undefined)` (an Invalid Date) — the field should
112+
// simply be absent from the resulting Error.
113+
const retryableReviver = (REVIVERS as Record<string, (v: any) => unknown>)
114+
.RetryableError;
115+
const revived = retryableReviver({
116+
message: 'try again',
117+
stack: 'RetryableError: try again',
118+
}) as Error & { retryAfter?: Date };
119+
expect(revived.name).toBe('RetryableError');
120+
expect(revived.message).toBe('try again');
121+
expect(revived.retryAfter).toBeUndefined();
122+
});
123+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)