From 510ab04e3bff885f623ea002622d9400f340c9f9 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:54:45 -0700 Subject: [PATCH 1/8] test(binding): stage explicit client binding prototype --- src/core/client-binding-prototype.ts | 351 ++++++++++++++ test/client-binding-prototype.test.ts | 444 ++++++++++++++++++ .../python/binding_a/binding_fixture.py | 62 +++ .../python/binding_b/binding_fixture.py | 62 +++ 4 files changed, 919 insertions(+) create mode 100644 src/core/client-binding-prototype.ts create mode 100644 test/client-binding-prototype.test.ts create mode 100644 test/fixtures/python/binding_a/binding_fixture.py create mode 100644 test/fixtures/python/binding_b/binding_fixture.py diff --git a/src/core/client-binding-prototype.ts b/src/core/client-binding-prototype.ts new file mode 100644 index 00000000..d9266b8d --- /dev/null +++ b/src/core/client-binding-prototype.ts @@ -0,0 +1,351 @@ +/** Opt-in generated client binding for the issue #340 prototype. */ + +import ts from 'typescript'; + +import type { GeneratedCode } from '../types/index.js'; + +export interface BindingPrototypeCode extends GeneratedCode { + bindingPrototype: { + core: Pick; + client: Pick; + }; +} + +interface ClassExport { + name: string; + methods: string[]; +} + +interface ExportedCalls { + functions: string[]; + classes: ClassExport[]; + typeAliases: string[]; +} + +function quotedProperty(name: string): string { + return `[${JSON.stringify(name)}]`; +} + +function allocateName(base: string, used: ReadonlySet): string { + let name = base; + let suffix = 2; + while (used.has(name)) { + name = `${base}${suffix++}`; + } + return name; +} + +function stripExport( + source: ts.SourceFile, + node: ts.FunctionDeclaration | ts.ClassDeclaration +): string { + const start = node.getFullStart(); + const end = node.getEnd(); + let text = source.text.slice(start, end); + const exportModifier = node.modifiers?.find( + modifier => modifier.kind === ts.SyntaxKind.ExportKeyword + ); + if (exportModifier) { + const from = exportModifier.getStart(source) - start; + const to = exportModifier.getEnd() - start; + text = `${text.slice(0, from)}${text.slice(to).replace(/^\s/, '')}`; + } + return text; +} + +function rewriteRuntimeCalls( + source: ts.SourceFile, + node: ts.FunctionDeclaration | ts.ClassDeclaration, + providerName: string +): string { + const start = node.getFullStart(); + let text = stripExport(source, node); + const exportLength = node.modifiers?.some( + modifier => modifier.kind === ts.SyntaxKind.ExportKeyword + ) + ? 'export '.length + : 0; + const replacements: Array<{ start: number; end: number }> = []; + const visit = (child: ts.Node): void => { + if ( + ts.isCallExpression(child) && + ts.isIdentifier(child.expression) && + child.expression.text === 'getRuntimeBridge' && + child.arguments.length === 0 + ) { + replacements.push({ + start: child.expression.getStart(source) - start - exportLength, + end: child.expression.getEnd() - start - exportLength, + }); + } + ts.forEachChild(child, visit); + }; + visit(node); + for (const range of replacements.sort((left, right) => right.start - left.start)) { + text = `${text.slice(0, range.start)}${providerName}${text.slice(range.end)}`; + } + return text; +} + +function runtimeImport(source: ts.SourceFile, node: ts.ImportDeclaration): string { + const text = node.getText(source); + if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== 'tywrap/runtime') { + return text; + } + return text.replace(/\bgetRuntimeBridge\s*,\s*/, '').replace(/,\s*getRuntimeBridge\b/, ''); +} + +function inspectExports(source: ts.SourceFile): ExportedCalls { + const functions: string[] = []; + const classes: ClassExport[] = []; + const typeAliases: string[] = []; + for (const node of source.statements) { + if (ts.isFunctionDeclaration(node) && node.body && node.name) { + functions.push(node.name.text); + } else if (ts.isClassDeclaration(node) && node.name) { + const methods = node.members + .filter( + (member): member is ts.MethodDeclaration => + ts.isMethodDeclaration(member) && + member.body !== undefined && + member.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword) === + true + ) + .map(member => { + if (!ts.isIdentifier(member.name)) { + throw new Error(`Cannot bind computed method in ${node.name?.text}`); + } + return member.name.text; + }); + classes.push({ name: node.name.text, methods }); + } else if (ts.isTypeAliasDeclaration(node)) { + typeAliases.push(node.name.text); + } + } + const names = [...functions, ...classes.map(cls => cls.name), ...typeAliases]; + if (new Set(names).size !== names.length) { + throw new Error('Generated module has duplicate exported names'); + } + return { functions, classes, typeAliases }; +} + +function countCalls(source: ts.SourceFile, identifier: string): number { + let count = 0; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === identifier + ) { + count++; + } + ts.forEachChild(node, visit); + }; + visit(source); + return count; +} + +function functionKeyUnion(names: readonly string[]): string { + return names.length > 0 ? names.map(JSON.stringify).join(' | ') : 'never'; +} + +function renderClient( + moduleName: string, + coreFactory: string, + calls: ExportedCalls +): Pick { + const coreFile = `./${moduleName}.generated.core.js`; + const functionFields = calls.functions.map( + name => ` ${quotedProperty(name)}: core${quotedProperty(name)},` + ); + const classFields = calls.classes + .filter(cls => cls.methods.length > 0) + .map( + cls => + ` ${quotedProperty(cls.name)}: { ${cls.methods + .map( + method => + `${quotedProperty(method)}: core${quotedProperty(cls.name)}${quotedProperty(method)}` + ) + .join(', ')} },` + ); + const classTypes = calls.classes + .filter(cls => cls.methods.length > 0) + .map( + cls => + ` ${quotedProperty(cls.name)}: Pick<__CoreApi[${JSON.stringify(cls.name)}], ${functionKeyUnion(cls.methods)}>;` + ) + .join('\n'); + const types = `export type RuntimeExecution = ReturnType; +type __CoreApi = ReturnType; +export type BoundApi = Pick<__CoreApi, ${functionKeyUnion(calls.functions)}> & { +${classTypes} +}; +export interface BoundHandle { + api: BoundApi; + dispose(): void; +}`; + const typescript = `// Generated by tywrap: explicit runtime binding prototype +import { ${coreFactory} } from '${coreFile}'; +import { BridgeDisposedError } from 'tywrap'; + +${types} + +export function bindRuntime(runtime: RuntimeExecution): BoundHandle { + let disposed = false; + const core = ${coreFactory}(() => { + if (disposed) throw new BridgeDisposedError('Bound client has been disposed'); + return runtime; + }); + const api: BoundApi = { +${[...functionFields, ...classFields].join('\n')} + }; + return { + api, + dispose(): void { disposed = true; }, + }; +} +`; + const declaration = `// Generated by tywrap: explicit runtime binding prototype +import { ${coreFactory} } from '${coreFile}'; + +${types} + +export declare function bindRuntime(runtime: RuntimeExecution): BoundHandle; +`; + return { typescript, declaration }; +} + +/** + * Split one compiled wrapper into a shared core and two call providers. + * This function does not change the default generator output. + */ +export function renderClientBindingPrototype( + generated: GeneratedCode, + moduleName: string +): BindingPrototypeCode { + if (!/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(moduleName)) { + throw new Error(`Invalid Python module name: ${moduleName}`); + } + const source = ts.createSourceFile( + `${moduleName}.generated.ts`, + generated.typescript, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const declarations = ts.createSourceFile( + `${moduleName}.generated.d.ts`, + generated.declaration, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const calls = inspectExports(source); + const callableCount = + calls.functions.length + calls.classes.reduce((count, cls) => count + cls.methods.length, 0); + if (countCalls(source, 'getRuntimeBridge') !== callableCount) { + throw new Error('Generated call sites differ from exported callable count'); + } + const usedNames = new Set([ + ...calls.functions, + ...calls.classes.map(cls => cls.name), + ...calls.typeAliases, + ]); + const coreFactory = allocateName('__tywrapCreateApi', usedNames); + usedNames.add(coreFactory); + const legacyValue = allocateName('__tywrapLegacy', usedNames); + usedNames.add(legacyValue); + const registryImport = allocateName('__tywrapRegistry', usedNames); + usedNames.add(registryImport); + const providerName = allocateName('__tywrapRuntimeProvider', usedNames); + usedNames.add(providerName); + const runtimeType = allocateName('__TywrapRuntime', usedNames); + const imports: string[] = []; + const typeAliases: string[] = []; + const body: string[] = []; + for (const node of source.statements) { + if (ts.isImportDeclaration(node)) { + imports.push(runtimeImport(source, node)); + } else if (ts.isTypeAliasDeclaration(node)) { + typeAliases.push(node.getText(source)); + } else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) { + body.push(rewriteRuntimeCalls(source, node, providerName)); + } else { + body.push(node.getFullText(source)); + } + } + const returned = [...calls.functions, ...calls.classes.map(cls => cls.name)] + .map(name => `${quotedProperty(name)}: ${name}`) + .join(', '); + const coreTypescript = `// Generated by tywrap: shared call implementation +${imports.join('\n')} +${typeAliases.join('\n')} + +type ${runtimeType} = ReturnType; +export function ${coreFactory}(${providerName}: () => ${runtimeType}) { +${body.join('\n')} + return { ${returned} }; +} +`; + const coreSource = ts.createSourceFile( + `${moduleName}.generated.core.ts`, + coreTypescript, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + if ( + countCalls(coreSource, providerName) !== callableCount || + countCalls(coreSource, 'getRuntimeBridge') !== 0 + ) { + throw new Error('Generated core did not bind every call site'); + } + const declarationBodies = declarations.statements + .filter( + (node): node is ts.FunctionDeclaration | ts.ClassDeclaration => + ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) + ) + .map(node => stripExport(declarations, node)) + .join('\n'); + const returnedTypes = [...calls.functions, ...calls.classes.map(cls => cls.name)] + .map(name => ` ${quotedProperty(name)}: typeof ${name};`) + .join('\n'); + const coreDeclaration = `// Generated by tywrap: shared call implementation +${typeAliases.join('\n')} +type ${runtimeType} = ReturnType; +${declarationBodies} +export declare function ${coreFactory}(${providerName}: () => ${runtimeType}): { +${returnedTypes} +}; +`; + const coreFile = `./${moduleName}.generated.core.js`; + const legacyFunctions = calls.functions.map( + name => `export const ${name} = ${legacyValue}${quotedProperty(name)};` + ); + const legacyClasses = calls.classes.flatMap(cls => [ + `export const ${cls.name} = ${legacyValue}${quotedProperty(cls.name)};`, + `export type ${cls.name} = InstanceType;`, + ]); + const legacyTypes = + calls.typeAliases.length > 0 + ? `export type { ${calls.typeAliases.join(', ')} } from '${coreFile}';\n` + : ''; + const legacyTypescript = `// Generated by tywrap: registry-backed wrapper prototype +import { ${coreFactory} } from '${coreFile}'; +import { getRuntimeBridge as ${registryImport} } from 'tywrap/runtime'; +${legacyTypes} +const ${legacyValue} = ${coreFactory}(${registryImport}); +${[...legacyFunctions, ...legacyClasses].join('\n')} +`; + return { + ...generated, + typescript: legacyTypescript, + declaration: generated.declaration, + sourceMap: undefined, + bindingPrototype: { + core: { typescript: coreTypescript, declaration: coreDeclaration }, + client: renderClient(moduleName, coreFactory, calls), + }, + }; +} diff --git a/test/client-binding-prototype.test.ts b/test/client-binding-prototype.test.ts new file mode 100644 index 00000000..30261551 --- /dev/null +++ b/test/client-binding-prototype.test.ts @@ -0,0 +1,444 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import ts from 'typescript'; +import { BridgeDisposedError, BridgeValidationError } from 'tywrap'; +import { createBridgeReloader } from 'tywrap/dev'; +import { HttpBridge } from 'tywrap/http'; +import { NodeBridge } from 'tywrap/node'; +import { clearRuntimeBridge, getRuntimeBridge, setRuntimeBridge } from 'tywrap/runtime'; + +import { + renderClientBindingPrototype, + type BindingPrototypeCode, +} from '../src/core/client-binding-prototype.js'; +import { generate } from '../src/tywrap.js'; +import type { RuntimeExecution } from '../src/types/index.js'; +import { getDefaultPythonPath } from '../src/utils/python.js'; +import { processUtils } from '../src/utils/runtime.js'; +import { PYTHON_AVAILABLE } from './helpers/python-probe.js'; + +interface FixtureApi { + environment(): Promise; + createClient(): Promise; + dispose(): Promise; + identity(value: T): Promise; + select(value: string | number): Promise; + scale(value: number, factor?: number): Promise; + kwOnly(kwargs?: { label: string }): Promise; + echoBytes(value: Uint8Array): Promise; + invalidReturn(): Promise; + fail(): Promise; + Client: { label(value: string): Promise }; +} + +interface FixtureHandle { + api: FixtureApi; + dispose(): void; +} + +interface ClientModule { + bindRuntime(runtime: RuntimeExecution): FixtureHandle; +} + +interface LegacyModule { + environment(): Promise; + scale(value: number, factor?: number): Promise; + kwOnly(kwargs?: { label: string }): Promise; + echoBytes(value: Uint8Array): Promise; + invalidReturn(): Promise; + Client: { label(value: string): Promise }; +} + +class LabeledRuntime implements RuntimeExecution { + disposed = false; + + constructor(readonly label: string) {} + + async call( + _module: string, + _functionName: string, + _args: unknown[], + _kwargs?: Record, + validate?: (result: T) => void + ): Promise { + if (this.disposed) throw new BridgeDisposedError('Bridge has been disposed'); + const result = this.label as T; + validate?.(result); + return result; + } + + async dispose(): Promise { + this.disposed = true; + } +} + +const fixtureA = resolve('test/fixtures/python/binding_a'); +const fixtureB = resolve('test/fixtures/python/binding_b'); +const bridgeScript = resolve('runtime/python_bridge.py'); +const pythonPath = getDefaultPythonPath(); + +describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype', () => { + let temporary = ''; + let generatedDir = ''; + let originalSource = ''; + let originalDeclaration = ''; + let prototype: BindingPrototypeCode; + let clientModule: ClientModule; + let legacyModule: LegacyModule; + + beforeAll(async () => { + temporary = await mkdtemp(join(process.cwd(), 'test', '.tywrap-binding-')); + generatedDir = join(temporary, 'generated'); + const generated = await generate({ + pythonModules: { binding_fixture: { runtime: 'node', typeHints: 'strict' } }, + pythonImportPath: [fixtureA], + output: { dir: generatedDir, format: 'esm', declaration: true, sourceMap: false }, + runtime: { node: { pythonPath } }, + performance: { caching: false, batching: false, compression: 'none' }, + }); + expect(generated.failures).toEqual([]); + originalSource = await readFile(join(generatedDir, 'binding_fixture.generated.ts'), 'utf8'); + originalDeclaration = await readFile( + join(generatedDir, 'binding_fixture.generated.d.ts'), + 'utf8' + ); + prototype = renderClientBindingPrototype( + { + typescript: originalSource, + declaration: originalDeclaration, + metadata: { generatedAt: new Date(0), sourceFiles: [], runtime: 'auto', optimizations: [] }, + }, + 'binding_fixture' + ); + + const files = [ + ['binding_fixture.generated', prototype.typescript, prototype.declaration], + [ + 'binding_fixture.generated.core', + prototype.bindingPrototype.core.typescript, + prototype.bindingPrototype.core.declaration, + ], + [ + 'binding_fixture.generated.client', + prototype.bindingPrototype.client.typescript, + prototype.bindingPrototype.client.declaration, + ], + ] as const; + for (const [stem, source, declaration] of files) { + await writeFile(join(generatedDir, `${stem}.ts`), source, 'utf8'); + await writeFile(join(generatedDir, `${stem}.d.ts`), declaration, 'utf8'); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + await writeFile(join(generatedDir, `${stem}.js`), javascript, 'utf8'); + } + clientModule = (await import( + pathToFileURL(join(generatedDir, 'binding_fixture.generated.client.js')).href + )) as ClientModule; + legacyModule = (await import( + pathToFileURL(join(generatedDir, 'binding_fixture.generated.js')).href + )) as LegacyModule; + }, 60_000); + + afterAll(async () => { + clearRuntimeBridge(); + if (temporary) await rm(temporary, { recursive: true, force: true }); + }); + + it('uses one generated call implementation and keeps default bytes intact', () => { + const core = prototype.bindingPrototype.core.typescript; + expect(core.match(/__tywrapRuntimeProvider\(\)\.call/g)).toHaveLength(11); + expect(prototype.typescript).not.toContain('.call<'); + expect(prototype.bindingPrototype.client.typescript).not.toContain('.call<'); + expect(originalSource).toContain('getRuntimeBridge().call'); + expect(originalDeclaration).toContain('export class Client'); + + const sourceBytes = Buffer.byteLength(originalSource); + const declarationBytes = Buffer.byteLength(originalDeclaration); + const optInSourceBytes = + Buffer.byteLength(prototype.typescript) + + Buffer.byteLength(core) + + Buffer.byteLength(prototype.bindingPrototype.client.typescript); + const optInDeclarationBytes = + Buffer.byteLength(prototype.declaration) + + Buffer.byteLength(prototype.bindingPrototype.core.declaration) + + Buffer.byteLength(prototype.bindingPrototype.client.declaration); + const sourceDeltaPerCallable = (optInSourceBytes - sourceBytes) / 11; + expect(sourceDeltaPerCallable).toBeGreaterThan(0); + console.info( + JSON.stringify({ + fixture: 'binding_fixture', + sourceBytes, + declarationBytes, + optInSourceBytes, + optInDeclarationBytes, + sourceDeltaPerCallable, + }) + ); + }); + + it('keeps two Python environments separate across interleaved and concurrent calls', async () => { + clearRuntimeBridge(); + const bridgeA = new NodeBridge({ + pythonPath, + scriptPath: bridgeScript, + env: { PYTHONPATH: fixtureA }, + }); + const bridgeB = new NodeBridge({ + pythonPath, + scriptPath: bridgeScript, + env: { PYTHONPATH: fixtureB }, + }); + const a = clientModule.bindRuntime(bridgeA); + const b = clientModule.bindRuntime(bridgeB); + try { + expect(() => getRuntimeBridge()).toThrow('No runtime bridge configured'); + expect(await Promise.all([a.api.environment(), b.api.environment()])).toEqual(['A', 'B']); + expect(await a.api.environment()).toBe('A'); + expect(await b.api.environment()).toBe('B'); + expect(await Promise.all([b.api.Client.label('x'), a.api.Client.label('y')])).toEqual([ + 'B:x', + 'A:y', + ]); + expect(await a.api.createClient()).toBe('A-client'); + expect(await a.api.dispose()).toBe('A-dispose'); + expect(await a.api.identity('x')).toBe('x'); + expect(await a.api.select(4)).toBe(4); + expect(await a.api.select('four')).toBe('four'); + expect(await a.api.scale(3)).toBe(3); + expect(await a.api.scale(3, 4)).toBe(12); + expect(await a.api.kwOnly({ label: 'ok' })).toBe('A:ok'); + expect(await a.api.echoBytes(new Uint8Array([1, 2, 3]))).toEqual(new Uint8Array([1, 2, 3])); + + await expect(a.api.invalidReturn()).rejects.toBeInstanceOf(BridgeValidationError); + await expect(a.api.kwOnly()).rejects.toThrow('Missing required keyword-only'); + await expect(a.api.fail()).rejects.toThrow('A-failure'); + await expect(a.api.scale('bad' as never)).rejects.toThrow(); + await expect(b.api.environment()).resolves.toBe('B'); + expect(() => getRuntimeBridge()).toThrow('No runtime bridge configured'); + + const shared = clientModule.bindRuntime(bridgeB); + b.dispose(); + await expect(b.api.environment()).rejects.toBeInstanceOf(BridgeDisposedError); + await expect(shared.api.environment()).resolves.toBe('B'); + expect(bridgeB.isDisposed).toBe(false); + shared.dispose(); + + a.dispose(); + a.dispose(); + await expect(a.api.environment()).rejects.toBeInstanceOf(BridgeDisposedError); + await expect(bridgeA.call('binding_fixture', 'environment', [])).resolves.toBe('A'); + const ownerHandle = clientModule.bindRuntime(bridgeA); + await bridgeA.dispose(); + await expect(ownerHandle.api.environment()).rejects.toBeInstanceOf(BridgeDisposedError); + } finally { + a.dispose(); + b.dispose(); + await Promise.all([bridgeA.dispose(), bridgeB.dispose()]); + } + }, 30_000); + + it('keeps legacy argument, conversion, and return validation on the registry path', async () => { + const bridge = new NodeBridge({ + pythonPath, + scriptPath: bridgeScript, + env: { PYTHONPATH: fixtureA }, + }); + setRuntimeBridge(bridge); + try { + expect(await legacyModule.scale(3, 4)).toBe(12); + expect(await legacyModule.kwOnly({ label: 'ok' })).toBe('A:ok'); + expect(await legacyModule.echoBytes(new Uint8Array([4, 5]))).toEqual(new Uint8Array([4, 5])); + await expect(legacyModule.kwOnly()).rejects.toThrow('Missing required keyword-only'); + await expect(legacyModule.invalidReturn()).rejects.toBeInstanceOf(BridgeValidationError); + expect(await legacyModule.Client.label('x')).toBe('A:x'); + } finally { + clearRuntimeBridge(); + await bridge.dispose(); + } + }, 30_000); + + it('keeps a call started before handle disposal on its selected bridge', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const runtime: RuntimeExecution = { + async call(): Promise { + await gate; + return 'ready' as T; + }, + async dispose(): Promise {}, + }; + const handle = clientModule.bindRuntime(runtime); + const started = handle.api.environment(); + handle.dispose(); + release(); + await expect(started).resolves.toBe('ready'); + await expect(handle.api.environment()).rejects.toBeInstanceOf(BridgeDisposedError); + }); + + it('uses the same companion with Node and HTTP bridges', async () => { + const server = createServer((request, response) => { + request.resume(); + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ id: 1, result: 'H' })); + }); + await new Promise(resolveServer => server.listen(0, '127.0.0.1', resolveServer)); + const address = server.address() as AddressInfo; + const node = new NodeBridge({ + pythonPath, + scriptPath: bridgeScript, + env: { PYTHONPATH: fixtureA }, + }); + const http = new HttpBridge({ baseURL: `http://127.0.0.1:${address.port}` }); + try { + const a = clientModule.bindRuntime(node); + const h = clientModule.bindRuntime(http); + expect(await Promise.all([a.api.environment(), h.api.environment()])).toEqual(['A', 'H']); + } finally { + await Promise.all([node.dispose(), http.dispose()]); + await new Promise(resolveServer => server.close(() => resolveServer())); + } + }, 30_000); + + it('keeps bound and legacy behavior explicit across successful and failed reloads', async () => { + let nextLabel = 'A'; + const reloader = await createBridgeReloader(async () => { + if (nextLabel === 'fail') throw new Error('reload failed'); + return new LabeledRuntime(nextLabel); + }); + try { + const oldBridge = reloader.current(); + const oldHandle = clientModule.bindRuntime(oldBridge); + expect(await oldHandle.api.environment()).toBe('A'); + expect(await legacyModule.environment()).toBe('A'); + + nextLabel = 'B'; + await reloader.reload(); + const newHandle = clientModule.bindRuntime(reloader.current()); + await vi.waitFor(() => expect(oldBridge.disposed).toBe(true)); + await expect(oldHandle.api.environment()).rejects.toBeInstanceOf(BridgeDisposedError); + expect(await newHandle.api.environment()).toBe('B'); + expect(await legacyModule.environment()).toBe('B'); + + nextLabel = 'fail'; + await expect(reloader.reload()).rejects.toThrow('reload failed'); + expect(reloader.current().label).toBe('B'); + expect(await newHandle.api.environment()).toBe('B'); + expect(await legacyModule.environment()).toBe('B'); + } finally { + await reloader.dispose(); + clearRuntimeBridge(); + } + }); + + it('typechecks overloads, generics, optional arguments, and the class namespace', async () => { + const consumer = join(generatedDir, 'binding-consumer.ts'); + await writeFile( + consumer, + `import { bindRuntime, type RuntimeExecution } from './binding_fixture.generated.client.js'; +declare const runtime: RuntimeExecution; +const client = bindRuntime(runtime); +const a: Promise = client.api.identity('x'); +const b: Promise = client.api.select(2); +const c: Promise = client.api.select('x'); +const d: Promise = client.api.scale(2); +const e: Promise = client.api.scale(2, 3); +const f: Promise = client.api.Client.label('x'); +const g: Promise = client.api.kwOnly({ label: 'x' }); +const h: Promise = client.api.echoBytes(new Uint8Array([1])); +void [a, b, c, d, e, f, g, h]; +// @ts-expect-error the string overload returns a string +const wrong: Promise = client.api.select('x'); +void wrong; +`, + 'utf8' + ); + const tscPath = join(process.cwd(), 'node_modules', 'typescript', 'lib', 'tsc.js'); + const compiled = await processUtils.exec( + process.execPath, + [ + tscPath, + '--ignoreConfig', + '--noEmit', + '--pretty', + 'false', + '--strict', + '--target', + 'ES2022', + '--lib', + 'ES2022,DOM,DOM.Iterable', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--skipLibCheck', + consumer, + ], + { cwd: process.cwd(), timeoutMs: 30_000 } + ); + expect(compiled.code, compiled.stdout || compiled.stderr).toBe(0); + }, 30_000); + + it('records source and declaration size for a representative module', async () => { + const comparisonDir = await mkdtemp(join(process.cwd(), 'test', '.tywrap-binding-size-')); + try { + const result = await generate({ + pythonModules: { advanced_types: { runtime: 'node', typeHints: 'strict' } }, + pythonImportPath: [resolve('test/fixtures/python')], + output: { dir: comparisonDir, format: 'esm', declaration: true, sourceMap: false }, + runtime: { node: { pythonPath } }, + performance: { caching: false, batching: false, compression: 'none' }, + }); + expect(result.failures).toEqual([]); + const source = await readFile(join(comparisonDir, 'advanced_types.generated.ts'), 'utf8'); + const declaration = await readFile( + join(comparisonDir, 'advanced_types.generated.d.ts'), + 'utf8' + ); + const rendered = renderClientBindingPrototype( + { + typescript: source, + declaration, + metadata: { + generatedAt: new Date(0), + sourceFiles: [], + runtime: 'auto', + optimizations: [], + }, + }, + 'advanced_types' + ); + const callableCount = (source.match(/getRuntimeBridge\(\)\.call/g) ?? []).length; + expect(callableCount).toBeGreaterThan(0); + const sourceBytes = Buffer.byteLength(source); + const declarationBytes = Buffer.byteLength(declaration); + const optInSourceBytes = + Buffer.byteLength(rendered.typescript) + + Buffer.byteLength(rendered.bindingPrototype.core.typescript) + + Buffer.byteLength(rendered.bindingPrototype.client.typescript); + const optInDeclarationBytes = + Buffer.byteLength(rendered.declaration) + + Buffer.byteLength(rendered.bindingPrototype.core.declaration) + + Buffer.byteLength(rendered.bindingPrototype.client.declaration); + console.info( + JSON.stringify({ + fixture: 'advanced_types', + callableCount, + sourceBytes, + declarationBytes, + optInSourceBytes, + optInDeclarationBytes, + sourceDeltaPerCallable: (optInSourceBytes - sourceBytes) / callableCount, + }) + ); + } finally { + await rm(comparisonDir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/test/fixtures/python/binding_a/binding_fixture.py b/test/fixtures/python/binding_a/binding_fixture.py new file mode 100644 index 00000000..23097351 --- /dev/null +++ b/test/fixtures/python/binding_a/binding_fixture.py @@ -0,0 +1,62 @@ +"""First Python environment for explicit client binding tests.""" + +from __future__ import annotations + +from typing import TypeVar, overload + + +T = TypeVar("T") + + +def environment() -> str: + return "A" + + +def create_client() -> str: + return "A-client" + + +def dispose() -> str: + return "A-dispose" + + +def identity(value: T) -> T: + return value + + +@overload +def select(value: int) -> int: ... + + +@overload +def select(value: str) -> str: ... + + +def select(value: int | str) -> int | str: + return value + + +def scale(value: int, factor: int = 1) -> int: + return value * factor + + +def kw_only(*, label: str) -> str: + return f"A:{label}" + + +def echo_bytes(value: bytes) -> bytes: + return value + + +def invalid_return() -> int: + return "wrong" # type: ignore[return-value] + + +def fail() -> str: + raise RuntimeError("A-failure") + + +class Client: + @classmethod + def label(cls, value: str) -> str: + return f"A:{value}" diff --git a/test/fixtures/python/binding_b/binding_fixture.py b/test/fixtures/python/binding_b/binding_fixture.py new file mode 100644 index 00000000..7d0410a9 --- /dev/null +++ b/test/fixtures/python/binding_b/binding_fixture.py @@ -0,0 +1,62 @@ +"""Second Python environment for explicit client binding tests.""" + +from __future__ import annotations + +from typing import TypeVar, overload + + +T = TypeVar("T") + + +def environment() -> str: + return "B" + + +def create_client() -> str: + return "B-client" + + +def dispose() -> str: + return "B-dispose" + + +def identity(value: T) -> T: + return value + + +@overload +def select(value: int) -> int: ... + + +@overload +def select(value: str) -> str: ... + + +def select(value: int | str) -> int | str: + return value + + +def scale(value: int, factor: int = 1) -> int: + return value * factor + + +def kw_only(*, label: str) -> str: + return f"B:{label}" + + +def echo_bytes(value: bytes) -> bytes: + return value + + +def invalid_return() -> int: + return "wrong" # type: ignore[return-value] + + +def fail() -> str: + raise RuntimeError("B-failure") + + +class Client: + @classmethod + def label(cls, value: str) -> str: + return f"B:{value}" From 129fd24ad9f0d64ce5c1d8fea626a42940394765 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:57:04 -0700 Subject: [PATCH 2/8] feat(binding): consume compiler runtime getter template --- src/core/client-binding-prototype.ts | 69 ++++++++------------------- test/client-binding-prototype.test.ts | 40 +++++++++++++++- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/src/core/client-binding-prototype.ts b/src/core/client-binding-prototype.ts index d9266b8d..3d6c27b4 100644 --- a/src/core/client-binding-prototype.ts +++ b/src/core/client-binding-prototype.ts @@ -53,48 +53,6 @@ function stripExport( return text; } -function rewriteRuntimeCalls( - source: ts.SourceFile, - node: ts.FunctionDeclaration | ts.ClassDeclaration, - providerName: string -): string { - const start = node.getFullStart(); - let text = stripExport(source, node); - const exportLength = node.modifiers?.some( - modifier => modifier.kind === ts.SyntaxKind.ExportKeyword - ) - ? 'export '.length - : 0; - const replacements: Array<{ start: number; end: number }> = []; - const visit = (child: ts.Node): void => { - if ( - ts.isCallExpression(child) && - ts.isIdentifier(child.expression) && - child.expression.text === 'getRuntimeBridge' && - child.arguments.length === 0 - ) { - replacements.push({ - start: child.expression.getStart(source) - start - exportLength, - end: child.expression.getEnd() - start - exportLength, - }); - } - ts.forEachChild(child, visit); - }; - visit(node); - for (const range of replacements.sort((left, right) => right.start - left.start)) { - text = `${text.slice(0, range.start)}${providerName}${text.slice(range.end)}`; - } - return text; -} - -function runtimeImport(source: ts.SourceFile, node: ts.ImportDeclaration): string { - const text = node.getText(source); - if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== 'tywrap/runtime') { - return text; - } - return text.replace(/\bgetRuntimeBridge\s*,\s*/, '').replace(/,\s*getRuntimeBridge\b/, ''); -} - function inspectExports(source: ts.SourceFile): ExportedCalls { const functions: string[] = []; const classes: ClassExport[] = []; @@ -118,7 +76,10 @@ function inspectExports(source: ts.SourceFile): ExportedCalls { return member.name.text; }); classes.push({ name: node.name.text, methods }); - } else if (ts.isTypeAliasDeclaration(node)) { + } else if ( + ts.isTypeAliasDeclaration(node) && + node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { typeAliases.push(node.name.text); } } @@ -222,6 +183,7 @@ export declare function bindRuntime(runtime: RuntimeExecution): BoundHandle; */ export function renderClientBindingPrototype( generated: GeneratedCode, + bindingTemplate: GeneratedCode, moduleName: string ): BindingPrototypeCode { if (!/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(moduleName)) { @@ -229,14 +191,14 @@ export function renderClientBindingPrototype( } const source = ts.createSourceFile( `${moduleName}.generated.ts`, - generated.typescript, + bindingTemplate.typescript, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ); const declarations = ts.createSourceFile( `${moduleName}.generated.d.ts`, - generated.declaration, + bindingTemplate.declaration, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS @@ -244,8 +206,12 @@ export function renderClientBindingPrototype( const calls = inspectExports(source); const callableCount = calls.functions.length + calls.classes.reduce((count, cls) => count + cls.methods.length, 0); - if (countCalls(source, 'getRuntimeBridge') !== callableCount) { - throw new Error('Generated call sites differ from exported callable count'); + if ( + countCalls(source, '__tywrapRuntimeProvider') !== callableCount || + countCalls(source, 'getRuntimeBridge') !== 0 || + bindingTemplate.declaration !== generated.declaration + ) { + throw new Error('Binding template call sites or declarations differ from default output'); } const usedNames = new Set([ ...calls.functions, @@ -258,7 +224,10 @@ export function renderClientBindingPrototype( usedNames.add(legacyValue); const registryImport = allocateName('__tywrapRegistry', usedNames); usedNames.add(registryImport); - const providerName = allocateName('__tywrapRuntimeProvider', usedNames); + const providerName = '__tywrapRuntimeProvider'; + if (usedNames.has(providerName)) { + throw new Error(`Generated module defines reserved provider name ${providerName}`); + } usedNames.add(providerName); const runtimeType = allocateName('__TywrapRuntime', usedNames); const imports: string[] = []; @@ -266,11 +235,11 @@ export function renderClientBindingPrototype( const body: string[] = []; for (const node of source.statements) { if (ts.isImportDeclaration(node)) { - imports.push(runtimeImport(source, node)); + imports.push(node.getText(source)); } else if (ts.isTypeAliasDeclaration(node)) { typeAliases.push(node.getText(source)); } else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) { - body.push(rewriteRuntimeCalls(source, node, providerName)); + body.push(stripExport(source, node)); } else { body.push(node.getFullText(source)); } diff --git a/test/client-binding-prototype.test.ts b/test/client-binding-prototype.test.ts index 30261551..2214e925 100644 --- a/test/client-binding-prototype.test.ts +++ b/test/client-binding-prototype.test.ts @@ -11,12 +11,20 @@ import { HttpBridge } from 'tywrap/http'; import { NodeBridge } from 'tywrap/node'; import { clearRuntimeBridge, getRuntimeBridge, setRuntimeBridge } from 'tywrap/runtime'; +import { + compileContract, + DEFAULT_CALLABLE_CAPABILITIES, + DEFAULT_VALUE_CONVERSION, +} from '../src/core/callable-compiler.js'; import { renderClientBindingPrototype, type BindingPrototypeCode, } from '../src/core/client-binding-prototype.js'; +import { CodeGenerator } from '../src/core/generator.js'; +import { validateIrContract } from '../src/core/ir-contract.js'; +import { transformIrToTsModel } from '../src/core/ir-model.js'; import { generate } from '../src/tywrap.js'; -import type { RuntimeExecution } from '../src/types/index.js'; +import type { GeneratedCode, RuntimeExecution } from '../src/types/index.js'; import { getDefaultPythonPath } from '../src/utils/python.js'; import { processUtils } from '../src/utils/runtime.js'; import { PYTHON_AVAILABLE } from './helpers/python-probe.js'; @@ -81,6 +89,34 @@ const fixtureB = resolve('test/fixtures/python/binding_b'); const bridgeScript = resolve('runtime/python_bridge.py'); const pythonPath = getDefaultPythonPath(); +async function bindingTemplate(outputDir: string, moduleName: string): Promise { + const contractPath = join(outputDir, `${moduleName}.contract.json`); + const parsed: unknown = JSON.parse(await readFile(contractPath, 'utf8')); + const validated = validateIrContract(parsed, contractPath, { allowOmittedMetadata: true }); + if (!validated.ok || !validated.contract) { + throw new Error(`Invalid generated contract: ${JSON.stringify(validated.diagnostics)}`); + } + const generator = new CodeGenerator(); + const compiled = compileContract(validated.contract, { + module: transformIrToTsModel(validated.contract), + generator, + conversion: DEFAULT_VALUE_CONVERSION, + capabilities: DEFAULT_CALLABLE_CAPABILITIES, + }); + const emittedSource = await readFile(join(outputDir, `${moduleName}.generated.ts`), 'utf8'); + const emittedDeclaration = await readFile( + join(outputDir, `${moduleName}.generated.d.ts`), + 'utf8' + ); + if ( + compiled.generated.typescript !== emittedSource || + compiled.generated.declaration !== emittedDeclaration + ) { + throw new Error('Compiled contract differs from default generated output'); + } + return generator.generateModuleBindingTemplate(compiled.module); +} + describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype', () => { let temporary = ''; let generatedDir = ''; @@ -112,6 +148,7 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' declaration: originalDeclaration, metadata: { generatedAt: new Date(0), sourceFiles: [], runtime: 'auto', optimizations: [] }, }, + await bindingTemplate(generatedDir, 'binding_fixture'), 'binding_fixture' ); @@ -412,6 +449,7 @@ void wrong; optimizations: [], }, }, + await bindingTemplate(comparisonDir, 'advanced_types'), 'advanced_types' ); const callableCount = (source.match(/getRuntimeBridge\(\)\.call/g) ?? []).length; From dd0534c629c654cb8709af2897e78ed50dbe59c4 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:00:32 -0700 Subject: [PATCH 3/8] fix(binding): accept allocated runtime provider identifier --- src/core/client-binding-prototype.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/client-binding-prototype.ts b/src/core/client-binding-prototype.ts index 3d6c27b4..719e31a9 100644 --- a/src/core/client-binding-prototype.ts +++ b/src/core/client-binding-prototype.ts @@ -184,11 +184,15 @@ export declare function bindRuntime(runtime: RuntimeExecution): BoundHandle; export function renderClientBindingPrototype( generated: GeneratedCode, bindingTemplate: GeneratedCode, - moduleName: string + moduleName: string, + runtimeGetterIdentifier = '__tywrapRuntimeProvider' ): BindingPrototypeCode { if (!/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(moduleName)) { throw new Error(`Invalid Python module name: ${moduleName}`); } + if (!/^__tywrapRuntimeProvider(?:[1-9][0-9]*)?$/.test(runtimeGetterIdentifier)) { + throw new Error(`Invalid runtime getter identifier: ${runtimeGetterIdentifier}`); + } const source = ts.createSourceFile( `${moduleName}.generated.ts`, bindingTemplate.typescript, @@ -207,7 +211,7 @@ export function renderClientBindingPrototype( const callableCount = calls.functions.length + calls.classes.reduce((count, cls) => count + cls.methods.length, 0); if ( - countCalls(source, '__tywrapRuntimeProvider') !== callableCount || + countCalls(source, runtimeGetterIdentifier) !== callableCount || countCalls(source, 'getRuntimeBridge') !== 0 || bindingTemplate.declaration !== generated.declaration ) { @@ -224,7 +228,7 @@ export function renderClientBindingPrototype( usedNames.add(legacyValue); const registryImport = allocateName('__tywrapRegistry', usedNames); usedNames.add(registryImport); - const providerName = '__tywrapRuntimeProvider'; + const providerName = runtimeGetterIdentifier; if (usedNames.has(providerName)) { throw new Error(`Generated module defines reserved provider name ${providerName}`); } From d572cb7c167f0026aaf4b762bf1a5f4551047ea7 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:01:40 -0700 Subject: [PATCH 4/8] fix(binding): type JSON property mapping explicitly --- src/core/client-binding-prototype.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/client-binding-prototype.ts b/src/core/client-binding-prototype.ts index 719e31a9..6cd94579 100644 --- a/src/core/client-binding-prototype.ts +++ b/src/core/client-binding-prototype.ts @@ -107,7 +107,7 @@ function countCalls(source: ts.SourceFile, identifier: string): number { } function functionKeyUnion(names: readonly string[]): string { - return names.length > 0 ? names.map(JSON.stringify).join(' | ') : 'never'; + return names.length > 0 ? names.map(name => JSON.stringify(name)).join(' | ') : 'never'; } function renderClient( From 90098c5bd8a55ab73bbf94dedde60bbc3ad17e15 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:06:47 -0700 Subject: [PATCH 5/8] test(binding): align assertions with generated runtime contracts --- test/client-binding-prototype.test.ts | 101 +++++++++++++++++++++----- 1 file changed, 83 insertions(+), 18 deletions(-) diff --git a/test/client-binding-prototype.test.ts b/test/client-binding-prototype.test.ts index 2214e925..48a7282c 100644 --- a/test/client-binding-prototype.test.ts +++ b/test/client-binding-prototype.test.ts @@ -1,12 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import ts from 'typescript'; -import { BridgeDisposedError, BridgeValidationError } from 'tywrap'; -import { createBridgeReloader } from 'tywrap/dev'; +import { BridgeDisposedError } from 'tywrap'; import { HttpBridge } from 'tywrap/http'; import { NodeBridge } from 'tywrap/node'; import { clearRuntimeBridge, getRuntimeBridge, setRuntimeBridge } from 'tywrap/runtime'; @@ -23,8 +22,9 @@ import { import { CodeGenerator } from '../src/core/generator.js'; import { validateIrContract } from '../src/core/ir-contract.js'; import { transformIrToTsModel } from '../src/core/ir-model.js'; +import { createBridgeReloader } from '../src/dev.js'; import { generate } from '../src/tywrap.js'; -import type { GeneratedCode, RuntimeExecution } from '../src/types/index.js'; +import type { GeneratedCode, PythonModule, RuntimeExecution } from '../src/types/index.js'; import { getDefaultPythonPath } from '../src/utils/python.js'; import { processUtils } from '../src/utils/runtime.js'; import { PYTHON_AVAILABLE } from './helpers/python-probe.js'; @@ -88,8 +88,19 @@ const fixtureA = resolve('test/fixtures/python/binding_a'); const fixtureB = resolve('test/fixtures/python/binding_b'); const bridgeScript = resolve('runtime/python_bridge.py'); const pythonPath = getDefaultPythonPath(); +const defaultExcludedExports = new Set([ + 'dataclass', + 'property', + 'staticmethod', + 'classmethod', + 'abstractmethod', + 'cached_property', +]); -async function bindingTemplate(outputDir: string, moduleName: string): Promise { +async function bindingTemplate( + outputDir: string, + moduleName: string +): Promise<{ baseline: GeneratedCode; template: GeneratedCode; module: PythonModule }> { const contractPath = join(outputDir, `${moduleName}.contract.json`); const parsed: unknown = JSON.parse(await readFile(contractPath, 'utf8')); const validated = validateIrContract(parsed, contractPath, { allowOmittedMetadata: true }); @@ -97,8 +108,11 @@ async function bindingTemplate(outputDir: string, moduleName: string): Promise !defaultExcludedExports.has(func.name)); + model.classes = model.classes.filter(cls => !defaultExcludedExports.has(cls.name)); const compiled = compileContract(validated.contract, { - module: transformIrToTsModel(validated.contract), + module: model, generator, conversion: DEFAULT_VALUE_CONVERSION, capabilities: DEFAULT_CALLABLE_CAPABILITIES, @@ -114,7 +128,11 @@ async function bindingTemplate(outputDir: string, moduleName: string): Promise { @@ -122,6 +140,7 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' let generatedDir = ''; let originalSource = ''; let originalDeclaration = ''; + let compiledModule: PythonModule; let prototype: BindingPrototypeCode; let clientModule: ClientModule; let legacyModule: LegacyModule; @@ -142,13 +161,17 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' join(generatedDir, 'binding_fixture.generated.d.ts'), 'utf8' ); + const compiled = await bindingTemplate(generatedDir, 'binding_fixture'); + compiledModule = compiled.module; + expect(compiled.baseline.typescript).toBe(originalSource); + expect(compiled.baseline.declaration).toBe(originalDeclaration); prototype = renderClientBindingPrototype( { typescript: originalSource, declaration: originalDeclaration, metadata: { generatedAt: new Date(0), sourceFiles: [], runtime: 'auto', optimizations: [] }, }, - await bindingTemplate(generatedDir, 'binding_fixture'), + compiled.template, 'binding_fixture' ); @@ -188,7 +211,9 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' it('uses one generated call implementation and keeps default bytes intact', () => { const core = prototype.bindingPrototype.core.typescript; - expect(core.match(/__tywrapRuntimeProvider\(\)\.call/g)).toHaveLength(11); + const callableCount = (originalSource.match(/getRuntimeBridge\(\)\.call/g) ?? []).length; + expect(callableCount).toBeGreaterThan(0); + expect(core.match(/__tywrapRuntimeProvider\(\)\.call/g)).toHaveLength(callableCount); expect(prototype.typescript).not.toContain('.call<'); expect(prototype.bindingPrototype.client.typescript).not.toContain('.call<'); expect(originalSource).toContain('getRuntimeBridge().call'); @@ -204,11 +229,12 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' Buffer.byteLength(prototype.declaration) + Buffer.byteLength(prototype.bindingPrototype.core.declaration) + Buffer.byteLength(prototype.bindingPrototype.client.declaration); - const sourceDeltaPerCallable = (optInSourceBytes - sourceBytes) / 11; + const sourceDeltaPerCallable = (optInSourceBytes - sourceBytes) / callableCount; expect(sourceDeltaPerCallable).toBeGreaterThan(0); console.info( JSON.stringify({ fixture: 'binding_fixture', + callableCount, sourceBytes, declarationBytes, optInSourceBytes, @@ -218,6 +244,47 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' ); }); + it('executes an allocated runtime getter without falling back to the registry', async () => { + const getter = '__tywrapRuntimeProvider1'; + const template = new CodeGenerator().generateModuleBindingTemplate( + compiledModule, + false, + getter + ); + const allocated = renderClientBindingPrototype( + { + typescript: originalSource, + declaration: originalDeclaration, + metadata: { generatedAt: new Date(0), sourceFiles: [], runtime: 'auto', optimizations: [] }, + }, + template, + 'binding_fixture', + getter + ); + expect(allocated.bindingPrototype.core.typescript).toContain(`${getter}().call`); + expect(allocated.bindingPrototype.core.typescript).not.toContain('getRuntimeBridge().call'); + const allocatedDir = join(temporary, 'allocated'); + await mkdir(allocatedDir); + for (const [stem, source] of [ + ['binding_fixture.generated.core', allocated.bindingPrototype.core.typescript], + ['binding_fixture.generated.client', allocated.bindingPrototype.client.typescript], + ] as const) { + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + await writeFile(join(allocatedDir, `${stem}.js`), javascript, 'utf8'); + } + const module = (await import( + pathToFileURL(join(allocatedDir, 'binding_fixture.generated.client.js')).href + )) as ClientModule; + const handle = module.bindRuntime(new LabeledRuntime('allocated')); + try { + await expect(handle.api.environment()).resolves.toBe('allocated'); + } finally { + handle.dispose(); + } + }); + it('keeps two Python environments separate across interleaved and concurrent calls', async () => { clearRuntimeBridge(); const bridgeA = new NodeBridge({ @@ -251,7 +318,7 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' expect(await a.api.kwOnly({ label: 'ok' })).toBe('A:ok'); expect(await a.api.echoBytes(new Uint8Array([1, 2, 3]))).toEqual(new Uint8Array([1, 2, 3])); - await expect(a.api.invalidReturn()).rejects.toBeInstanceOf(BridgeValidationError); + await expect(a.api.invalidReturn()).rejects.toThrow('Return validation failed'); await expect(a.api.kwOnly()).rejects.toThrow('Missing required keyword-only'); await expect(a.api.fail()).rejects.toThrow('A-failure'); await expect(a.api.scale('bad' as never)).rejects.toThrow(); @@ -291,7 +358,7 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' expect(await legacyModule.kwOnly({ label: 'ok' })).toBe('A:ok'); expect(await legacyModule.echoBytes(new Uint8Array([4, 5]))).toEqual(new Uint8Array([4, 5])); await expect(legacyModule.kwOnly()).rejects.toThrow('Missing required keyword-only'); - await expect(legacyModule.invalidReturn()).rejects.toBeInstanceOf(BridgeValidationError); + await expect(legacyModule.invalidReturn()).rejects.toThrow('Return validation failed'); expect(await legacyModule.Client.label('x')).toBe('A:x'); } finally { clearRuntimeBridge(); @@ -433,11 +500,9 @@ void wrong; performance: { caching: false, batching: false, compression: 'none' }, }); expect(result.failures).toEqual([]); - const source = await readFile(join(comparisonDir, 'advanced_types.generated.ts'), 'utf8'); - const declaration = await readFile( - join(comparisonDir, 'advanced_types.generated.d.ts'), - 'utf8' - ); + const compiled = await bindingTemplate(comparisonDir, 'advanced_types'); + const source = compiled.baseline.typescript; + const declaration = compiled.baseline.declaration; const rendered = renderClientBindingPrototype( { typescript: source, @@ -449,7 +514,7 @@ void wrong; optimizations: [], }, }, - await bindingTemplate(comparisonDir, 'advanced_types'), + compiled.template, 'advanced_types' ); const callableCount = (source.match(/getRuntimeBridge\(\)\.call/g) ?? []).length; From 932d288325117f03c50efe42e8ccbf35956a7b53 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:10:06 -0700 Subject: [PATCH 6/8] test(binding): reflect Python 3.10 overload fallback --- test/client-binding-prototype.test.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/test/client-binding-prototype.test.ts b/test/client-binding-prototype.test.ts index 48a7282c..df2e2f54 100644 --- a/test/client-binding-prototype.test.ts +++ b/test/client-binding-prototype.test.ts @@ -140,12 +140,20 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' let generatedDir = ''; let originalSource = ''; let originalDeclaration = ''; + let supportsOverloadExtraction = false; let compiledModule: PythonModule; let prototype: BindingPrototypeCode; let clientModule: ClientModule; let legacyModule: LegacyModule; beforeAll(async () => { + const overloadProbe = await processUtils.exec( + pythonPath, + ['-c', 'import typing; print(int(hasattr(typing, "get_overloads")))'], + { timeoutMs: 10_000 } + ); + expect(overloadProbe.code, overloadProbe.stderr).toBe(0); + supportsOverloadExtraction = overloadProbe.stdout.trim() === '1'; temporary = await mkdtemp(join(process.cwd(), 'test', '.tywrap-binding-')); generatedDir = join(temporary, 'generated'); const generated = await generate({ @@ -443,23 +451,30 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' it('typechecks overloads, generics, optional arguments, and the class namespace', async () => { const consumer = join(generatedDir, 'binding-consumer.ts'); + const selectSignatures = (originalDeclaration.match(/^export function select\(/gm) ?? []) + .length; + expect(selectSignatures).toBe(supportsOverloadExtraction ? 2 : 1); + const selectChecks = supportsOverloadExtraction + ? `const b: Promise = client.api.select(2); +const c: Promise = client.api.select('x'); +// @ts-expect-error the string overload returns a string +const wrong: Promise = client.api.select('x'); +void wrong;` + : `const b: Promise = client.api.select(2); +const c: Promise = client.api.select('x');`; await writeFile( consumer, `import { bindRuntime, type RuntimeExecution } from './binding_fixture.generated.client.js'; declare const runtime: RuntimeExecution; const client = bindRuntime(runtime); const a: Promise = client.api.identity('x'); -const b: Promise = client.api.select(2); -const c: Promise = client.api.select('x'); +${selectChecks} const d: Promise = client.api.scale(2); const e: Promise = client.api.scale(2, 3); const f: Promise = client.api.Client.label('x'); const g: Promise = client.api.kwOnly({ label: 'x' }); const h: Promise = client.api.echoBytes(new Uint8Array([1])); void [a, b, c, d, e, f, g, h]; -// @ts-expect-error the string overload returns a string -const wrong: Promise = client.api.select('x'); -void wrong; `, 'utf8' ); From 1e6ff97f9f057688b3159d70802258c03e0833a4 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:46:03 -0700 Subject: [PATCH 7/8] test(binding): preserve canonical unknown generic return --- test/client-binding-prototype.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/client-binding-prototype.test.ts b/test/client-binding-prototype.test.ts index df2e2f54..3b191979 100644 --- a/test/client-binding-prototype.test.ts +++ b/test/client-binding-prototype.test.ts @@ -449,7 +449,7 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' } }); - it('typechecks overloads, generics, optional arguments, and the class namespace', async () => { + it('typechecks overloads, generic inputs, optional arguments, and class calls', async () => { const consumer = join(generatedDir, 'binding-consumer.ts'); const selectSignatures = (originalDeclaration.match(/^export function select\(/gm) ?? []) .length; @@ -465,16 +465,24 @@ const c: Promise = client.api.select('x');`; await writeFile( consumer, `import { bindRuntime, type RuntimeExecution } from './binding_fixture.generated.client.js'; +import { identity as legacyIdentity } from './binding_fixture.generated.js'; declare const runtime: RuntimeExecution; const client = bindRuntime(runtime); -const a: Promise = client.api.identity('x'); +const a: Promise = client.api.identity('x'); +const legacyA: Promise = legacyIdentity('x'); +// @ts-expect-error the explicit generic input type is string +client.api.identity(3); +// @ts-expect-error an unresolved type variable cannot promise a string result +const unsafeResult: Promise = client.api.identity('x'); +// @ts-expect-error legacy calls use the same unresolved return contract +const unsafeLegacy: Promise = legacyIdentity('x'); ${selectChecks} const d: Promise = client.api.scale(2); const e: Promise = client.api.scale(2, 3); const f: Promise = client.api.Client.label('x'); const g: Promise = client.api.kwOnly({ label: 'x' }); const h: Promise = client.api.echoBytes(new Uint8Array([1])); -void [a, b, c, d, e, f, g, h]; +void [a, legacyA, unsafeResult, unsafeLegacy, b, c, d, e, f, g, h]; `, 'utf8' ); From 65bed9577254e68566827496a9693223da1e3e05 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 02:55:17 -0700 Subject: [PATCH 8/8] fix(binding): derive both forms from one compiled model --- src/core/client-binding-prototype.ts | 30 ++++++++---- test/client-binding-prototype.test.ts | 70 +++++++++++---------------- 2 files changed, 47 insertions(+), 53 deletions(-) diff --git a/src/core/client-binding-prototype.ts b/src/core/client-binding-prototype.ts index 6cd94579..13fa4286 100644 --- a/src/core/client-binding-prototype.ts +++ b/src/core/client-binding-prototype.ts @@ -2,7 +2,8 @@ import ts from 'typescript'; -import type { GeneratedCode } from '../types/index.js'; +import type { GeneratedCode, PythonModule } from '../types/index.js'; +import type { CodeGenerator } from './generator.js'; export interface BindingPrototypeCode extends GeneratedCode { bindingPrototype: { @@ -137,7 +138,7 @@ function renderClient( ` ${quotedProperty(cls.name)}: Pick<__CoreApi[${JSON.stringify(cls.name)}], ${functionKeyUnion(cls.methods)}>;` ) .join('\n'); - const types = `export type RuntimeExecution = ReturnType; + const types = `export type BoundRuntime = Pick, 'call'>; type __CoreApi = ReturnType; export type BoundApi = Pick<__CoreApi, ${functionKeyUnion(calls.functions)}> & { ${classTypes} @@ -152,7 +153,7 @@ import { BridgeDisposedError } from 'tywrap'; ${types} -export function bindRuntime(runtime: RuntimeExecution): BoundHandle { +export function bindRuntime(runtime: BoundRuntime): BoundHandle { let disposed = false; const core = ${coreFactory}(() => { if (disposed) throw new BridgeDisposedError('Bound client has been disposed'); @@ -172,7 +173,7 @@ import { ${coreFactory} } from '${coreFile}'; ${types} -export declare function bindRuntime(runtime: RuntimeExecution): BoundHandle; +export declare function bindRuntime(runtime: BoundRuntime): BoundHandle; `; return { typescript, declaration }; } @@ -182,17 +183,27 @@ export declare function bindRuntime(runtime: RuntimeExecution): BoundHandle; * This function does not change the default generator output. */ export function renderClientBindingPrototype( - generated: GeneratedCode, - bindingTemplate: GeneratedCode, - moduleName: string, + module: PythonModule, + generator: Pick, + annotatedJSDoc = false, runtimeGetterIdentifier = '__tywrapRuntimeProvider' ): BindingPrototypeCode { + const moduleName = module.name; if (!/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(moduleName)) { throw new Error(`Invalid Python module name: ${moduleName}`); } if (!/^__tywrapRuntimeProvider(?:[1-9][0-9]*)?$/.test(runtimeGetterIdentifier)) { throw new Error(`Invalid runtime getter identifier: ${runtimeGetterIdentifier}`); } + const generated = generator.generateModuleDefinition(module, annotatedJSDoc); + const bindingTemplate = generator.generateModuleBindingTemplate( + module, + annotatedJSDoc, + runtimeGetterIdentifier + ); + if (generated.sourceMap !== undefined || bindingTemplate.sourceMap !== undefined) { + throw new Error('Client binding prototype does not support source maps'); + } const source = ts.createSourceFile( `${moduleName}.generated.ts`, bindingTemplate.typescript, @@ -255,7 +266,7 @@ export function renderClientBindingPrototype( ${imports.join('\n')} ${typeAliases.join('\n')} -type ${runtimeType} = ReturnType; +type ${runtimeType} = Pick, 'call'>; export function ${coreFactory}(${providerName}: () => ${runtimeType}) { ${body.join('\n')} return { ${returned} }; @@ -286,7 +297,7 @@ ${body.join('\n')} .join('\n'); const coreDeclaration = `// Generated by tywrap: shared call implementation ${typeAliases.join('\n')} -type ${runtimeType} = ReturnType; +type ${runtimeType} = Pick, 'call'>; ${declarationBodies} export declare function ${coreFactory}(${providerName}: () => ${runtimeType}): { ${returnedTypes} @@ -315,7 +326,6 @@ ${[...legacyFunctions, ...legacyClasses].join('\n')} ...generated, typescript: legacyTypescript, declaration: generated.declaration, - sourceMap: undefined, bindingPrototype: { core: { typescript: coreTypescript, declaration: coreDeclaration }, client: renderClient(moduleName, coreFactory, calls), diff --git a/test/client-binding-prototype.test.ts b/test/client-binding-prototype.test.ts index 3b191979..0b332366 100644 --- a/test/client-binding-prototype.test.ts +++ b/test/client-binding-prototype.test.ts @@ -49,7 +49,7 @@ interface FixtureHandle { } interface ClientModule { - bindRuntime(runtime: RuntimeExecution): FixtureHandle; + bindRuntime(runtime: Pick): FixtureHandle; } interface LegacyModule { @@ -97,10 +97,10 @@ const defaultExcludedExports = new Set([ 'cached_property', ]); -async function bindingTemplate( +async function compiledFixture( outputDir: string, moduleName: string -): Promise<{ baseline: GeneratedCode; template: GeneratedCode; module: PythonModule }> { +): Promise<{ baseline: GeneratedCode; module: PythonModule }> { const contractPath = join(outputDir, `${moduleName}.contract.json`); const parsed: unknown = JSON.parse(await readFile(contractPath, 'utf8')); const validated = validateIrContract(parsed, contractPath, { allowOmittedMetadata: true }); @@ -130,7 +130,6 @@ async function bindingTemplate( } return { baseline: compiled.generated, - template: generator.generateModuleBindingTemplate(compiled.module), module: compiled.module, }; } @@ -169,19 +168,12 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' join(generatedDir, 'binding_fixture.generated.d.ts'), 'utf8' ); - const compiled = await bindingTemplate(generatedDir, 'binding_fixture'); + const compiled = await compiledFixture(generatedDir, 'binding_fixture'); compiledModule = compiled.module; expect(compiled.baseline.typescript).toBe(originalSource); expect(compiled.baseline.declaration).toBe(originalDeclaration); - prototype = renderClientBindingPrototype( - { - typescript: originalSource, - declaration: originalDeclaration, - metadata: { generatedAt: new Date(0), sourceFiles: [], runtime: 'auto', optimizations: [] }, - }, - compiled.template, - 'binding_fixture' - ); + prototype = renderClientBindingPrototype(compiled.module, new CodeGenerator()); + expect(prototype.declaration).toBe(originalDeclaration); const files = [ ['binding_fixture.generated', prototype.typescript, prototype.declaration], @@ -254,21 +246,12 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' it('executes an allocated runtime getter without falling back to the registry', async () => { const getter = '__tywrapRuntimeProvider1'; - const template = new CodeGenerator().generateModuleBindingTemplate( + const allocated = renderClientBindingPrototype( compiledModule, + new CodeGenerator(), false, getter ); - const allocated = renderClientBindingPrototype( - { - typescript: originalSource, - declaration: originalDeclaration, - metadata: { generatedAt: new Date(0), sourceFiles: [], runtime: 'auto', optimizations: [] }, - }, - template, - 'binding_fixture', - getter - ); expect(allocated.bindingPrototype.core.typescript).toContain(`${getter}().call`); expect(allocated.bindingPrototype.core.typescript).not.toContain('getRuntimeBridge().call'); const allocatedDir = join(temporary, 'allocated'); @@ -293,6 +276,19 @@ describe.skipIf(!PYTHON_AVAILABLE)('explicit generated client binding prototype' } }); + it('rejects source maps until the opt-in files can each carry one', () => { + const generator = new CodeGenerator(); + expect(() => + renderClientBindingPrototype(compiledModule, { + generateModuleDefinition: module => ({ + ...generator.generateModuleDefinition(module), + sourceMap: '{}', + }), + generateModuleBindingTemplate: generator.generateModuleBindingTemplate.bind(generator), + }) + ).toThrow('Client binding prototype does not support source maps'); + }); + it('keeps two Python environments separate across interleaved and concurrent calls', async () => { clearRuntimeBridge(); const bridgeA = new NodeBridge({ @@ -464,10 +460,11 @@ void wrong;` const c: Promise = client.api.select('x');`; await writeFile( consumer, - `import { bindRuntime, type RuntimeExecution } from './binding_fixture.generated.client.js'; + `import { bindRuntime, type BoundRuntime } from './binding_fixture.generated.client.js'; import { identity as legacyIdentity } from './binding_fixture.generated.js'; -declare const runtime: RuntimeExecution; +declare const runtime: BoundRuntime; const client = bindRuntime(runtime); +const callOnly = bindRuntime({ call: async () => undefined as T }); const a: Promise = client.api.identity('x'); const legacyA: Promise = legacyIdentity('x'); // @ts-expect-error the explicit generic input type is string @@ -482,7 +479,7 @@ const e: Promise = client.api.scale(2, 3); const f: Promise = client.api.Client.label('x'); const g: Promise = client.api.kwOnly({ label: 'x' }); const h: Promise = client.api.echoBytes(new Uint8Array([1])); -void [a, legacyA, unsafeResult, unsafeLegacy, b, c, d, e, f, g, h]; +void [callOnly, a, legacyA, unsafeResult, unsafeLegacy, b, c, d, e, f, g, h]; `, 'utf8' ); @@ -523,23 +520,10 @@ void [a, legacyA, unsafeResult, unsafeLegacy, b, c, d, e, f, g, h]; performance: { caching: false, batching: false, compression: 'none' }, }); expect(result.failures).toEqual([]); - const compiled = await bindingTemplate(comparisonDir, 'advanced_types'); + const compiled = await compiledFixture(comparisonDir, 'advanced_types'); const source = compiled.baseline.typescript; const declaration = compiled.baseline.declaration; - const rendered = renderClientBindingPrototype( - { - typescript: source, - declaration, - metadata: { - generatedAt: new Date(0), - sourceFiles: [], - runtime: 'auto', - optimizations: [], - }, - }, - compiled.template, - 'advanced_types' - ); + const rendered = renderClientBindingPrototype(compiled.module, new CodeGenerator()); const callableCount = (source.match(/getRuntimeBridge\(\)\.call/g) ?? []).length; expect(callableCount).toBeGreaterThan(0); const sourceBytes = Buffer.byteLength(source);