Skip to content

fix: remove unsafe-eval from typescript sdk#5003

Open
ferntheplant wants to merge 1 commit into
clockworklabs:masterfrom
ferntheplant:ferntheplant/remove-unsafe-eval-ts-sdk
Open

fix: remove unsafe-eval from typescript sdk#5003
ferntheplant wants to merge 1 commit into
clockworklabs:masterfrom
ferntheplant:ferntheplant/remove-unsafe-eval-ts-sdk

Conversation

@ferntheplant
Copy link
Copy Markdown

@ferntheplant ferntheplant commented May 12, 2026

disclaimer: this is vibe-slop. I only manually reviewed the typescript code which seems acceptable - unsure about the rust side. Still working on verifying by running this build against my actual private project but it works on the test-app

Description of Changes

Removes every use of new Function(...) from the TypeScript SDK in favor of static, statically-analyzable code.

crates/bindings-typescript/src/lib/algebraic_type.ts previously dynamically synthesized serializer/deserializer functions at runtime using new Function('writer', 'value', '...'). This was both a CSP / security hazard and a maintenance burden (the inlined source had to handle every primitive and special case by string concatenation). It also meant downstream consumers couldn't statically inspect or tree-shake what their generated types serialize to.

This PR replaces that with two complementary mechanisms:

  1. Runtime closure-based tree walker. ProductType.makeSerializer / makeDeserializer and SumType.makeSerializer / makeDeserializer are rewritten to use closures that cache themselves before populating their child (de)serializers — so recursive types still terminate, and the SDK still works for any AlgebraicType the user constructs at runtime (e.g. via t.object(...), internal protocol types, the reducer-args path).
  2. Statically-emitted serializers via codegen. A new TypeBuilder.withSerde(serialize, deserialize) helper installs precomputed serialize/deserialize functions on a builder and registers them in the global SERIALIZERS / DESERIALIZERS caches (keyed by the underlying ProductType / SumType). The TypeScript code generator in crates/codegen/src/typescript.rs now emits a .withSerde((writer, value) => { … }, reader => { … }) chained onto every generated __t.object(...), __t.row(...), and __t.enum(...). New helper functions (write_serialize_value, write_deserialize_into, write_product_with_serde, write_sum_with_serde) emit inline JS for every AlgebraicTypeUse variant, including the special wrapper types (Identity, ConnectionId, Timestamp, TimeDuration, Uuid) and ScheduleAt.

Both paths share the runtime caches, so any caller of ProductType.makeDeserializer(table.rowType) (e.g. the SDK row pipeline in db_connection_impl.ts) automatically picks up the static serializer for codegen-known types and falls back to the closure walker for everything else.

The last leftover new Function('m', 'return import(m)') shim in ws.ts (used to hide the dynamic import('undici') from bundlers) is replaced with a plain await import(...) that uses a variable specifier plus /* webpackIgnore: true */ and /* @vite-ignore */ magic comments — same "don't prebundle undici" behavior, no Function.

Other notable touches:

  • crates/cli/src/subcommands/generate.rs: bug fix — the CLI's --module-def flag was previously unusable because prepare_generate_run_configs always required a module source path even when the schema was supplied as JSON. Now the module-source check is skipped when --module-def is set. This unblocked re-running cargo run -p generate-client-api.
  • crates/bindings-typescript/src/lib/type_builders.ts re-exports ConnectionId, Identity, TimeDuration, Timestamp, Uuid so generated code (import { … as __Identity, … } from "../../lib/type_builders") resolves the special-type wrappers.
  • Regenerated files: crates/bindings-typescript/src/lib/autogen/types.ts, crates/bindings-typescript/src/sdk/client_api/{index,types}.ts, crates/bindings-typescript/src/sdk/client_api/types/{reducers,procedures}.ts, and the typescript codegen snapshot.

Out of scope for this PR: reducer-args / procedure-params / procedure-return still use the runtime closure walker. Those are built at runtime in reducerSchema() from a brand-new ProductType that has no static identity codegen can register against, so making them static would require API shape changes. They no longer use Function, just the new closure walker, so they're already safe — just not optimized.

API and ABI breaking changes

