Parsing Safari/WebKit Stack Trace Format

Safari’s JavaScriptCore engine emits Error.stack in a format that is superficially similar to Firefox’s SpiderMonkey but contains three divergences that systematically break parsers written against V8 or SpiderMonkey output. Handling them requires engine-specific logic inside your stack parser. This page covers the exact quirks, shows a production-ready parser, and integrates with the column-offset normalization described in Cross-Browser Source Map and Stack Compatibility and the broader Source Map Generation & Stack Trace Debugging pipeline.

Safari JavaScriptCore Error.stack Format and Column Normalization A Safari Error.stack string showing: no Error header line, function@url:line:col frame format, global code sentinel for top-level frames, and the 1-based column that must be decremented by 1 before SourceMapConsumer lookup in Safari versions prior to 16. Raw Safari Error.stack (no "Error:" header) [email protected]:1:9001 [email protected]:1:450 global [email protected]:1:1 ^ cols are 1-based (pre-Safari 16) ^ "global code" = top-level frame parseJSCStack() NormalizedFrame[] { fn: 'renderApp', file: 'app.js', line: 1, col: 9000 } ← col − 1 { fn: '<global>', col: 0 } SourceMapConsumer originalPositionFor({ line, column }) Skipping col − 1 → wrong symbol in minified bundle

Symptom / Trigger

Safari users appear with unresolved minified frames in your error dashboard while the identical error symbolicates correctly for Chrome and Firefox users. Inspecting the raw event shows a stack string like this:

renderComponent@https://example.com/app.abc123.js:1:9433
handleClick@https://example.com/app.abc123.js:1:3201
global code@https://example.com/app.abc123.js:1:1

Notice what is missing: there is no TypeError: Cannot read properties of null header line before the first frame. The global code entry at the bottom has no V8 or SpiderMonkey equivalent. And the column numbers are 1-based, not 0-based.

Feeding these frames to a V8-style parser yields zero matches because no line starts with at . Feeding them to a SpiderMonkey parser extracts the frames but passes column 9433 directly to SourceMapConsumer.originalPositionFor, which expects column 9432. In a minified single-line bundle this resolves to the wrong token — symbolication returns an incorrect function name with no indication that anything went wrong.

Root Cause Explanation

JavaScriptCore has three independent deviations from V8 and SpiderMonkey conventions.

No Error: header line. When a TypeError or RangeError is thrown in global scope, JavaScriptCore omits the first Error: message line that V8 always includes. Parsers that call .slice(1) to skip the V8 header on a JSC stack discard the first real frame.

global code sentinel. Frames that execute at the top level of a module or script (outside any function) receive the synthetic function name global code in JSC. This is not a reserved keyword or a special construct — it is just a string that JSC injects. V8 would produce <anonymous> for these frames; SpiderMonkey produces an empty function name field.

1-based columns in Safari ≤ 15. SourceMapConsumer.originalPositionFor uses the same convention as Error.stack in V8 and SpiderMonkey: lines are 1-based, columns are 0-based. JSC violated this convention through Safari 15, reporting columns starting from 1. Safari 16 aligned with the 0-based convention, but because you cannot determine the Safari major version from the stack string alone, you must derive it from the User-Agent header.

// Broken pattern — passes raw JSC column without offset correction
function parseBrokenJSCFrame(line) {
  const m = line.match(/^(.*?)@(.*):(\d+):(\d+)$/);
  if (!m) return null;
  return { fn: m[1], file: m[2], line: +m[3], col: +m[4] }; // col is 1-based on Safari ≤ 15
}
// consumer.originalPositionFor({ line: 1, column: 9433 }) → wrong symbol

Step-by-Step Fix

1. Detect a JSC stack string

// Returns true if the stack was produced by JavaScriptCore (Safari / WebKit)
export function isJSCStack(stack: string): boolean {
  if (!stack) return false;
  // JSC uses @ delimiter and never starts with "Error:" in its canonical form
  // SpiderMonkey also uses @ but inserts "async*" markers or an "Error:" header
  const hasAtDelimiter = stack.includes('@');
  const hasV8AtPrefix  = /\n\s+at /.test(stack);
  const hasAsyncMarker = stack.includes('async*');
  return hasAtDelimiter && !hasV8AtPrefix && !hasAsyncMarker;
}

This is conservative: it excludes SpiderMonkey by requiring the absence of async* markers and at –prefixed lines. Apply this check inside a broader engine-detection function rather than calling it on every frame individually.

2. Determine the column offset from User-Agent

On a server-side ingestion endpoint you have access to the HTTP User-Agent header of the originating request. Parse it to derive the Safari major version:

// Minimal Safari major-version extractor — no external library needed
export function safariMajorVersion(userAgent: string): number | null {
  // Safari UA: "... Version/16.5 Safari/605.1.15"
  const m = userAgent.match(/Version\/(\d+)\.\d+.*Safari\//);
  return m ? parseInt(m[1], 10) : null;
}

// Returns true when JSC columns are already 0-based (Safari 16+)
export function jsColumnIsZeroBased(userAgent: string): boolean {
  const major = safariMajorVersion(userAgent);
  return major !== null && major >= 16;
}

If you cannot access the UA (e.g., the stack was captured in a service worker and the UA was not forwarded), default to false — treat columns as 1-based. A systematic one-column over-correction on Safari 16+ (shifting already-0-based columns to -1, clamped to 0) is less harmful than a systematic under-correction on Safari 15 (passing 1-based columns unchanged).

3. Parse each JSC frame

const JSC_FRAME_RE = /^(.*?)@(https?:\/\/.+):(\d+):(\d+)$/;

export interface NormalizedFrame {
  fn:      string;
  file:    string;
  line:    number;   // 1-based (unchanged from Error.stack)
  col:     number;   // 0-based (normalized in this function)
  isGlobal: boolean;
}

export function parseJSCStack(
  stack: string,
  zeroBased: boolean   // true for Safari 16+
): NormalizedFrame[] {
  return stack
    .split('\n')
    .map((raw): NormalizedFrame | null => {
      const trimmed = raw.trim();
      if (!trimmed) return null;

      // Skip any "ErrorType: message" line that some JSC versions include
      if (/^\w+Error:/.test(trimmed) && !trimmed.includes('@')) return null;

      const m = trimmed.match(JSC_FRAME_RE);
      if (!m) return null;

      const rawCol = parseInt(m[4], 10);
      const col    = zeroBased ? rawCol : Math.max(0, rawCol - 1); // normalize

      return {
        fn:       m[1] === 'global code' ? '<global>' : (m[1] || '<anonymous>'),
        file:     m[2],
        line:     parseInt(m[3], 10),
        col,
        isGlobal: m[1] === 'global code',
      };
    })
    .filter((f): f is NormalizedFrame => f !== null);
}

The isGlobal flag lets downstream logic suppress <global> frames from user-facing error reports while still preserving them for full-fidelity debugging.

4. Handle the missing header edge case

When the error is thrown in a try/catch block rather than at the top level, some JSC versions include the Error: message header. The header-skip condition in step 3 handles this, but you should also guard against the inverse: a frame whose function name contains a colon (e.g., a URL accidentally matched as a function name).

// Guard: reject frames where the "file" field doesn't look like a URL
function looksLikeUrl(s: string): boolean {
  return /^https?:\/\//.test(s);
}

// After parsing, filter out malformed entries
const frames = parseJSCStack(rawStack, zeroBased)
  .filter(f => looksLikeUrl(f.file));

This eliminates mismatched captures when an error message contains @ and ends up being parsed as a frame line.

5. Feed normalized frames to SourceMapConsumer

import { SourceMapConsumer } from 'source-map';

export async function symbolicateJSCFrames(
  frames: NormalizedFrame[],
  getMap: (fileUrl: string) => Promise<object>
): Promise<NormalizedFrame[]> {
  const cache = new Map<string, SourceMapConsumer>();

  const out: NormalizedFrame[] = [];
  for (const frame of frames) {
    const mapUrl = frame.file + '.map';
    let consumer = cache.get(mapUrl);
    if (!consumer) {
      consumer = await new SourceMapConsumer(await getMap(mapUrl) as any);
      cache.set(mapUrl, consumer);
    }

    const orig = consumer.originalPositionFor({
      line:   frame.line,  // 1-based — correct as-is
      column: frame.col,   // 0-based after normalization above
    });

    out.push({
      ...frame,
      fn:   orig.name   ?? frame.fn,
      file: orig.source  ?? frame.file,
      line: orig.line    ?? frame.line,
      col:  orig.column  ?? frame.col,
    });
  }

  for (const c of cache.values()) c.destroy();
  return out;
}

Verification

Trigger a controlled throw in Safari, capture the raw Error.stack, and assert that your parser produces the expected 0-based column:

// verify-jsc-parser.test.ts
import { describe, it, expect } from 'vitest';
import { parseJSCStack } from './jsc-parser';

// Fixture from Safari 15 (1-based columns)
const SAFARI_15_STACK = [
  'renderApp@https://example.com/app.abc123.js:1:9433',
  'bootstrap@https://example.com/app.abc123.js:1:451',
  'global code@https://example.com/app.abc123.js:1:1',
].join('\n');

describe('parseJSCStack', () => {
  it('converts Safari 15 columns from 1-based to 0-based', () => {
    const frames = parseJSCStack(SAFARI_15_STACK, false /* Safari 15 */);
    expect(frames[0]).toMatchObject({ fn: 'renderApp',  col: 9432 }); // 9433 → 9432
    expect(frames[1]).toMatchObject({ fn: 'bootstrap',  col: 450  }); // 451  → 450
    expect(frames[2]).toMatchObject({ fn: '<global>',   col: 0    }); // 1    → 0
    expect(frames[2].isGlobal).toBe(true);
  });

  it('leaves Safari 16 columns unchanged', () => {
    const frames = parseJSCStack(SAFARI_15_STACK, true /* Safari 16+ */);
    expect(frames[0].col).toBe(9433); // no adjustment
  });

  it('skips Error: header line when present', () => {
    const withHeader = 'TypeError: null is not an object\n' + SAFARI_15_STACK;
    const frames = parseJSCStack(withHeader, false);
    expect(frames[0].fn).toBe('renderApp'); // header not treated as a frame
  });
});

Run npx vitest run verify-jsc-parser.test.ts. All three cases passing confirms that column normalization, <global> mapping, and header-skipping behave correctly before you wire this into production ingestion.

Edge Cases & Gotchas

  • Safari extensions and JavaScriptCore-based WebViews — WKWebView and iOS Safari both use JSC, but iOS Safari before iOS 16 behaves identically to desktop Safari 15 (1-based columns). WKWebView UA strings do not always include Version/NN, so safariMajorVersion() may return null. Default to 1-based in that case.
  • @ in function names — Though rare, minifiers can produce property-accessor expressions that contain @ in their stringified form (template literal tags, decorator metadata). The JSC_FRAME_RE anchors the URL portion with https?://, which prevents the right-most @ from being misidentified as the frame delimiter in the vast majority of cases. If you use protocol-relative URLs, expand the regex to cover // prefixes.
  • Inline scripts and <anonymous> URLs — JSC may produce fn@[native code]:0:0 for built-ins or fn@undefined:0:0 when a source URL is not attached to a script tag. These frames cannot be symbolicated; log them as native and skip the SourceMapConsumer lookup to avoid a fetch error.
  • Multi-frame same-line collisions — In minified bundles, multiple JSC frames can share line: 1. The column is the only discriminator. A one-column error propagates to a different symbol. This is why column normalization is not optional — it is the primary differentiator between correct and incorrect symbolication on single-line bundles.

FAQ

Why doesn’t Safari 16 need column adjustment?

Apple aligned JavaScriptCore with the ECMAScript convention (0-based columns in Error.stack) starting in Safari 16. Versions before 16 used 1-based columns in Error.stack while SourceMapConsumer expected 0-based — a gap that produced systematically off-by-one symbolication.

My error tracker already parses stacks. Do I still need this?

Only if your tracker handles JSC column offsets correctly. Many open-source parsers (including older versions of the error-stack-parser npm package) do not subtract 1 from JSC columns. Check your tracker’s source or test it with a Safari 15 UA and verify the resolved function name matches the actual throw site in your source.

How do I test this without a real Safari device?

Construct synthetic stack strings that match the JSC format (shown in the fixture above) and run them through your parser in any Node.js environment. The column-offset logic is pure arithmetic — it does not require a browser. For full end-to-end symbolication testing, use a real .map file from a known build and assert that originalPositionFor returns the expected source location.