From d5f79ed81644624dfd2e4b6cb60c0437e733eb3a Mon Sep 17 00:00:00 2001 From: Julien Lavocat Date: Fri, 23 Oct 2026 16:23:12 -0700 Subject: [PATCH] TS SDK - Fall back to closures when Function is forbidden The TypeScript SDK builds its serializers and deserializers with `Function`, which a Content-Security-Policy without 'unsafe-eval' and Cloudflare Workers forbid, so the first row decode threw an EvalError. - Probe once whether `Function` is allowed. If not, build the serializers and deserializers from closures that write the same bytes. - Load `undici` with a variable dynamic import instead of `new Function('m', 'return import(m)')`. Fixes #4966. --- .../src/lib/algebraic_type.ts | 111 ++++++++ .../src/lib/runtime_codegen.ts | 33 +++ crates/bindings-typescript/src/sdk/ws.ts | 11 +- .../tests/serde_without_function.test.ts | 268 ++++++++++++++++++ 4 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 crates/bindings-typescript/src/lib/runtime_codegen.ts create mode 100644 crates/bindings-typescript/tests/serde_without_function.test.ts diff --git a/crates/bindings-typescript/src/lib/algebraic_type.ts b/crates/bindings-typescript/src/lib/algebraic_type.ts index 2009dab02e5..69acdcce8b4 100644 --- a/crates/bindings-typescript/src/lib/algebraic_type.ts +++ b/crates/bindings-typescript/src/lib/algebraic_type.ts @@ -7,6 +7,7 @@ import BinaryWriter from './binary_writer'; import { Identity } from './identity'; import * as AlgebraicTypeVariants from './algebraic_type_variants'; import { hasOwn } from './util'; +import { canCompileFunctions } from './runtime_codegen'; type TypespaceType = { types: AlgebraicTypeType[]; @@ -448,6 +449,10 @@ export const ProductType = { let serializer = SERIALIZERS.get(ty); if (serializer != null) return serializer; + if (!canCompileFunctions()) { + return makeProductSerializerWithClosures(ty, typespace); + } + if (isFixedSizeProduct(ty)) { const size = productSize(ty); const body = `\ @@ -527,6 +532,10 @@ writer.offset += ${primitiveSizes[tag]};` let deserializer = DESERIALIZERS.get(ty); if (deserializer != null) return deserializer; + if (!canCompileFunctions()) { + return makeProductDeserializerWithClosures(ty, typespace); + } + if (isFixedSizeProduct(ty)) { const body = `\ "use strict"; @@ -678,6 +687,10 @@ export const SumType = { let serializer = SERIALIZERS.get(ty); if (serializer != null) return serializer; + if (!canCompileFunctions()) { + return makeSumSerializerWithClosures(ty, typespace); + } + const serializers: Record> = {}; const body = `\ @@ -782,6 +795,11 @@ ${ty.variants } else { let deserializer = DESERIALIZERS.get(ty); if (deserializer != null) return deserializer; + + if (!canCompileFunctions()) { + return makeSumDeserializerWithClosures(ty, typespace); + } + const deserializers: Record> = {}; deserializer = Function( 'reader', @@ -816,4 +834,97 @@ ${ty.variants }, }; +// Closure-based (de)serializers, used when `Function` is unavailable +// (see `canCompileFunctions`). They must produce the same bytes and values as +// the `Function`-based ones in `ProductType` and `SumType`. +// +// Like those, each one is cached *before* the (de)serializers of its fields or +// variants are built, so that a recursive type has an exit condition. + +function makeProductSerializerWithClosures( + ty: ProductTypeType, + typespace?: TypespaceType +): Serializer { + const names = ty.elements.map(({ name }) => name!); + const serializers: Serializer[] = []; + const serializer: Serializer = (writer, value) => { + for (let i = 0; i < names.length; i++) { + serializers[i](writer, value[names[i]]); + } + }; + SERIALIZERS.set(ty, serializer); + for (const { algebraicType } of ty.elements) { + serializers.push(AlgebraicType.makeSerializer(algebraicType, typespace)); + } + return serializer; +} + +function makeProductDeserializerWithClosures( + ty: ProductTypeType, + typespace?: TypespaceType +): Deserializer { + const names = ty.elements.map(({ name }) => name!); + const deserializers: Deserializer[] = []; + const deserializer: Deserializer = reader => { + // Fields are always added in the same order, so V8 gives every result the + // same hidden class, as it does for the object literal in the fast path. + const result: Record = {}; + for (let i = 0; i < names.length; i++) { + result[names[i]] = deserializers[i](reader); + } + return result; + }; + DESERIALIZERS.set(ty, deserializer); + for (const { algebraicType } of ty.elements) { + deserializers.push( + AlgebraicType.makeDeserializer(algebraicType, typespace) + ); + } + return deserializer; +} + +function makeSumSerializerWithClosures( + ty: SumTypeType, + typespace?: TypespaceType +): Serializer { + const tags = new Map(ty.variants.map(({ name }, i) => [name!, i])); + const serializers: Serializer[] = []; + const serializer: Serializer = (writer, value) => { + const tag = tags.get(value.tag); + if (tag === undefined) { + throw new TypeError( + `Could not serialize sum type; unknown tag ${value.tag}` + ); + } + writer.writeByte(tag); + serializers[tag](writer, value.value); + }; + SERIALIZERS.set(ty, serializer); + for (const { algebraicType } of ty.variants) { + serializers.push(AlgebraicType.makeSerializer(algebraicType, typespace)); + } + return serializer; +} + +function makeSumDeserializerWithClosures( + ty: SumTypeType, + typespace?: TypespaceType +): Deserializer { + const names = ty.variants.map(({ name }) => name!); + const deserializers: Deserializer[] = []; + const deserializer: Deserializer = reader => { + const tag = reader.readU8(); + // An unknown tag yields `undefined`, like the `switch` in the fast path. + if (tag >= names.length) return undefined; + return { tag: names[tag], value: deserializers[tag](reader) }; + }; + DESERIALIZERS.set(ty, deserializer); + for (const { algebraicType } of ty.variants) { + deserializers.push( + AlgebraicType.makeDeserializer(algebraicType, typespace) + ); + } + return deserializer; +} + export type ComparablePrimitive = number | string | boolean | bigint; diff --git a/crates/bindings-typescript/src/lib/runtime_codegen.ts b/crates/bindings-typescript/src/lib/runtime_codegen.ts new file mode 100644 index 00000000000..6cd9c9424e1 --- /dev/null +++ b/crates/bindings-typescript/src/lib/runtime_codegen.ts @@ -0,0 +1,33 @@ +// Whether this environment lets us compile code from strings with `Function`. +// +// Generating (de)serializers with `Function` is faster, but some environments +// forbid it: browsers with a Content-Security-Policy that lacks +// `'unsafe-eval'`, Cloudflare Workers, and Node with +// `--disallow-code-generation-from-strings`. In those we fall back to closures. +// +// We probe once and cache the answer, because under a CSP every failed +// attempt also fires a `securitypolicyviolation` report. +let allowed: boolean | undefined; + +export function canCompileFunctions(): boolean { + if (allowed === undefined) { + try { + Function(''); + allowed = true; + } catch { + allowed = false; + } + } + return allowed; +} + +/** + * Overrides the result of {@link canCompileFunctions}. Pass `undefined` to + * probe the environment again. For tests only. + * + * (De)serializers are cached per type, so this only affects types whose + * (de)serializers have not been built yet. + */ +export function setCanCompileFunctionsForTesting(value: boolean | undefined) { + allowed = value; +} diff --git a/crates/bindings-typescript/src/sdk/ws.ts b/crates/bindings-typescript/src/sdk/ws.ts index 96eb7e5fe51..ac8cb274e86 100644 --- a/crates/bindings-typescript/src/sdk/ws.ts +++ b/crates/bindings-typescript/src/sdk/ws.ts @@ -7,13 +7,14 @@ async function resolveWS(): Promise { } // Node without a global WebSocket: lazily load undici's polyfill. - // Use an unstatable dynamic import so bundlers don't prebundle it. - const dynamicImport = new Function('m', 'return import(m)') as ( - m: string - ) => Promise; + // Import from a variable, with the bundlers' ignore comments, so bundlers + // don't prebundle it. Not `new Function`, which a strict CSP forbids. + const undici = 'undici'; try { - const { WebSocket: UndiciWS } = await dynamicImport('undici'); + const { WebSocket: UndiciWS } = await import( + /* webpackIgnore: true */ /* @vite-ignore */ undici + ); return UndiciWS as unknown as typeof WebSocket; } catch (err) { stdbLogger( diff --git a/crates/bindings-typescript/tests/serde_without_function.test.ts b/crates/bindings-typescript/tests/serde_without_function.test.ts new file mode 100644 index 00000000000..f826ec10c3f --- /dev/null +++ b/crates/bindings-typescript/tests/serde_without_function.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + AlgebraicType, + type AlgebraicTypeType, +} from '../src/lib/algebraic_type'; +import BinaryReader from '../src/lib/binary_reader'; +import BinaryWriter from '../src/lib/binary_writer'; +import { + canCompileFunctions, + setCanCompileFunctionsForTesting, +} from '../src/lib/runtime_codegen'; + +// When `Function` is unavailable (a strict CSP, Cloudflare Workers), the +// (de)serializers are built from closures instead. These tests check that both +// paths agree byte for byte. +// +// (De)serializers are cached per type object, so each case builds a fresh type +// for each path. + +type Case = { + name: string; + type: () => { ty: AlgebraicTypeType; typespace?: { types: any[] } }; + values: any[]; +}; + +const unit = () => AlgebraicType.Product({ elements: [] }); +const option = (ty: AlgebraicTypeType) => + AlgebraicType.Sum({ + variants: [ + { name: 'some', algebraicType: ty }, + { name: 'none', algebraicType: unit() }, + ], + }); + +const cases: Case[] = [ + { + name: 'fixed-size product', + type: () => ({ + ty: AlgebraicType.Product({ + elements: [ + { name: 'b', algebraicType: AlgebraicType.Bool }, + { name: 'i8', algebraicType: AlgebraicType.I8 }, + { name: 'u8', algebraicType: AlgebraicType.U8 }, + { name: 'i16', algebraicType: AlgebraicType.I16 }, + { name: 'u16', algebraicType: AlgebraicType.U16 }, + { name: 'i32', algebraicType: AlgebraicType.I32 }, + { name: 'u32', algebraicType: AlgebraicType.U32 }, + { name: 'i64', algebraicType: AlgebraicType.I64 }, + { name: 'u64', algebraicType: AlgebraicType.U64 }, + { name: 'i128', algebraicType: AlgebraicType.I128 }, + { name: 'u128', algebraicType: AlgebraicType.U128 }, + { name: 'i256', algebraicType: AlgebraicType.I256 }, + { name: 'u256', algebraicType: AlgebraicType.U256 }, + { name: 'f32', algebraicType: AlgebraicType.F32 }, + { name: 'f64', algebraicType: AlgebraicType.F64 }, + ], + }), + }), + values: [ + { + b: true, + i8: -8, + u8: 8, + i16: -16, + u16: 16, + i32: -32, + u32: 32, + i64: -64n, + u64: 64n, + i128: -(1n << 100n), + u128: 1n << 100n, + i256: -(1n << 200n), + u256: 1n << 200n, + f32: 1.5, + f64: Math.PI, + }, + { + b: false, + i8: 0, + u8: 255, + i16: 0, + u16: 65535, + i32: 0, + u32: 4294967295, + i64: 0n, + u64: (1n << 64n) - 1n, + i128: 0n, + u128: 0n, + i256: 0n, + u256: (1n << 256n) - 1n, + f32: -0.25, + f64: -1e300, + }, + ], + }, + { + name: 'product with strings, arrays and nested products', + type: () => ({ + ty: AlgebraicType.Product({ + elements: [ + { name: 'name', algebraicType: AlgebraicType.String }, + { + name: 'tags', + algebraicType: AlgebraicType.Array(AlgebraicType.String), + }, + { + name: 'bytes', + algebraicType: AlgebraicType.Array(AlgebraicType.U8), + }, + { + name: 'pos', + algebraicType: AlgebraicType.Product({ + elements: [ + { name: 'x', algebraicType: AlgebraicType.F32 }, + { name: 'y', algebraicType: AlgebraicType.F32 }, + ], + }), + }, + { name: 'nick', algebraicType: option(AlgebraicType.String) }, + ], + }), + }), + values: [ + { + name: 'alice', + tags: ['a', 'b'], + bytes: new Uint8Array([1, 2, 3]), + pos: { x: 1.5, y: -2.5 }, + nick: 'al', + }, + { + name: '', + tags: [], + bytes: new Uint8Array([]), + pos: { x: 0, y: 0 }, + nick: undefined, + }, + ], + }, + { + name: 'sum', + type: () => ({ + ty: AlgebraicType.Sum({ + variants: [ + { name: 'Empty', algebraicType: unit() }, + { name: 'Count', algebraicType: AlgebraicType.U32 }, + { name: 'Label', algebraicType: AlgebraicType.String }, + ], + }), + }), + values: [ + { tag: 'Empty', value: {} }, + { tag: 'Count', value: 7 }, + { tag: 'Label', value: 'hi' }, + ], + }, + { + name: 'recursive product', + type: () => ({ + ty: AlgebraicType.Ref(0), + typespace: { + types: [ + AlgebraicType.Product({ + elements: [ + { name: 'value', algebraicType: AlgebraicType.I32 }, + { name: 'next', algebraicType: option(AlgebraicType.Ref(0)) }, + ], + }), + ], + }, + }), + values: [ + { value: 1, next: { value: 2, next: { value: 3, next: undefined } } }, + ], + }, + { + name: 'recursive sum', + type: () => ({ + ty: AlgebraicType.Ref(0), + typespace: { + types: [ + AlgebraicType.Sum({ + variants: [ + { name: 'Leaf', algebraicType: AlgebraicType.U32 }, + { + name: 'Node', + algebraicType: AlgebraicType.Product({ + elements: [ + { name: 'left', algebraicType: AlgebraicType.Ref(0) }, + { name: 'right', algebraicType: AlgebraicType.Ref(0) }, + ], + }), + }, + ], + }), + ], + }, + }), + values: [ + { + tag: 'Node', + value: { + left: { tag: 'Leaf', value: 1 }, + right: { + tag: 'Node', + value: { + left: { tag: 'Leaf', value: 2 }, + right: { tag: 'Leaf', value: 3 }, + }, + }, + }, + }, + ], + }, +]; + +function serializeAll(compile: boolean, c: Case) { + setCanCompileFunctionsForTesting(compile); + const { ty, typespace } = c.type(); + const serialize = AlgebraicType.makeSerializer(ty, typespace); + const deserialize = AlgebraicType.makeDeserializer(ty, typespace); + return c.values.map(value => { + const writer = new BinaryWriter(8); + serialize(writer, value); + const bytes = writer.getBuffer().slice(); + return { bytes, roundTripped: deserialize(new BinaryReader(bytes)) }; + }); +} + +describe('serialization without Function', () => { + afterEach(() => { + vi.unstubAllGlobals(); + setCanCompileFunctionsForTesting(undefined); + }); + + test('detects when Function is forbidden', () => { + vi.stubGlobal('Function', () => { + throw new EvalError('Code generation from strings disallowed'); + }); + setCanCompileFunctionsForTesting(undefined); + expect(canCompileFunctions()).toBe(false); + }); + + test.each(cases)('$name matches the Function path', c => { + const compiled = serializeAll(true, c); + + // Forbid `Function` as a strict CSP would. + vi.stubGlobal('Function', () => { + throw new EvalError('Code generation from strings disallowed'); + }); + const closures = serializeAll(false, c); + + for (let i = 0; i < c.values.length; i++) { + expect(closures[i].bytes).toEqual(compiled[i].bytes); + expect(closures[i].roundTripped).toEqual(c.values[i]); + expect(compiled[i].roundTripped).toEqual(c.values[i]); + } + }); + + test('throws on an unknown sum tag', () => { + setCanCompileFunctionsForTesting(false); + const { ty } = cases.find(c => c.name === 'sum')!.type(); + const serialize = AlgebraicType.makeSerializer(ty); + expect(() => + serialize(new BinaryWriter(8), { tag: 'Nope', value: 0 }) + ).toThrow('Could not serialize sum type; unknown tag Nope'); + }); +});