None.

  • TypeBuilder.withSerde(...) is a new, additive public method.
  • The codegen output gains a .withSerde(...) call chained onto existing __t.object(...) / __t.row(...) / __t.enum(...) expressions — a strict superset of the previous output; nothing was renamed or removed.
  • The wire format is unchanged (the static and dynamic paths emit byte-for-byte identical output).
  • The CLI generate --module-def fix only relaxes a previously over-broad validation; existing flag combinations continue to work.

Expected complexity level and risk

3 / 5.

The diff is large (mostly mechanical: every codegen-emitted type gains a withSerde block, and the regenerated autogen/types.ts + sdk/client_api/types.ts make up the bulk of the line count). The interesting surface is small but subtle:

  • The runtime closures must cache themselves before populating their child (de)serializers, otherwise recursive types like AlgebraicType (sum of Sum/Product/Array/...) would stack-overflow. The new code does this carefully and is exercised by the protocol's own recursive AlgebraicType in autogen/types.ts.
  • The codegen emits inline JS that must match the runtime walker byte-for-byte. Special cases that needed care: Identity / ConnectionId / Timestamp / TimeDuration / Uuid (1-field product types whose JS representation is a class instance, not an object literal); ScheduleAt (2-variant sum with bespoke wrapping); Option (uses tag byte but no tag field in JS); Result (wire is {ok}|{err} but TS inference is Ok | Err — handled by typing the generated (writer, value: any) to bypass the mismatch).
  • Sum-type deserialization preserves the prior fall-through-to-undefined behavior on out-of-range tags, since some callers may rely on it.
  • The CLI change has full coverage in the existing prepare_generate_run_configs test suite (21 tests, all passing).

If something regresses, it's most likely a wire-format mismatch on one of the special types — but the snapshot test plus the bindings-typescript test suite (which round-trips real protocol frames against the generated client_api) make that highly visible.

Testing

  • cargo test -p spacetimedb-codegen --test codegen test_codegen_typescript — snapshot accepted.
  • cargo test -p spacetimedb-cli --lib generate::21 / 21 passed. Confirms the --module-def validation fix didn't regress anything.
  • pnpm --filter spacetimedb run build:types (i.e. tsc -p tsconfig.build.json) — passes. Confirms the regenerated autogen/types.ts and sdk/client_api/types.ts (with their new .withSerde(...) blocks) typecheck under strict mode.
  • pnpm --filter spacetimedb run test182 / 182 passed (20 files). Includes tests/serde.test.ts, tests/db_connection.test.ts (round-trips reducer + table updates through the real WebSocket-frame parser, so it exercises both the static codegen path on client_api/types.ts and the runtime closure walker for reducer args), tests/algebraic_type.test.ts, and the binary reader/writer tests.
  • pnpm --filter spacetimedb exec tsup — all 18 build entrypoints build cleanly.
  • Manually inspected the built artifacts:
    • dist/sdk/index.mjs preserves the dynamic import(undiciSpecifier) with both magic comments intact, so downstream bundlers also leave undici alone.
    • dist/min/index.browser.mjs contains zero references to undici (tree-shaken away on the browser build target).
  • rg 'new Function\(|Function\([\x27"]' crates/bindings-typescript/src sdks/typescript/srcno matches. Function(...) is fully gone from both SDK source trees.
  • Reviewer: spot-check crates/bindings-typescript/src/sdk/client_api/types.ts — especially OneOffQueryResult (Result-typed) and TableUpdateRows (sum with recursive refs) — to confirm the generated .withSerde(...) blocks are readable and look correct.
  • Reviewer: confirm you're comfortable with (writer, value: any) / (reader): any parameter types in the generated withSerde callbacks. The escape hatch is intentional: the wire shape (e.g. {ok}|{err} for Result) doesn't always coincide with the inferred TS shape, and the generated code is opaque to consumers anyway.
  • Reviewer: try a fresh pnpm generate cycle on one of the example modules (e.g. pnpm generate:test-app) and confirm the output diff matches the codegen snapshot.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 12, 2026

CLA assistant check
All committers have signed the CLA.

@ferntheplant ferntheplant marked this pull request as ready for review May 12, 2026 22:29
@ferntheplant ferntheplant changed the title fix: remove unsafe-eval form typescript sdk fix: remove unsafe-eval from typescript sdk May 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants