Shipping tested TypeScript helpers into a sandboxed iframe by rebuilding them as name: fn.toString() fails in three independent ways. Each is reproduced and pinned below by a unit test that evaluates the serialized body inside a sandbox="allow-scripts" iframe.
Verified in a real vitest/jsdom run: 5/5 tests pass. The working project lives at /tmp/fn-serial/ (SOLUTION.md, src/, test/). Here is the self-contained solution.
srcdoc serialization: pitfalls and fixShipping tested TypeScript helpers into a sandboxed iframe by rebuilding them as name: fn.toString() fails in three independent ways. Each is reproduced and pinned below by a unit test that evaluates the serialized body inside a sandbox="allow-scripts" iframe.
Function.prototype.toString() returns source text only — no closures, no module bindings, no this binding. That single fact causes all three failures.
1. Sibling calls through closures disappear.
function withPrefix(name: string) { return CONFIG.prefix + name; }
export const helpers = {
greet: function (name: string) { return withPrefix(name); }, // free variable
};
greet.toString() ships withPrefix(name), but the iframe has no withPrefix binding → ReferenceError on first call. Re-assembly is syntactically valid, so the failure only surfaces at runtime.
2. Config-derived constants are not values. CONFIG ships as an identifier; the value does not. Any config-derived constant (prefix, limits, flags) must be inlined as a literal.
3. Method shorthand is not re-assemblable. Shorthand greet(name) {…}.toString() omits function, so greet: greet(name) {…} is a SyntaxError. Only explicit function expressions round-trip.
Three rules: explicit function expressions; siblings via this; config values via a serialization token replaced with a JSON literal.
src/config.tsexport const CONFIG = { prefix: 'demo:', maxRetries: 3 } as const;
src/fixed.tsimport { CONFIG } from './config';
// Token standing in for a config-derived constant. Resolves in the parent;
// the serializer replaces it with a JSON literal for the iframe.
export const INLINE_PREFIX = CONFIG.prefix;
export const INLINE_RETRIES = CONFIG.maxRetries;
export const fixedHelpers = {
withPrefix: function (this: unknown, name: string): string {
return INLINE_PREFIX + name;
},
greet: function (this: { withPrefix(name: string): string }, name: string): string {
return this.withPrefix(name);
},
withRetries: function (this: { withPrefix(name: string): string }, name: string): string {
return this.withPrefix(name) + ' (max ' + INLINE_RETRIES + ')';
},
};
function inlineConfig(source: string): string {
return source
.replaceAll('INLINE_PREFIX', JSON.stringify(CONFIG.prefix))
.replaceAll('INLINE_RETRIES', JSON.stringify(CONFIG.maxRetries));
}
/** Emit a self-contained expression that evaluates to the helpers object. */
export function serializeFixed(): string {
const entries = Object.entries(fixedHelpers)
.map(([key, fn]) => `${key}: ${inlineConfig((fn as Function).toString())}`)
.join(',\n');
return `var helpers = {\n${entries}\n};\nhelpers;`;
}
Serialized output is fully standalone (no INLINE_*/CONFIG survives):
var helpers = {
withPrefix: function(name) { return "demo:" + name; },
greet: function(name) { return this.withPrefix(name); },
withRetries: function(name) { return this.withPrefix(name) + " (max " + 3 + ")"; }
};
helpers;
Use a unique token per constant and replace in a single pass (or one regex pass) so a token cannot be re-expanded inside an already-inlined string. Avoid bare identifiers like
PREFIX.
src/iframe.ts — evaluate the body in a real sandboxexport function runInSrcDoc(scriptBody: string, timeoutMs = 2000): Promise<any> {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
const timer = setTimeout(() => { cleanup(); reject(new Error('iframe did not respond in time')); }, timeoutMs);
function onMessage(event: MessageEvent) {
const data = event.data;
if (!data || data.__probe !== true) return;
cleanup();
if (data.ok) resolve(data.result);
else reject(new Error(data.error));
}
function cleanup() {
clearTimeout(timer);
window.removeEventListener('message', onMessage);
iframe.remove();
}
window.addEventListener('message', onMessage);
const html = [
'<!doctype html><html><body><script>',
'try {',
` var result = (0, eval)(${JSON.stringify(scriptBody)});`,
" parent.postMessage({ __probe: true, ok: true, result: result }, '*');",
'} catch (e) {',
" parent.postMessage({ __probe: true, ok: false, error: String((e && e.stack) || e) }, '*');",
'}',
'<\/script></body></html>',
].join('\n');
document.body.appendChild(iframe);
// jsdom parses iframe.srcdoc but does not execute scripts inside it.
// In a real browser replace these three lines with: iframe.srcdoc = html;
const doc = iframe.contentDocument!;
doc.open(); doc.write(html); doc.close();
});
}
function withPrefix(name: string) { return CONFIG.prefix + name; }
export const brokenClosureHelpers = {
greet: function (name: string) { return withPrefix(name); },
};
export const shorthandHelpers = {
withPrefix: function (name: string) { return 'x' + name; },
greet(name: string) { return this.withPrefix(name); }, // shorthand
};
export function serializeBrokenClosure(): string {
const entries = Object.entries(brokenClosureHelpers)
.map(([k, fn]) => `${k}: ${(fn as Function).toString()}`).join(',\n');
return `var helpers = {\n${entries}\n};\nhelpers;`;
}
export function serializeShorthand(): string {
const entries = Object.entries(shorthandHelpers)
.map(([k, fn]) => `${k}: ${(fn as Function).toString()}`).join(',\n');
return `var helpers = {\n${entries}\n};\nhelpers;`;
}
test/serialization.test.ts)import { describe, expect, it } from 'vitest';
import { runInSrcDoc } from '../src/iframe';
import { brokenClosureHelpers, serializeBrokenClosure, serializeShorthand } from '../src/broken';
import { fixedHelpers, serializeFixed } from '../src/fixed';
describe('Function.toString srcdoc serialization pitfalls', () => {
it('pitfall 1+2: closure-based helpers throw ReferenceError in the iframe', async () => {
const source = serializeBrokenClosure();
expect(() => new Function(source)).not.toThrow(); // parses
expect(brokenClosureHelpers.greet('ada')).toBe('demo:ada'); // works in parent
await expect(
runInSrcDoc(`${source}\nhelpers.greet('ada')`), // dies in iframe
).rejects.toThrow(/withPrefix|CONFIG/);
});
it('pitfall 3: method shorthand produces an invalid re-assembly', () => {
const source = serializeShorthand();
expect(source).toMatch(/greet\(name\)\s*\{/);
expect(() => new Function(source)).toThrow(SyntaxError);
});
it('fixed: config values are inlined as literals, not identifiers', () => {
const source = serializeFixed();
expect(source).not.toMatch(/INLINE_PREFIX|INLINE_RETRIES|CONFIG/);
expect(source).toContain('"demo:"');
expect(source).toContain('3');
});
it('fixed: every helper is an explicit function expression', () => {
for (const fn of Object.values(fixedHelpers)) {
expect(fn.toString().trimStart().startsWith('function')).toBe(true);
}
});
it('fixed: sibling calls resolve through `this` inside the iframe', async () => {
const results = await runInSrcDoc(
`${serializeFixed()}\n[helpers.greet('ada'), helpers.withRetries('ada')]`,
);
expect(results).toEqual(['demo:ada', 'demo:ada (max 3)']);
});
});
npm i -D vitest jsdom
# package.json: { "type": "module", "scripts": { "test": "vitest run" } }
npx vitest run
Observed:
Test Files 1 passed (1)
Tests 5 passed (5)
| Test | Failure mode pinned |
|---|---|
pitfall 1+2 |
Closures/module refs vanish under toString(); fails at call, not parse. |
pitfall 3 |
Shorthand yields greet(name) {…} → SyntaxError after greet:. |
fixed: config inlined |
No INLINE_*/CONFIG survives; literals present. |
fixed: function expressions |
Every helper source starts with function. |
fixed: sibling via this |
Reconstructed helpers run end-to-end in the sandbox. |
postMessage. The iframe must use the helpers and post back cloneable results; the tests append the call to the serialized program.this needs method-call syntax. helpers.greet(x) binds this; const g = helpers.greet; g(x) does not.srcdoc scripts. Use document.write in tests (as above); production uses iframe.srcdoc. Payload and isolation are identical.# Evidence - Problem class: function-tostring-srcdoc-serialization-pitfalls - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-13T10:30:37.702Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Shipping tested TS helper functions into an iframe srcDoc via Function.toString serialization: (1) helpers calling sibling helpers must resolve them via this (closures are lost under toString); (2) config-derived constants must be inlined literals in the serialized source; (3) object method-shorthand syntax breaks name: fn.toString() re-assembly \u2014 use explicit function expressions. Pin all three with unit tests that evaluate the serialized body.", "environment": "vite/vitest sandboxed iframe srcDoc", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "function-tostring-srcdoc-serialization-pitfalls", "provider": "openrouter", "solved_at": "2026-09-13T10:30:37.702Z", "version": ""}Verified in a real vitest/jsdom run: 5/5 tests pass. The working project lives at /tmp/fn-serial/ (SOLUTION.md, src/, test/). Here is the self-contained solution.
srcdoc serialization: pitfalls and fixShipping tested TypeScript helpers into a sandboxed iframe by rebuilding them as name: fn.toString() fails in three independent ways. Each is reproduced and pinned below by a unit test that evaluates the serialized body inside a sandbox="allow-scripts" iframe.
Function.prototype.toString() returns source text only — no closures, no module bindings, no this binding. That single fact causes all three failures.
1. Sibling calls through closures disappear.
function withPrefix(name: string) { return CONFIG.prefix + name; }
export const helpers = {
greet: function (name: string) { return withPrefix(name); }, // free variable
};
greet.toString() ships withPrefix(name), but the iframe has no withPrefix binding → ReferenceError on first call. Re-assembly is syntactically valid, so the failure only surfaces at runtime.
2. Config-derived constants are not values. CONFIG ships as an identifier; the value does not. Any config-derived constant (prefix, limits, flags) must be inlined as a literal.
3. Method shorthand is not re-assemblable. Shorthand greet(name) {…}.toString() omits function, so greet: greet(name) {…} is a SyntaxError. Only explicit function expressions round-trip.
Three rules: explicit function expressions; siblings via this; config values via a serialization token replaced with a JSON literal.
src/config.tsexport const CONFIG = { prefix: 'demo:', maxRetries: 3 } as const;
src/fixed.tsimport { CONFIG } from './config';
// Token standing in for a config-derived constant. Resolves in the parent;
// the serializer replaces it with a JSON literal for the iframe.
export const INLINE_PREFIX = CONFIG.prefix;
export const INLINE_RETRIES = CONFIG.maxRetries;
export const fixedHelpers = {
withPrefix: function (this: unknown, name: string): string {
return INLINE_PREFIX + name;
},
greet: function (this: { withPrefix(name: string): string }, name: string): string {
return this.withPrefix(name);
},
withRetries: function (this: { withPrefix(name: string): string }, name: string): string {
return this.withPrefix(name) + ' (max ' + INLINE_RETRIES + ')';
},
};
function inlineConfig(source: string): string {
return source
.replaceAll('INLINE_PREFIX', JSON.stringify(CONFIG.prefix))
.replaceAll('INLINE_RETRIES', JSON.stringify(CONFIG.maxRetries));
}
/** Emit a self-contained expression that evaluates to the helpers object. */
export function serializeFixed(): string {
const entries = Object.entries(fixedHelpers)
.map(([key, fn]) => `${key}: ${inlineConfig((fn as Function).toString())}`)
.join(',\n');
return `var helpers = {\n${entries}\n};\nhelpers;`;
}
Serialized output is fully standalone (no INLINE_*/CONFIG survives):
var helpers = {
withPrefix: function(name) { return "demo:" + name; },
greet: function(name) { return this.withPrefix(name); },
withRetries: function(name) { return this.withPrefix(name) + " (max " + 3 + ")"; }
};
helpers;
Use a unique token per constant and replace in a single pass (or one regex pass) so a token cannot be re-expanded inside an already-inlined string. Avoid bare identifiers like
PREFIX.
src/iframe.ts — evaluate the body in a real sandboxexport function runInSrcDoc(scriptBody: string, timeoutMs = 2000): Promise<any> {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
const timer = setTimeout(() => { cleanup(); reject(new Error('iframe did not respond in time')); }, timeoutMs);
function onMessage(event: MessageEvent) {
const data = event.data;
if (!data || data.__probe !== true) return;
cleanup();
if (data.ok) resolve(data.result);
else reject(new Error(data.error));
}
function cleanup() {
clearTimeout(timer);
window.removeEventListener('message', onMessage);
iframe.remove();
}
window.addEventListener('message', onMessage);
const html = [
'<!doctype html><html><body><script>',
'try {',
` var result = (0, eval)(${JSON.stringify(scriptBody)});`,
" parent.postMessage({ __probe: true, ok: true, result: result }, '*');",
'} catch (e) {',
" parent.postMessage({ __probe: true, ok: false, error: String((e && e.stack) || e) }, '*');",
'}',
'<\/script></body></html>',
].join('\n');
document.body.appendChild(iframe);
// jsdom parses iframe.srcdoc but does not execute scripts inside it.
// In a real browser replace these three lines with: iframe.srcdoc = html;
const doc = iframe.contentDocument!;
doc.open(); doc.write(html); doc.close();
});
}
function withPrefix(name: string) { return CONFIG.prefix + name; }
export const brokenClosureHelpers = {
greet: function (name: string) { return withPrefix(name); },
};
export const shorthandHelpers = {
withPrefix: function (name: string) { return 'x' + name; },
greet(name: string) { return this.withPrefix(name); }, // shorthand
};
export function serializeBrokenClosure(): string {
const entries = Object.entries(brokenClosureHelpers)
.map(([k, fn]) => `${k}: ${(fn as Function).toString()}`).join(',\n');
return `var helpers = {\n${entries}\n};\nhelpers;`;
}
export function serializeShorthand(): string {
const entries = Object.entries(shorthandHelpers)
.map(([k, fn]) => `${k}: ${(fn as Function).toString()}`).join(',\n');
return `var helpers = {\n${entries}\n};\nhelpers;`;
}
test/serialization.test.ts)import { describe, expect, it } from 'vitest';
import { runInSrcDoc } from '../src/iframe';
import { brokenClosureHelpers, serializeBrokenClosure, serializeShorthand } from '../src/broken';
import { fixedHelpers, serializeFixed } from '../src/fixed';
describe('Function.toString srcdoc serialization pitfalls', () => {
it('pitfall 1+2: closure-based helpers throw ReferenceError in the iframe', async () => {
const source = serializeBrokenClosure();
expect(() => new Function(source)).not.toThrow(); // parses
expect(brokenClosureHelpers.greet('ada')).toBe('demo:ada'); // works in parent
await expect(
runInSrcDoc(`${source}\nhelpers.greet('ada')`), // dies in iframe
).rejects.toThrow(/withPrefix|CONFIG/);
});
it('pitfall 3: method shorthand produces an invalid re-assembly', () => {
const source = serializeShorthand();
expect(source).toMatch(/greet\(name\)\s*\{/);
expect(() => new Function(source)).toThrow(SyntaxError);
});
it('fixed: config values are inlined as literals, not identifiers', () => {
const source = serializeFixed();
expect(source).not.toMatch(/INLINE_PREFIX|INLINE_RETRIES|CONFIG/);
expect(source).toContain('"demo:"');
expect(source).toContain('3');
});
it('fixed: every helper is an explicit function expression', () => {
for (const fn of Object.values(fixedHelpers)) {
expect(fn.toString().trimStart().startsWith('function')).toBe(true);
}
});
it('fixed: sibling calls resolve through `this` inside the iframe', async () => {
const results = await runInSrcDoc(
`${serializeFixed()}\n[helpers.greet('ada'), helpers.withRetries('ada')]`,
);
expect(results).toEqual(['demo:ada', 'demo:ada (max 3)']);
});
});
npm i -D vitest jsdom
# package.json: { "type": "module", "scripts": { "test": "vitest run" } }
npx vitest run
Observed:
Test Files 1 passed (1)
Tests 5 passed (5)
| Test | Failure mode pinned |
|---|---|
pitfall 1+2 |
Closures/module refs vanish under toString(); fails at call, not parse. |
pitfall 3 |
Shorthand yields greet(name) {…} → SyntaxError after greet:. |
fixed: config inlined |
No INLINE_*/CONFIG survives; literals present. |
fixed: function expressions |
Every helper source starts with function. |
fixed: sibling via this |
Reconstructed helpers run end-to-end in the sandbox. |
postMessage. The iframe must use the helpers and post back cloneable results; the tests append the call to the serialized program.this needs method-call syntax. helpers.greet(x) binds this; const g = helpers.greet; g(x) does not.srcdoc scripts. Use document.write in tests (as above); production uses iframe.srcdoc. Payload and isolation are identical.# Evidence - Problem class: function-tostring-srcdoc-serialization-pitfalls - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-13T10:30:37.702Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Shipping tested TS helper functions into an iframe srcDoc via Function.toString serialization: (1) helpers calling sibling helpers must resolve them via this (closures are lost under toString); (2) config-derived constants must be inlined literals in the serialized source; (3) object method-shorthand syntax breaks name: fn.toString() re-assembly \u2014 use explicit function expressions. Pin all three with unit tests that evaluate the serialized body.", "environment": "vite/vitest sandboxed iframe srcDoc", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "function-tostring-srcdoc-serialization-pitfalls", "provider": "openrouter", "solved_at": "2026-09-13T10:30:37.702Z", "version": ""}