Page crash frames in the shared packages resolve to their source, not their dist (#576)
A page crash's frames inside `@text-to-cad/core` and `@text-to-cad/ui` now resolve in PostHog to the packages' source (`packages/*/src/**`, with its text), not to the compiled `dist/*.js` the pages import. ## Evidence On cadgen 0.7.17, a page-crash probe frame in the CAD Viewer's entry chunk (`index-CNGP9tWC.js`, line 3, column 12) resolved in PostHog (`resolved: true`) to `../../../../packages/core/dist/client/crash.js:41:31`. The release's maps were found and used, but they ended at `dist`. The cause: Rolldown reads no map a module names (`//# sourceMappingURL=`). It chains only a map that a plugin's `load` hook returns. The packages' library build already wrote a map, with `sourcesContent`, beside every `dist` module, and no page used it. ## What changed - **`@text-to-cad/core/source-maps`** (`packages/core/scripts/source-maps.mjs`): a Vite plugin that both pages' builds add for `@text-to-cad/core` and `@text-to-cad/ui`. - Each of their `dist` modules loads with its own map, sources made absolute. Rolldown then chains it under every later edit, so each chunk's map ends in `src/`. - A module the library build copies as it is (an `.mjs`; only `common/stepTopology.mjs` reaches a page) has no map of its own. It loads with an identity map to its twin in `src/`, column for column. - A chunk whose map still ends in a package's compiled code fails the build, so a regression shows up in CI, not in PostHog. Imported images are exempt: no frame is ever in one. - **The CAD app's worker inlining keeps its map.** It prepended an import line and returned `map: null`, which put the six modules that start workers one line off. It now edits with magic-string and returns that map. - No source aliases, and the packages are still consumed as compiled exports. The maps stay in `dist`, out of the wheel: `_runtime` has no `.map`. Every chunk's text is unchanged except its `//# debugId=` line. The 28 viewer chunks and 26 CAD app chunks that hold package code get new debug ids, because their maps changed. The rest keep the ids main gives them: the same text with the same map. - Docs: the core, web and CAD app READMEs, and a comment in `scripts/build/library.mjs`, which writes the maps the pages now depend on. ## Verification (resolved paths) Resolved with the `source-map` package (scratch script, outside the repo), positions 1-based: | Page chunk:line:col | Before (main) | After | | --- | --- | --- | | viewer `index-CNGP9tWC.js:3:10`–`12` | `packages/core/dist/client/crash.js:42:26` | `packages/core/src/client/crash.js:63:26` | | viewer `index-CNGP9tWC.js:3:13`–`14` | `packages/core/dist/client/crash.js:42:32` | `packages/core/src/client/crash.js:63:31` | | CAD app `index-CpUzQROK.js:10:16` | `packages/core/dist/client/crash.js:42:41` | `packages/core/src/client/crash.js:63:39` | All three land on `const chromium = lines.some(line => /^\s+at /.test(line));` in `crashOf`, the crash-signature code, with `sourcesContent`. PostHog's `41:31` is the 0-based form of main's `42:32`. Samples from chunks with package code, each a token found in the chunk and resolved: - core `.js`: viewer `GlbRenderer-DFf8XZ14.js:1:4193` `castShadow` → `packages/core/src/lib/render/glbScene.js:37:12` - core `.mjs`: viewer `surfaceMaterials-Biudp1of.js:171:3653` `NON_MANIFOLD` → `packages/core/src/common/stepTopology.mjs:27:3` - ui `.ts`: viewer `fileView-CrKf9XW9.js:12:5403` `requestAnimationFrame` → `packages/ui/src/renderers/kit/shell/liveBinding.ts:83:56`; CAD app `drawing-CG2XVurH.js:158:39650` `getContext` → `packages/ui/src/drawing/fill.ts:61:26` - ui `.tsx`: viewer `drawing-CB1Cs64-.js:159:43985` `background` → `packages/ui/src/drawing/index.tsx:201:25` - ui `.jsx`: CAD app `RobotRenderer-CoPUU5HD.js:1:14398` `cancelAnimationFrame` → `packages/ui/src/renderers/robot/PositionControls.jsx:122:43` Across every chunk a crash can name (the 174 viewer and 182 CAD app chunks in `__cadChunkIds`), each map now names all 417 package code files the page bundles, in `src/`. Main named them in `dist/`. By extension: core 133 `.js` and 1 `.mjs`; ui 190 `.js`, 61 `.jsx`, 22 `.ts` and 10 `.tsx`. None lacks `sourcesContent`. Every mapped package token of 10 or more word characters resolves onto a source line that contains it: - viewer: 14,284 of 14,284; - CAD app: 14,229 of 14,247. The other 18 are `__cadImport`, the CAD app's own rewrite of an `import(`, and they map to that `import(`. `scripts/release/sourcemaps.py collect` passes: `viewer: 174 chunks, cad-app: 182 chunks`. ## Accuracy (the CAD app) #570's script isn't in the repo, so this uses a stated metric and runs it on main and on this branch. Of the mapped positions where both the chunk text and the source text start a word (an identifier, keyword or number), it counts the share where the two words are equal: | | main | this PR | | --- | --- | --- | | CAD app, shipped maps (edited chunks, composed) | 50.72% of 1,079,808 | **50.73%** of 1,076,746 | | CAD app, Rolldown's own map of its unedited chunks | 50.45% | 50.39% | | CAD app, package tokens only (shipped) | 46.08%, against `dist` text | 46.05%, against `src` text | | Viewer, shipped maps | 50.66% | 50.65% | The relation #570 reported still holds: the composed maps are at or above Rolldown's own map of the same build. Package tokens are now compared with source text rather than compiled text. A sample of the positions that matched `dist` and no longer match `src` shows two kinds, both on the right source line: - most are tokens the compiler rewrote: `children` from JSX, `void 0` for `undefined`, `3e4` for `30_000`; - the rest land a few columns early, where esbuild maps a statement rather than each of its tokens (`} else if`, `export async function`). Without the worker-map fix, package tokens score 45.79%. ## Cost - **Build time.** Medians of 5 warm runs on an M-series Mac: - the viewer build goes from 1.82 s to 1.99 s (+0.17 s); - the CAD app build goes from 4.54 s to 5.00 s (+0.46 s). - **CI time.** The `mcp` job builds the CAD app once. `web` and `packaging` run `bundle.sh`, which builds both pages once. `core-js` gets 3 tests, about 10 ms. Against the 0.7.17 release PR's full run (37711526983), this PR's run (37718026111) stayed within runner noise: - `web`'s bundle step: 84 s against 81 s; - `packaging`'s bundle step: 24 s against 29 s; - `mcp`'s test-and-build step: 19 s against 29 s. - **CI selection.** The change touches `packages/core`, so this PR runs every job, as any core change does. - **Map size.** Named-chunk maps: - viewer: 27.9 MB to 28.4 MB; - CAD app: 28.7 MB to 29.1 MB; - the release's `cadgen-sourcemaps` zip: 21.9 MB to 22.5 MB; - the largest single map is unchanged (`vendor-three`, 3.4 MB). - **Page size.** The pages are the same code. The CAD app's `index.html` differs by 8 bytes, from gzipping the new debug ids. ## Tests - New, `packages/core/scripts/source-maps.test.mjs`, which reads core's built `dist`: - a compiled module loads with a map that sends the crash signature's position in `dist/client/crash.js` to its line and column in `src/client/crash.js`, with the source text; - a copied `.mjs` maps column for column to its twin; - the guard fails a chunk whose map ends in `dist` code. With the guard disabled, this test fails. - `scripts/test/test-js.sh`: - `--select core`: 1295 tests, 1 skipped, as on main; - `--select ui`: 751, plus 54 browser and 347 vitest; - `--select web`: 56; - `--select mcp`: 37, and its build. - `scripts/test/test-global.sh`: 210 tests, 1 skipped. - `scripts/bundle/bundle.sh --check`, the full `bundle.sh` and `scripts/github-workflows/check-builds.sh` pass. ## Not changed - **Worker bundles.** The viewer's worker bundles (`surfWorker`, `glbMeshWorker` and others) still map to `dist`. They aren't in `__cadChunkIds` and aren't uploaded, and a worker's crash never reaches the page's reporter. The CAD app inlines them and drops their maps. - **A column shift inside `dist`.** `library.mjs` rewrites relative import specifiers after esbuild, for example `./X.jsx` to `./X.js`, without updating esbuild's map. That moves only what follows a specifier on its own line: a `;` or `)`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a6b4a2f5c1
commit
af1e9aa435
+4
-2
@@ -118,8 +118,10 @@ reference host `basic-host` does.
|
||||
gave, and the page's own (`main.tsx`, its frames named by the chunks the inline loader
|
||||
made blob URLs of, and by each chunk's debug id), as their type and frames, never a message.
|
||||
The build keeps a source map of each chunk as the page runs it (`dist/sourcemaps`, which a
|
||||
release uploads to PostHog): every edit it makes to a chunk is folded into rolldown's map,
|
||||
each import's placeholder is as wide as the blob URL that replaces it, and the chunk's debug
|
||||
release uploads to PostHog), leading into the shared packages' own source, whose modules
|
||||
load with their maps (`@text-to-cad/core/source-maps`): every edit the build makes to a
|
||||
module or a chunk is folded into rolldown's map, each import's placeholder is as wide as the
|
||||
blob URL that replaces it, and the chunk's debug
|
||||
id is the one its edited text and map decide (`@text-to-cad/core/chunk-ids`), never the
|
||||
id of the unedited chunk the CAD Viewer may ship. It is on by default once
|
||||
a `cadgen` command has said so, once, and nothing asks. The app menu's **Share usage
|
||||
|
||||
+22
-13
@@ -6,6 +6,7 @@ import MagicString from 'magic-string';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { stampDebugId } from '@text-to-cad/core/chunk-ids';
|
||||
import { packageSourceMaps } from '@text-to-cad/core/source-maps';
|
||||
import { drawingAssetFiles, localizeDrawingFontFallback } from '@text-to-cad/ui/drawing-assets';
|
||||
|
||||
// An MCP App is one HTML resource with no asset directory behind it: the host renders it in a
|
||||
@@ -33,19 +34,25 @@ function inlineDrawingFonts() {
|
||||
}
|
||||
|
||||
// The shared packages start workers with `new Worker(new URL(file, import.meta.url))`. Here
|
||||
// that URL would point at the sandbox, so each one becomes Vite's inline (blob) worker.
|
||||
// that URL would point at the sandbox, so each one becomes Vite's inline (blob) worker. The
|
||||
// edit comes with its map (its imports push the module's code down a line), so that code keeps
|
||||
// its place in the page's map.
|
||||
const WORKER = /new Worker\(\s*new URL\(\s*(["'])([^"']+)\1\s*,\s*import\.meta\.url\s*\)\s*,\s*\{\s*type:\s*(["'])module\3\s*\}\s*\)/g;
|
||||
function inlineWorkers() {
|
||||
return {
|
||||
name: 'cad-app-inline-workers', enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!/\.[cm]?jsx?$/.test(id) || !code.includes('new Worker')) return;
|
||||
const edited = new MagicString(code);
|
||||
const imports = [];
|
||||
const result = code.replace(/new Worker\(\s*new URL\(\s*(["'])([^"']+)\1\s*,\s*import\.meta\.url\s*\)\s*,\s*\{\s*type:\s*(["'])module\3\s*\}\s*\)/g, (_, _quote, file) => {
|
||||
for (const match of code.matchAll(WORKER)) {
|
||||
const name = `__cadInlineWorker${imports.length}`;
|
||||
imports.push(`import ${name} from ${JSON.stringify(`${file}?worker&inline`)};`);
|
||||
return `new ${name}()`;
|
||||
});
|
||||
return imports.length ? { code: `${imports.join('\n')}\n${result}`, map: null } : undefined;
|
||||
imports.push(`import ${name} from ${JSON.stringify(`${match[2]}?worker&inline`)};`);
|
||||
edited.overwrite(match.index, match.index + match[0].length, `new ${name}()`);
|
||||
}
|
||||
if (!imports.length) return;
|
||||
edited.prepend(`${imports.join('\n')}\n`);
|
||||
return { code: edited.toString(), map: edited.generateMap({ hires: true }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -62,12 +69,14 @@ function inlineWorkers() {
|
||||
//
|
||||
// A crash on the page names the chunk it ran in by its debug id (`IDS`, as `__cadChunkIds`; the
|
||||
// crash reporter in @text-to-cad/core reads it), and the release uploads each chunk with its source
|
||||
// map (`dist/sourcemaps`, scripts/release/sourcemaps.py), so PostHog can show the source. The map
|
||||
// must fit the text the page runs: every edit here is made with its positions kept (magic-string)
|
||||
// and folded into rolldown's map, and a placeholder is as wide as the blob URL that replaces it
|
||||
// (`WIDTH`, padded with spaces a URL's parser drops), so swapping it moves no code. The id is the
|
||||
// one that text and its map decide (@text-to-cad/core/chunk-ids), never rolldown's: rolldown names
|
||||
// the code before these edits, which the CAD Viewer may ship unedited under the same id.
|
||||
// map (`dist/sourcemaps`, scripts/release/sourcemaps.py), so PostHog can show the source -- the
|
||||
// shared packages' own, since their modules load with their maps (@text-to-cad/core/source-maps)
|
||||
// and rolldown chains them. The map must fit the text the page runs: every edit here is made with
|
||||
// its positions kept (magic-string) and folded into rolldown's map, and a placeholder is as wide as
|
||||
// the blob URL that replaces it (`WIDTH`, padded with spaces a URL's parser drops), so swapping it
|
||||
// moves no code. The id is the one that text and its map decide (@text-to-cad/core/chunk-ids), never
|
||||
// rolldown's: rolldown names the code before these edits, which the CAD Viewer may ship unedited
|
||||
// under the same id.
|
||||
const CHUNK = 'cad-chunk:';
|
||||
const WIDTH = 192;
|
||||
const DYNAMIC_IMPORT = /\bimport\(\s*([`"'])\.\/([^`"'\s]+\.js)\1\s*\)/g;
|
||||
@@ -179,7 +188,7 @@ function inlineDocument() {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [inlineWorkers(), inlineDrawingFonts(), react(), inlineDocument()],
|
||||
plugins: [packageSourceMaps(['@text-to-cad/core', '@text-to-cad/ui']), inlineWorkers(), inlineDrawingFonts(), react(), inlineDocument()],
|
||||
resolve: { dedupe: ['react', 'react-dom', 'three', 'lucide-react'] },
|
||||
build: {
|
||||
assetsInlineLimit: Infinity, cssCodeSplit: false, modulePreload: false,
|
||||
|
||||
+3
-1
@@ -308,7 +308,9 @@ file shown is counted as it joins the library (`/__cad/recents`). A Quick Edit c
|
||||
through the host's `usage`, and the page's own crashes -- an error nothing caught, or one a view's
|
||||
boundary caught (`main.tsx`) -- are reported as core's `crashOf` makes them: the error's type and its
|
||||
script frames, never its message, each once a page. The build gives every chunk a hidden source map and
|
||||
a debug id its text and that map decide (`vite.config.mjs`, `@text-to-cad/core/chunk-ids`); the wheel
|
||||
a debug id its text and that map decide (`vite.config.mjs`, `@text-to-cad/core/chunk-ids`). A map leads
|
||||
into the shared packages' own source, not their compiled `dist`: their modules load with the maps their
|
||||
builds wrote (`@text-to-cad/core/source-maps`). The wheel
|
||||
leaves the maps out and a release uploads them to PostHog, and the page
|
||||
carries each chunk's id by file name (`__cadChunkIds`), and a frame names its chunk by it. The server holds all of it as counts in memory
|
||||
-- a file once a day, by its format, never its name -- and sends them every few minutes.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { stampDebugId } from "@text-to-cad/core/chunk-ids";
|
||||
import { packageSourceMaps } from "@text-to-cad/core/source-maps";
|
||||
import { drawingAssetsPlugin } from "@text-to-cad/ui/drawing-assets";
|
||||
|
||||
import { resolveDirectoryRoot as resolveViewerDirectoryRoot } from "./scripts/directoryRoot.mjs";
|
||||
@@ -185,9 +186,10 @@ function serverLifetimePlugin() {
|
||||
// Each chunk's debug id, by file name, in the page before anything runs (`__cadChunkIds`): a crash
|
||||
// report names the chunk each of its frames ran in by it (@text-to-cad/core's crash reporter), and
|
||||
// the release uploads every chunk with its source map (scripts/release/sourcemaps.py) for PostHog to
|
||||
// show the source. Each chunk goes by an id its text and its map decide (@text-to-cad/core/chunk-ids),
|
||||
// never rolldown's, which names the code alone. The maps stay in dist, which the wheel leaves out
|
||||
// (scripts/bundle).
|
||||
// show the source -- the shared packages' own source, whose maps the build chains
|
||||
// (@text-to-cad/core/source-maps), not their compiled dist. Each chunk goes by an id its text and its
|
||||
// map decide (@text-to-cad/core/chunk-ids), never rolldown's, which names the code alone. The maps stay
|
||||
// in dist, which the wheel leaves out (scripts/bundle).
|
||||
function chunkIdsPlugin() {
|
||||
return {
|
||||
name: "cad-viewer-chunk-ids",
|
||||
@@ -226,6 +228,7 @@ export default defineConfig(async ({ command }) => ({
|
||||
drawingAssetsPlugin({ exclude: [/\/fonts\/Xiaolai\//] }),
|
||||
react(),
|
||||
serverLifetimePlugin(),
|
||||
packageSourceMaps(["@text-to-cad/core", "@text-to-cad/ui"]),
|
||||
chunkIdsPlugin(),
|
||||
],
|
||||
resolve: { alias: { "@": viewerClientRoot }, dedupe: ["react", "react-dom", "three", "lucide-react"] },
|
||||
|
||||
@@ -169,8 +169,9 @@ src/
|
||||
# parity-tested against cad_ref_syntax.py)
|
||||
bin/ # node builders the bundler ships into _runtime/node:
|
||||
# mesh-export.mjs (the ONE mesh path)
|
||||
scripts/ # chunk-ids.mjs (the pages' builds: a chunk's debug id)
|
||||
# beside the package's own build and test runners
|
||||
scripts/ # chunk-ids.mjs (the pages' builds: a chunk's debug id) and
|
||||
# source-maps.mjs (the pages' builds: maps into the packages'
|
||||
# src/), beside the package's own build and test runners
|
||||
docs/ # subsystem docs (the map below)
|
||||
```
|
||||
|
||||
@@ -208,7 +209,10 @@ a frame in one of the page's own chunks also names its debug id, from the table
|
||||
page's build wrote, `__cadChunkIds`) -- and sends each distinct one once a page, a few
|
||||
at most. A page's build stamps each chunk with the id its text and its source map decide
|
||||
(`@text-to-cad/core/chunk-ids`, a Node module the builds import), never the bundler's,
|
||||
which names the code alone: PostHog keeps one map an id. `dispose()` stops polling, aborts
|
||||
which names the code alone: PostHog keeps one map an id. That map leads into the shared
|
||||
packages' `src/`, not their `dist/`: the build loads their modules with the maps their own
|
||||
builds wrote (`@text-to-cad/core/source-maps`), and fails if a chunk's map still ends in
|
||||
compiled code. `dispose()` stops polling, aborts
|
||||
requests and disposes render sessions. The client lazily owns its cache provider
|
||||
and bounded write-back queue; each render session borrows a cancellable cache
|
||||
view and owns its abort signal and worker leases. A host whose transport
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"types": "./dist/drawing/index.d.ts",
|
||||
"import": "./dist/drawing/index.js"
|
||||
},
|
||||
"./chunk-ids": "./scripts/chunk-ids.mjs"
|
||||
"./chunk-ids": "./scripts/chunk-ids.mjs",
|
||||
"./source-maps": "./scripts/source-maps.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
@@ -51,6 +52,7 @@
|
||||
"dist",
|
||||
"bin",
|
||||
"scripts/chunk-ids.mjs",
|
||||
"scripts/source-maps.mjs",
|
||||
"docs",
|
||||
"README.md"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* The shared packages' own source maps, for the CAD Viewer's and the CAD app's builds: a page's map leads a frame
|
||||
* in a package's code to that package's source (`src/`), with the source's text, and not to the compiled file in
|
||||
* `dist/` the page imports, so a crash there reaches PostHog as the source.
|
||||
*
|
||||
* A package's build writes a map beside each module in `dist/` (esbuild's, which keeps the source's text) and names
|
||||
* it on the module's last line (`//# sourceMappingURL=`). Rolldown reads no map a module names; it chains only the
|
||||
* map a plugin loads the module with. So a page's build loads each module of the packages it names with that map.
|
||||
* A module the package's build copied as it is (an `.mjs`) names none, and is its source: it loads with a map that
|
||||
* names the same line and column of its copy in `src/`. Once the chunks are made, a chunk whose map still ends in
|
||||
* a package's compiled code fails the build.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAP_LINE = /\n\/\/# sourceMappingURL=(\S+)\s*$/;
|
||||
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
function vlq(value) {
|
||||
let rest = value < 0 ? (-value << 1) | 1 : value << 1, text = '';
|
||||
do {
|
||||
const digit = rest & 31;
|
||||
rest >>>= 5;
|
||||
text += BASE64[rest ? digit | 32 : digit];
|
||||
} while (rest);
|
||||
return text;
|
||||
}
|
||||
|
||||
// A map that sends every line and column of `code` to the same place in `source`, which `code` is a copy of.
|
||||
function identityMap(code, source) {
|
||||
let mappings = '', line = 0, column = 0; // where the previous segment points
|
||||
for (const [index, text] of code.split('\n').entries()) {
|
||||
if (index) mappings += ';';
|
||||
for (let at = 0; at < text.length; at++) {
|
||||
mappings += `${at ? ',' : ''}${vlq(at ? 1 : 0)}A${vlq(index - line)}${vlq(at - column)}`;
|
||||
line = index;
|
||||
column = at;
|
||||
}
|
||||
}
|
||||
return { version: 3, sources: [source], sourcesContent: [code], names: [], mappings };
|
||||
}
|
||||
|
||||
/**
|
||||
* A module of a package's `dist/`, as the page's build loads it: its code, and the map that leads it to its source,
|
||||
* the one its build wrote (its sources made absolute) or, for a copy of its source, the identity. `null` for a
|
||||
* module that is neither.
|
||||
*
|
||||
* @param {string} file The module, an absolute path.
|
||||
* @param {string} dist The package's `dist/`.
|
||||
* @param {string} src The package's `src/`.
|
||||
* @returns {{ code: string, map: object } | null}
|
||||
*/
|
||||
export function loadWithMap(file, dist, src) {
|
||||
const code = fs.readFileSync(file, 'utf8');
|
||||
const named = MAP_LINE.exec(code);
|
||||
if (named) {
|
||||
const mapFile = path.resolve(path.dirname(file), named[1]);
|
||||
const { sourceRoot = '', ...map } = JSON.parse(fs.readFileSync(mapFile, 'utf8'));
|
||||
const base = path.resolve(path.dirname(mapFile), sourceRoot);
|
||||
return { code: code.slice(0, named.index + 1), map: { ...map, sources: map.sources.map(source => path.resolve(base, source)) } };
|
||||
}
|
||||
const twin = path.join(src, path.relative(dist, file));
|
||||
return fs.existsSync(twin) && fs.readFileSync(twin, 'utf8') === code ? { code, map: identityMap(code, twin) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Vite plugin a page's build adds, before any other that loads a module.
|
||||
*
|
||||
* @param {string[]} packages The packages whose maps the page chains, by name; the page resolves each.
|
||||
*/
|
||||
export function packageSourceMaps(packages) {
|
||||
let roots = [];
|
||||
const rootOf = file => roots.find(({ dist }) => file.startsWith(dist + path.sep));
|
||||
return {
|
||||
name: 'cad-package-source-maps',
|
||||
apply: 'build',
|
||||
enforce: 'pre',
|
||||
configResolved(config) {
|
||||
const require = createRequire(path.join(config.root, 'package.json'));
|
||||
roots = packages.map(name => {
|
||||
const root = fs.realpathSync(path.dirname(require.resolve(`${name}/package.json`)));
|
||||
return { name, dist: path.join(root, 'dist'), src: path.join(root, 'src') };
|
||||
});
|
||||
},
|
||||
load: {
|
||||
filter: { id: /[\\/]dist[\\/][^?]*\.m?js$/ },
|
||||
handler(id) {
|
||||
const file = path.resolve(id);
|
||||
const root = rootOf(file);
|
||||
return root ? loadWithMap(file, root.dist, root.src) : null;
|
||||
},
|
||||
},
|
||||
generateBundle: {
|
||||
order: 'pre',
|
||||
handler(options, bundle) {
|
||||
const out = options.dir ?? path.dirname(options.file);
|
||||
const compiled = [];
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type !== 'chunk' || !chunk.map) continue;
|
||||
for (const source of chunk.map.sources) {
|
||||
const file = path.resolve(out, path.dirname(chunk.fileName), source);
|
||||
const root = rootOf(file);
|
||||
// Code only: an image a module imports is named too, and no frame is ever in one.
|
||||
if (root && /\.m?js$/.test(file)) {
|
||||
compiled.push(`${chunk.fileName}: ${root.name}/${path.relative(path.dirname(root.dist), file).split(path.sep).join('/')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (compiled.length) this.error(`A chunk's map ends in a package's compiled code, not its source:\n ${compiled.join('\n ')}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { SourceMap } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadWithMap, packageSourceMaps } from './source-maps.mjs';
|
||||
|
||||
// This package as its build left it (the test runs after `npm run build`).
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const dist = path.join(root, 'dist');
|
||||
const src = path.join(root, 'src');
|
||||
|
||||
// The line and column (0-based) at which `needle` starts in `text`.
|
||||
function placeOf(text, needle) {
|
||||
const before = text.slice(0, text.indexOf(needle)).split('\n');
|
||||
return [before.length - 1, before.at(-1).length];
|
||||
}
|
||||
|
||||
test('a compiled module loads with the map that sends it back to its source, with the source text', () => {
|
||||
const source = path.join(src, 'client/crash.js');
|
||||
const { code, map } = loadWithMap(path.join(dist, 'client/crash.js'), dist, src);
|
||||
assert.doesNotMatch(code, /sourceMappingURL/);
|
||||
assert.deepEqual(map.sources, [source]);
|
||||
assert.equal(map.sourcesContent[0], fs.readFileSync(source, 'utf8'));
|
||||
// The crash signature's test for a Chromium stack, the code a page crash's probe frame named in PostHog.
|
||||
const signature = '/^\\s+at /';
|
||||
const found = new SourceMap(map).findEntry(...placeOf(code, signature));
|
||||
assert.deepEqual([found.originalSource, found.originalLine, found.originalColumn],
|
||||
[source, ...placeOf(map.sourcesContent[0], signature)]);
|
||||
});
|
||||
|
||||
test('a module the build copied as it is loads as its source, line for line and column for column', () => {
|
||||
const source = path.join(src, 'common/stepTopology.mjs');
|
||||
const { code, map } = loadWithMap(path.join(dist, 'common/stepTopology.mjs'), dist, src);
|
||||
assert.equal(code, fs.readFileSync(source, 'utf8'));
|
||||
const lines = code.trimEnd().split('\n');
|
||||
const last = [lines.length - 1, lines.at(-1).length - 1];
|
||||
for (const [line, column] of [[0, 0], placeOf(code, 'export function'), last]) {
|
||||
const found = new SourceMap(map).findEntry(line, column);
|
||||
assert.deepEqual([found.originalSource, found.originalLine, found.originalColumn], [source, line, column]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a page's build fails when a chunk's map still ends in a package's compiled code", () => {
|
||||
const plugin = packageSourceMaps(['@text-to-cad/core']);
|
||||
plugin.configResolved({ root });
|
||||
const out = path.join(root, 'tmp-out');
|
||||
const chunk = sources => ({ type: 'chunk', fileName: 'assets/index-A.js', map: { sources } });
|
||||
const fail = message => { throw new Error(message); };
|
||||
const relative = file => path.relative(path.join(out, 'assets'), file).split(path.sep).join('/');
|
||||
// Its source, and an image a module imports (no frame is ever in one), pass.
|
||||
plugin.generateBundle.handler.call({ error: fail }, { dir: out },
|
||||
{ 'index-A.js': chunk([relative(path.join(src, 'client/crash.js')), relative(path.join(dist, 'logo.svg'))]) });
|
||||
assert.throws(() => plugin.generateBundle.handler.call({ error: fail }, { dir: out },
|
||||
{ 'index-A.js': chunk([relative(path.join(dist, 'client/crash.js'))]) }),
|
||||
/assets\/index-A\.js: @text-to-cad\/core\/dist\/client\/crash\.js/);
|
||||
});
|
||||
@@ -25,6 +25,8 @@ export async function buildLibrary(root, { css = false } = {}) {
|
||||
}
|
||||
await walk(src);
|
||||
await fs.rm(dist, { recursive: true, force: true });
|
||||
// Each module's map, with its source's text, is what a page's build chains to show a crash
|
||||
// in this code as its source (@text-to-cad/core/source-maps).
|
||||
await build({entryPoints: entries, outbase: src, outdir: dist, bundle: false,
|
||||
format: 'esm', platform: 'neutral', target: 'es2022', jsx: 'automatic',
|
||||
loader: { '.js': 'jsx' }, sourcemap: true, logLevel: 'warning'});
|
||||
|
||||
Reference in New Issue
Block a user