Supertalk is a type-safe, unified client/server communication library for:
- Web Workers
- Iframes
- Node.js worker threads
- Browser-to-server RPC (HTTP/WebSocket)
It aims to replace Comlink (which lacks composability and rich typing) and potentially tRPC (which has verbose builder APIs and requires type imports from server).
- Type-safe — Clients get typed interfaces; servers get guidance on producing serializable types
- Simple DX — Basic request/response should be trivial
- Decoupled types — Define service interface separately from implementation; no mandatory server imports
- Declarative — Prefer decorated classes over builder patterns
- Rich serialization — Structured clone + transferables for postMessage; JSON + pluggable serializers (superjson) for HTTP
- Async-first — Support promises, streams, async iterables as first-class citizens
- Proxy support — Easy proxying for functions, expensive objects, graph nodes
- Signals integration — TC39 Signals for reactive state sync across boundaries
- Memory safe — WeakRef/FinalizationRegistry for automatic proxy cleanup
- Composable — Nested objects, sub-services, no special cases for top-level vs nested
- ESM-only — No CommonJS
- ESNext target — Use latest JS features
- Chrome-only initially — Can use bleeding-edge APIs
- Node 20+ — Minimum supported version
- Standard decorators (Stage 3) — No
experimentalDecorators - Strict mode — All strict flags enabled
verbatimModuleSyntax— Explicittypeimports- Private fields — Use
#fieldsyntax, notprivate
WeakRefandFinalizationRegistryfor memory managementusingdeclarations for resource management where appropriate- Private class fields (
#field) - Standard decorators
AbortSignalfor cancellationReadableStream/WritableStreamfor streaming
- Node tests:
node:testwithnode --test - Browser tests:
@web/test-runnerwith Playwright - Tests run against compiled JS — Wireit ensures build before test
- Test API: Use
suite()andtest(), notdescribe()andit() - Test files:
test/node/*_test.js,test/browser/*_test.js
Keep the library small! Run the size check during development:
WIREIT_LOGGER=simple npm run checksizeThis uses Rollup + Terser + rollup-plugin-summary to show:
- Size: Unminified bundle
- Minified: After Terser (private fields are auto-mangled)
- Gzipped: Compressed size
- Brotli: Best-case compressed size
Current target: ~2 kB brotli-compressed.
- All source under
src/— Including tests:src/test/node/,src/test/browser/ - Build output to root —
src/index.ts→index.js,src/lib/→lib/,src/test/→test/ - Test file naming — End with
_test.ts(not.test.ts) to distinguish from test utilities - Gitignored outputs —
index.*,lib/,test/at package root are gitignored
-
Prettier with project config
-
ESLint with strict TypeScript rules
-
Consistent type imports:
import type { Foo } from './foo.js' -
Prefer type inference — Don't annotate field or variable types when they can be inferred. Use generics at the constructor/function call instead:
// ✅ Good: type inferred from constructor #docs = new Map<string, unknown>(); // ❌ Avoid: redundant type annotation #docs: Map<string, unknown> = new Map();
- Ask clarifying questions if requirements are ambiguous
- Check existing documentation in
docs/for context - Review this file for design decisions and tips
- Document first — Update relevant docs before or alongside code changes
- Work incrementally — Small, testable chunks
- Check in frequently — Pause after significant progress to confirm direction
- Write tests — Prefer test-first for complex logic
Keep AGENTS.md as a living document — a current snapshot of key instructions, not a changelog.
When you discover something worth recording (coding patterns, implementation details, debugging tips):
- Add it to the appropriate section
- Organize as if writing documentation, not a log entry
- No dates or "discovered on..." framing
- Consolidate related information; avoid duplication
This library is not yet released. We prioritize clean APIs and minimal code size over backwards compatibility. Feel free to:
- Remove deprecated methods
- Rename or refactor freely
- Consolidate redundant APIs
- Break changes without migration paths
Unlike Comlink's global transferHandlers map, Supertalk has no global state. All configuration is scoped to individual connections via options to expose() and wrap().
What gets auto-proxied (always):
- Functions (unambiguously non-cloneable)
- Promises (also non-cloneable)
- The root service via
expose()
When to use explicit proxy():
- Class instances where you need methods (prototypes are skipped during cloning)
- Mutable objects where the remote side should see updates
- Large objects to avoid cloning overhead
When to use handle():
- Opaque tokens or session identifiers
- References where you don't want to expose the object's interface
- Graph nodes that should only be accessed on the owning side
Types are consistent on both sides:
interface MyService {
createWidget(): AsyncProxy<Widget>; // Same type on both sides
createSession(): Handle<Session>; // Same type on both sides
getData(): {value: number}; // Cloned, same shape
}Use getProxyValue() and getHandleValue() on the owning side to extract the
underlying value. These throw on the remote side.
Proxies and handles stay as proxies/handles when sent back across the boundary. This enables consistent bidirectional APIs:
// Service accepts the same types it returns
interface MyService {
createWidget(): AsyncProxy<Widget>;
updateWidget(widget: AsyncProxy<Widget>): void;
}-
Shallow mode (default,
nestedProxies: false): Only top-level function arguments are proxied. No traversal. Maximum performance. Nested functions/promises fail with DataCloneError. -
Debug mode (
debug: true): Traverses payloads to detect non-cloneable values and throwsNonCloneableErrorwith the exact path. Detects nested functions, promises,proxy()markers, andtransfer()markers that would fail withoutnestedProxies: true. -
Nested mode (
nestedProxies: true): Full payload traversal. Functions and promises are auto-proxied anywhere. Class instances require explicitproxy()markers.
A "service" is not a special concept — it's just an object that gets proxied. The same proxy mechanism works for services and any other proxied object:
- Methods are non-serializable function properties that get proxied
- Serializable properties get cloned/sent
- No special cases for "top-level" vs nested objects
Method enumeration:
- For plain objects: own enumerable properties
- For class instances: walk prototype chain up to (but not including) Object.prototype
CRITICAL: Always run commands from the monorepo root (/Users/justin/Projects/Web/supertalk), never cd into package directories.
npm run test # All tests
npm run test:node # Node tests only
npm run lint # Lint
npm run -w @supertalk/core <script> # Run in specific workspaceWireit handles dependencies automatically — don't run build separately before tests.
Debugging tip: If a script isn't running when expected, check that all input files are listed in the files array in the wireit config. Never manually clear the Wireit cache.
supertalk/
├── packages/
│ ├── core/ # @supertalk/core - main implementation
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ ├── lib/ # Library source
│ │ │ └── test/ # Test source
│ │ │ ├── node/
│ │ │ └── browser/
│ │ ├── index.js # Built (gitignored)
│ │ ├── lib/ # Built (gitignored)
│ │ ├── test/ # Built (gitignored)
│ │ └── package.json
│ └── supertalk/ # supertalk - re-exports @supertalk/core
├── docs/
│ ├── GOALS.md # Detailed requirements
│ ├── ARCHITECTURE.md # System design
│ ├── API-DESIGN.md # DX exploration
│ └── ROADMAP.md # Implementation phases
├── AGENTS.md # This file
└── package.json # Workspace root
| File | Purpose |
|---|---|
docs/GOALS.md |
Full requirements and non-goals |
docs/API-DESIGN.md |
API exploration and decisions |
docs/ARCHITECTURE.md |
Internal system design |
docs/ROADMAP.md |
Implementation phases and status |