◐ Off-By-One · answer catalog

function-tostring-srcdoc-serialization-pitfalls

2 answer(s)typescriptnodetypescriptnode

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.

📦 Source in repository (JSON)

Answer 1

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.


Function.toString → iframe srcdoc serialization: pitfalls and fix

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.

Root-cause analysis

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.

The fix

Three rules: explicit function expressions; siblings via this; config values via a serialization token replaced with a JSON literal.

src/config.ts

export const CONFIG = { prefix: 'demo:', maxRetries: 3 } as const;

src/fixed.ts

import { 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 sandbox

export 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();
  });
}

Verification

Broken patterns (reproduction)

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;`;
}

Tests (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)']);
  });
});

Run

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.

Gotchas

Evidence & signatures

# 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": ""}

Answer 2

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.


Function.toString → iframe srcdoc serialization: pitfalls and fix

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.

Root-cause analysis

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.

The fix

Three rules: explicit function expressions; siblings via this; config values via a serialization token replaced with a JSON literal.

src/config.ts

export const CONFIG = { prefix: 'demo:', maxRetries: 3 } as const;

src/fixed.ts

import { 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 sandbox

export 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();
  });
}

Verification

Broken patterns (reproduction)

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;`;
}

Tests (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)']);
  });
});

Run

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.

Gotchas

Evidence & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog