diff --git a/package-lock.json b/package-lock.json index e05c92944..772a5fb30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -769,6 +769,16 @@ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bl": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.2.tgz", @@ -1092,7 +1102,11 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } } } }, @@ -1150,6 +1164,13 @@ "integrity": "sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==", "requires": { "source-map": "~0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } } }, "cli-cursor": { @@ -2126,6 +2147,17 @@ "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", + "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + } } }, "fast-deep-equal": { @@ -2179,6 +2211,13 @@ "flat-cache": "^2.0.1" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -2386,15 +2425,6 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, - "get-stream": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", - "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, "get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -3610,6 +3640,13 @@ "thenify-all": "^1.0.0" } }, + "nan": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", + "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==", + "dev": true, + "optional": true + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -4994,6 +5031,14 @@ "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } } }, "source-map-url": { @@ -5441,6 +5486,14 @@ "commander": "^2.20.0", "source-map": "~0.6.1", "source-map-support": "~0.5.12" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } } }, "terser-webpack-plugin": { @@ -5459,6 +5512,14 @@ "terser": "^4.0.0", "webpack-sources": "^1.3.0", "worker-farm": "^1.7.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } } }, "text-table": { @@ -5622,6 +5683,13 @@ "requires": { "commander": "~2.20.0", "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } } }, "unbzip2-stream": { @@ -5862,6 +5930,14 @@ "requires": { "source-list-map": "^2.0.0", "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } } }, "which": { diff --git a/package.json b/package.json index 39da97557..152265c94 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "html-minifier": "^4.0.0", "http-link-header": "^1.0.2", "shimport": "^1.0.1", + "source-map": "^0.6.1", "sourcemap-codec": "^1.4.6", "string-hash": "^1.1.3" }, diff --git a/runtime/src/server/middleware/get_page_handler.ts b/runtime/src/server/middleware/get_page_handler.ts index da086d78f..3dfc51067 100644 --- a/runtime/src/server/middleware/get_page_handler.ts +++ b/runtime/src/server/middleware/get_page_handler.ts @@ -6,6 +6,7 @@ import devalue from 'devalue'; import fetch from 'node-fetch'; import URL from 'url'; import { Manifest, Page, Req, Res } from './types'; +import { sourcemap_stacktrace } from './sourcemap_stacktrace'; import { build_dir, dev, src_dir } from '@sapper/internal/manifest-server'; import App from '@sapper/internal/App.svelte'; @@ -233,6 +234,10 @@ export function get_page_handler( l++; }); + if (error instanceof Error && error.stack) { + error.stack = sourcemap_stacktrace(error.stack); + } + const props = { stores: { page: { diff --git a/runtime/src/server/middleware/sourcemap_stacktrace.ts b/runtime/src/server/middleware/sourcemap_stacktrace.ts new file mode 100644 index 000000000..f845cc61d --- /dev/null +++ b/runtime/src/server/middleware/sourcemap_stacktrace.ts @@ -0,0 +1,96 @@ +import fs from 'fs'; +import path from 'path'; +import { SourceMapConsumer, RawSourceMap } from 'source-map'; + +function get_sourcemap_url(contents: string) { + const reversed = contents + .split('\n') + .reverse() + .join('\n'); + + const match = /\/[/*]#[ \t]+sourceMappingURL=([^\s'"]+?)(?:[ \t]+|$)/gm.exec(reversed); + if (match) return match[1]; + + return undefined; +} + +const file_cache = new Map(); + +function get_file_contents(path: string) { + if (file_cache.has(path)) { + return file_cache.get(path); + } + + try { + const data = fs.readFileSync(path, 'utf8'); + file_cache.set(path, data); + return data; + } catch { + return undefined; + } +} + +export function sourcemap_stacktrace(stack: string) { + const replace = (line: string) => + line.replace( + /^ {4}at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?)\)?/, + (input, var_name, file_path, line, column) => { + if (!file_path) return input; + + const contents = get_file_contents(file_path); + if (!contents) return input; + + const sourcemap_url = get_sourcemap_url(contents); + if (!sourcemap_url) return input; + + let dir = path.dirname(file_path); + let sourcemap_data: string; + + if (/^data:application\/json[^,]+base64,/.test(sourcemap_url)) { + const raw_data = sourcemap_url.slice(sourcemap_url.indexOf(',') + 1); + try { + sourcemap_data = Buffer.from(raw_data, 'base64').toString(); + } catch { + return input; + } + } else { + const sourcemap_path = path.resolve(dir, sourcemap_url); + const data = get_file_contents(sourcemap_path); + + if (!data) return input; + + sourcemap_data = data; + dir = path.dirname(sourcemap_path); + } + + let raw_sourcemap: RawSourceMap; + try { + raw_sourcemap = JSON.parse(sourcemap_data); + } catch { + return input; + } + + const consumer = new SourceMapConsumer(raw_sourcemap); + const pos = consumer.originalPositionFor({ + line: Number(line), + column: Number(column), + bias: SourceMapConsumer.LEAST_UPPER_BOUND + }); + + if (!pos.source) return input; + + const source_path = path.resolve(dir, pos.source); + const source = `${source_path}:${pos.line || 0}:${pos.column || 0}`; + + if (!var_name) return ` at ${source}`; + return ` at ${var_name} (${source})`; + } + ); + + file_cache.clear(); + + return stack + .split('\n') + .map(replace) + .join('\n'); +} diff --git a/test/apps/errors/rollup.config.js b/test/apps/errors/rollup.config.js index ce8c3a113..c9b43e690 100644 --- a/test/apps/errors/rollup.config.js +++ b/test/apps/errors/rollup.config.js @@ -27,7 +27,7 @@ export default { server: { input: config.server.input(), - output: config.server.output(), + output: Object.assign({}, config.server.output(), { sourcemap: true }), plugins: [ replace({ 'process.browser': false, diff --git a/test/apps/errors/src/routes/_error.svelte b/test/apps/errors/src/routes/_error.svelte index d76724e3e..7f674bdd2 100644 --- a/test/apps/errors/src/routes/_error.svelte +++ b/test/apps/errors/src/routes/_error.svelte @@ -15,3 +15,5 @@

{mounted}

{error.message}

+ +{error.stack} diff --git a/test/apps/errors/src/routes/_trace.js b/test/apps/errors/src/routes/_trace.js new file mode 100644 index 000000000..b51b46286 --- /dev/null +++ b/test/apps/errors/src/routes/_trace.js @@ -0,0 +1,3 @@ +export function oops() { + throw new Error('oops'); +} diff --git a/test/apps/errors/src/routes/index.svelte b/test/apps/errors/src/routes/index.svelte index 5ccc389a2..6c6c7a395 100644 --- a/test/apps/errors/src/routes/index.svelte +++ b/test/apps/errors/src/routes/index.svelte @@ -3,4 +3,5 @@ nope blog/nope throw -preload-reject \ No newline at end of file +preload-reject +trace diff --git a/test/apps/errors/src/routes/trace.svelte b/test/apps/errors/src/routes/trace.svelte new file mode 100644 index 000000000..734db07c5 --- /dev/null +++ b/test/apps/errors/src/routes/trace.svelte @@ -0,0 +1,7 @@ + diff --git a/test/apps/errors/test.ts b/test/apps/errors/test.ts index f54c15cbf..d83ecec94 100644 --- a/test/apps/errors/test.ts +++ b/test/apps/errors/test.ts @@ -71,6 +71,15 @@ describe('errors', function() { ); }); + it('display correct stack trace sequences on server error referring to source file', async () => { + await r.load('/trace'); + + const stack = (await r.text('span')).split('\n'); + + assert.ok(stack[1] && stack[1].includes('_trace.js:2:11')); + assert.ok(stack[2] && stack[2].includes('trace.svelte:5:6')); + }); + it('handles error on client', async () => { await r.load('/'); await r.sapper.start();