Skip to content

Commit 6f2cbcd

Browse files
authored
fix: prioritize custom class serialization over generic Error serialization (#1172)
Move the Instance and Class reducers before the Error reducer in getCommonReducers() so that Error subclasses implementing WORKFLOW_SERIALIZE are serialized using custom class serialization instead of the generic Error reducer. devalue uses first-match-wins, so the previous ordering caused custom Error subclasses to lose their custom fields (only name/message/stack were preserved).
1 parent 028a828 commit 6f2cbcd

File tree

3 files changed

+321
-32
lines changed

3 files changed

+321
-32
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Fix custom Error subclass serialization precedence: move Instance reducer before Error reducer so that Error subclasses with WORKFLOW_SERIALIZE are serialized using custom class serialization instead of the generic Error serialization

packages/core/src/serialization.test.ts

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2872,6 +2872,287 @@ describe('custom class serialization', () => {
28722872
});
28732873
});
28742874

2875+
describe('custom Error subclass serialization', () => {
2876+
const { context, globalThis: vmGlobalThis } = createContext({
2877+
seed: 'test-error-serde',
2878+
fixedTimestamp: 1714857600000,
2879+
});
2880+
2881+
// Make the serialization symbols available inside the VM
2882+
(vmGlobalThis as any).WORKFLOW_SERIALIZE = WORKFLOW_SERIALIZE;
2883+
(vmGlobalThis as any).WORKFLOW_DESERIALIZE = WORKFLOW_DESERIALIZE;
2884+
2885+
// Define registerSerializationClass inside the VM so that it uses the VM's globalThis.
2886+
runInContext(
2887+
`
2888+
const WORKFLOW_CLASS_REGISTRY = Symbol.for('workflow-class-registry');
2889+
function registerSerializationClass(classId, cls) {
2890+
let registry = globalThis[WORKFLOW_CLASS_REGISTRY];
2891+
if (!registry) {
2892+
registry = new Map();
2893+
globalThis[WORKFLOW_CLASS_REGISTRY] = registry;
2894+
}
2895+
registry.set(classId, cls);
2896+
Object.defineProperty(cls, 'classId', {
2897+
value: classId,
2898+
writable: false,
2899+
enumerable: false,
2900+
configurable: false,
2901+
});
2902+
}
2903+
globalThis.registerSerializationClass = registerSerializationClass;
2904+
`,
2905+
context
2906+
);
2907+
2908+
it('should use custom serialization for Error subclass with WORKFLOW_SERIALIZE instead of generic Error serialization', async () => {
2909+
// Define an Error subclass with custom serialization that preserves extra fields
2910+
class AppError extends Error {
2911+
constructor(
2912+
message: string,
2913+
public code: number,
2914+
public details: string
2915+
) {
2916+
super(message);
2917+
this.name = 'AppError';
2918+
}
2919+
2920+
static [WORKFLOW_SERIALIZE](instance: AppError) {
2921+
return {
2922+
message: instance.message,
2923+
code: instance.code,
2924+
details: instance.details,
2925+
};
2926+
}
2927+
2928+
static [WORKFLOW_DESERIALIZE](data: {
2929+
message: string;
2930+
code: number;
2931+
details: string;
2932+
}) {
2933+
return new AppError(data.message, data.code, data.details);
2934+
}
2935+
}
2936+
2937+
// The classId is normally generated by the SWC compiler
2938+
(AppError as any).classId = 'test/AppError';
2939+
2940+
// Register the class on the host for serialization
2941+
registerSerializationClass('test/AppError', AppError);
2942+
2943+
// Define and register the class inside the VM
2944+
runInContext(
2945+
`
2946+
class AppError extends Error {
2947+
constructor(message, code, details) {
2948+
super(message);
2949+
this.name = 'AppError';
2950+
this.code = code;
2951+
this.details = details;
2952+
}
2953+
static [WORKFLOW_SERIALIZE](instance) {
2954+
return { message: instance.message, code: instance.code, details: instance.details };
2955+
}
2956+
static [WORKFLOW_DESERIALIZE](data) {
2957+
return new AppError(data.message, data.code, data.details);
2958+
}
2959+
}
2960+
AppError.classId = 'test/AppError';
2961+
registerSerializationClass('test/AppError', AppError);
2962+
`,
2963+
context
2964+
);
2965+
2966+
const error = new AppError('not found', 404, 'Resource does not exist');
2967+
2968+
// Serialize using workflow arguments (which uses getCommonReducers)
2969+
const serialized = await dehydrateWorkflowArguments(
2970+
error,
2971+
mockRunId,
2972+
noEncryptionKey,
2973+
[]
2974+
);
2975+
2976+
// Verify the serialized data uses Instance (custom class), NOT Error
2977+
const serializedStr = new TextDecoder().decode(serialized);
2978+
expect(serializedStr).toContain('test/AppError');
2979+
expect(serializedStr).toContain('Instance');
2980+
expect(serializedStr).not.toMatch(/"Error"/);
2981+
2982+
// Hydrate it back
2983+
const hydrated = await hydrateWorkflowArguments(
2984+
serialized,
2985+
mockRunId,
2986+
noEncryptionKey,
2987+
vmGlobalThis
2988+
);
2989+
2990+
// The hydrated object should be an AppError with custom fields preserved
2991+
expect(hydrated.constructor.name).toBe('AppError');
2992+
expect(hydrated.message).toBe('not found');
2993+
expect(hydrated.code).toBe(404);
2994+
expect(hydrated.details).toBe('Resource does not exist');
2995+
});
2996+
2997+
it('should still serialize plain Error instances using the generic Error reducer', async () => {
2998+
const error = new Error('plain error');
2999+
3000+
const serialized = await dehydrateWorkflowArguments(
3001+
error,
3002+
mockRunId,
3003+
noEncryptionKey,
3004+
[]
3005+
);
3006+
3007+
// Verify it uses the Error reducer, not Instance
3008+
const serializedStr = new TextDecoder().decode(serialized);
3009+
expect(serializedStr).toContain('Error');
3010+
expect(serializedStr).not.toContain('Instance');
3011+
3012+
// Hydrate it back
3013+
const hydrated = await hydrateWorkflowArguments(
3014+
serialized,
3015+
mockRunId,
3016+
noEncryptionKey,
3017+
globalThis
3018+
);
3019+
3020+
expect(hydrated).toBeInstanceOf(Error);
3021+
expect(hydrated.message).toBe('plain error');
3022+
});
3023+
3024+
it('should serialize Error subclass WITHOUT WORKFLOW_SERIALIZE using generic Error reducer', async () => {
3025+
// An Error subclass that does NOT implement custom serialization
3026+
class SimpleError extends Error {
3027+
constructor(message: string) {
3028+
super(message);
3029+
this.name = 'SimpleError';
3030+
}
3031+
}
3032+
3033+
const error = new SimpleError('simple error');
3034+
3035+
const serialized = await dehydrateWorkflowArguments(
3036+
error,
3037+
mockRunId,
3038+
noEncryptionKey,
3039+
[]
3040+
);
3041+
3042+
// Should use generic Error serialization since no WORKFLOW_SERIALIZE
3043+
const serializedStr = new TextDecoder().decode(serialized);
3044+
expect(serializedStr).toContain('Error');
3045+
expect(serializedStr).not.toContain('Instance');
3046+
3047+
const hydrated = await hydrateWorkflowArguments(
3048+
serialized,
3049+
mockRunId,
3050+
noEncryptionKey,
3051+
globalThis
3052+
);
3053+
3054+
expect(hydrated).toBeInstanceOf(Error);
3055+
expect(hydrated.name).toBe('SimpleError');
3056+
expect(hydrated.message).toBe('simple error');
3057+
});
3058+
3059+
it('should use custom serialization for Error subclass in step arguments', async () => {
3060+
class StepError extends Error {
3061+
constructor(
3062+
message: string,
3063+
public statusCode: number
3064+
) {
3065+
super(message);
3066+
this.name = 'StepError';
3067+
}
3068+
3069+
static [WORKFLOW_SERIALIZE](instance: StepError) {
3070+
return { message: instance.message, statusCode: instance.statusCode };
3071+
}
3072+
3073+
static [WORKFLOW_DESERIALIZE](data: {
3074+
message: string;
3075+
statusCode: number;
3076+
}) {
3077+
return new StepError(data.message, data.statusCode);
3078+
}
3079+
}
3080+
3081+
(StepError as any).classId = 'test/StepError';
3082+
registerSerializationClass('test/StepError', StepError);
3083+
3084+
const error = new StepError('bad request', 400);
3085+
3086+
const serialized = await dehydrateStepArguments(
3087+
[error],
3088+
mockRunId,
3089+
noEncryptionKey,
3090+
globalThis
3091+
);
3092+
3093+
const ops: Promise<void>[] = [];
3094+
const hydrated = await hydrateStepArguments(
3095+
serialized,
3096+
mockRunId,
3097+
noEncryptionKey,
3098+
ops,
3099+
globalThis
3100+
);
3101+
3102+
expect(Array.isArray(hydrated)).toBe(true);
3103+
expect(hydrated[0]).toBeInstanceOf(StepError);
3104+
expect(hydrated[0].message).toBe('bad request');
3105+
expect((hydrated[0] as StepError).statusCode).toBe(400);
3106+
});
3107+
3108+
it('should use custom serialization for Error subclass in step return values', async () => {
3109+
class ReturnError extends Error {
3110+
constructor(
3111+
message: string,
3112+
public errorCode: string
3113+
) {
3114+
super(message);
3115+
this.name = 'ReturnError';
3116+
}
3117+
3118+
static [WORKFLOW_SERIALIZE](instance: ReturnError) {
3119+
return { message: instance.message, errorCode: instance.errorCode };
3120+
}
3121+
3122+
static [WORKFLOW_DESERIALIZE](data: {
3123+
message: string;
3124+
errorCode: string;
3125+
}) {
3126+
return new ReturnError(data.message, data.errorCode);
3127+
}
3128+
}
3129+
3130+
(ReturnError as any).classId = 'test/ReturnError';
3131+
registerSerializationClass('test/ReturnError', ReturnError);
3132+
3133+
const error = new ReturnError('timeout', 'ERR_TIMEOUT');
3134+
3135+
const serialized = await dehydrateStepReturnValue(
3136+
error,
3137+
mockRunId,
3138+
noEncryptionKey,
3139+
[]
3140+
);
3141+
3142+
// Step return values are hydrated with workflow revivers
3143+
const hydrated = await hydrateWorkflowArguments(
3144+
serialized,
3145+
mockRunId,
3146+
noEncryptionKey,
3147+
globalThis
3148+
);
3149+
3150+
expect(hydrated).toBeInstanceOf(ReturnError);
3151+
expect(hydrated.message).toBe('timeout');
3152+
expect(hydrated.errorCode).toBe('ERR_TIMEOUT');
3153+
});
3154+
});
3155+
28753156
describe('format prefix system', () => {
28763157
const { globalThis: vmGlobalThis } = createContext({
28773158
seed: 'test',

packages/core/src/serialization.ts

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,41 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
547547
value instanceof global.BigInt64Array && viewToBase64(value),
548548
BigUint64Array: (value) =>
549549
value instanceof global.BigUint64Array && viewToBase64(value),
550+
// Class and Instance are intentionally placed before Error so that
551+
// custom Error subclasses with WORKFLOW_SERIALIZE take precedence
552+
// over the generic Error serialization (devalue uses first-match-wins).
553+
Class: (value) => {
554+
// Check if this is a class constructor with a classId property
555+
// (set by the SWC plugin for classes with static step/workflow methods)
556+
if (typeof value !== 'function') return false;
557+
const classId = (value as any).classId;
558+
if (typeof classId !== 'string') return false;
559+
return { classId };
560+
},
561+
Instance: (value) => {
562+
// Check if this is an instance of a class with custom serialization
563+
if (value === null || typeof value !== 'object') return false;
564+
const cls = value.constructor;
565+
if (!cls || typeof cls !== 'function') return false;
566+
567+
// Check if the class has a static WORKFLOW_SERIALIZE method
568+
const serialize = cls[WORKFLOW_SERIALIZE];
569+
if (typeof serialize !== 'function') {
570+
return false;
571+
}
572+
573+
// Get the classId from the static class property (set by SWC plugin)
574+
const classId = cls.classId;
575+
if (typeof classId !== 'string') {
576+
throw new Error(
577+
`Class "${cls.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.`
578+
);
579+
}
580+
581+
// Serialize the instance using the custom serializer
582+
const data = serialize.call(cls, value);
583+
return { classId, data };
584+
},
550585
Date: (value) => {
551586
if (!(value instanceof global.Date)) return false;
552587
const valid = !Number.isNaN(value.getDate());
@@ -611,38 +646,6 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
611646
redirected: value.redirected,
612647
};
613648
},
614-
Class: (value) => {
615-
// Check if this is a class constructor with a classId property
616-
// (set by the SWC plugin for classes with static step/workflow methods)
617-
if (typeof value !== 'function') return false;
618-
const classId = (value as any).classId;
619-
if (typeof classId !== 'string') return false;
620-
return { classId };
621-
},
622-
Instance: (value) => {
623-
// Check if this is an instance of a class with custom serialization
624-
if (value === null || typeof value !== 'object') return false;
625-
const cls = value.constructor;
626-
if (!cls || typeof cls !== 'function') return false;
627-
628-
// Check if the class has a static WORKFLOW_SERIALIZE method
629-
const serialize = cls[WORKFLOW_SERIALIZE];
630-
if (typeof serialize !== 'function') {
631-
return false;
632-
}
633-
634-
// Get the classId from the static class property (set by SWC plugin)
635-
const classId = cls.classId;
636-
if (typeof classId !== 'string') {
637-
throw new Error(
638-
`Class "${cls.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.`
639-
);
640-
}
641-
642-
// Serialize the instance using the custom serializer
643-
const data = serialize.call(cls, value);
644-
return { classId, data };
645-
},
646649
Set: (value) => value instanceof global.Set && Array.from(value),
647650
StepFunction: (value) => {
648651
if (typeof value !== 'function') return false;

0 commit comments

Comments
 (0)