bun-plugin-civet-sourcemaps 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # bun-plugin-civet-sourcemaps
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Unpublished 0.0.1 prototype** — a Bun bundler plugin that preserves original `.civet` locations after Civet → TypeScript/TSX → JavaScript compilation.
4
+
5
+ This is a real Bun `BunPlugin`, not a wrapper around `Bun.build()`. It uses `onLoad` to compile Civet with an inline source map and `onEnd` to compose Bun's output source maps with each Civet input map. No separate postbuild command or source-map library is needed.
6
+
7
+ ## Quick start
8
+
9
+ Install the package (from the accompanying local tarball until it is published) and Civet:
10
+
11
+ ```sh
12
+ bun add -d @danielx/civet ./bun-plugin-civet-sourcemaps-0.0.1.tgz
13
+ ```
14
+
15
+ Create `build.mjs`:
16
+
17
+ ```js
18
+ import { civetSourceMaps } from 'bun-plugin-civet-sourcemaps';
19
+
20
+ const result = await Bun.build({
21
+ entrypoints: ['./src/main.civet'],
22
+ outdir: './dist',
23
+ target: 'browser',
24
+ sourcemap: 'linked',
25
+ plugins: [civetSourceMaps()],
26
+ });
27
+
28
+ if (!result.success) throw new AggregateError(result.logs, 'Build failed');
29
+ ```
30
+
31
+ Then run `bun build.mjs`. With `sourcemap: 'linked'`, DevTools receives `dist/main.js` and `dist/main.js.map`; mapped generated positions should refer to the original `.civet` source. `sourcemap: 'external'` works too, but doesn't append a sourceMappingURL comment.
32
+
33
+ ## API
34
+
35
+ ```ts
36
+ civetSourceMaps({
37
+ // optional; default: lazy import of @danielx/civet's `compile`
38
+ compile?: (source: string, options: object) => string | Promise<string>,
39
+ // options passed through to Civet, e.g. { comptime: true }
40
+ compileOptions?: object,
41
+ // default: /\.civet$/
42
+ filter?: RegExp,
43
+ })
44
+ ```
45
+
46
+ `filename`, `outputFilename`, `inlineMap: true`, and `sourceMap: false` are forced to ensure reliable map generation and a string compiler return. `filter` must not have global or sticky flags.
47
+
48
+ This plugin **replaces Civet's existing Bun `onLoad` handler** when used with `Bun.build()`. Do not install two `.civet` handlers for the same files. It is **not** a preload-time source-map solution for `bun file.civet`.
49
+
50
+ ## What it does
51
+
52
+ 1. `onStart`: clear the per-build registry.
53
+ 2. `onLoad`: read `.civet`, invoke Civet, record its inline Source Map v3, return TSX source to Bun.
54
+ 3. Bun bundles and creates its usual intermediate-TSX-based source map.
55
+ 4. `onEnd`: match input files individually, compose each final map, and rewrite the emitted `.js.map` files **before `Bun.build()` resolves**.
56
+ 5. Unmodified TypeScript/JavaScript input mappings remain untouched; `sourcesContent` embeds original Civet text.
57
+
58
+ A missing compiler map, unsupported source map, absent output map, or disabled sourcemap causes an error rather than silently publishing incorrect debug positions.
59
+
60
+ ## Testing
61
+
62
+ The supplied Bun Linux x64 **1.4.4** binary was used for integration tests:
63
+
64
+ ```sh
65
+ bun test
66
+ bun tests/integration.test.mjs
67
+ ```
68
+
69
+ Unit tests exercise Base64 VLQ and map composition. Integration tests exercise Bun's actual `onLoad`/`onEnd`, multiple `.civet` modules, `.ts` input, multiple entrypoints, minification, repeated builds, linked/external maps, and failures. The compiler used in tests is a **simulated Civet compiler** with legitimate Source Map v3 output.
70
+
71
+ **Not yet verified with actual Civet:** `@danielx/civet` is not present in the offline test environment. This remains an experimental package pending real compiler fixture verification. Please provide the Civet tarball and dependencies to run this step.
72
+
73
+ ## Current constraints
74
+
75
+ - Requires `Bun.build({ sourcemap: 'linked' | 'external' })` and disk writes. No `sourcemap: 'inline'` or `write: false` support.
76
+ - Bun `onEnd` is a bundler lifecycle hook, so this does not add runtime `bun run` source-map support.
77
+ - Source Map v3 standard (not indexed/sectioned maps); the small in-house composer supports common local filesystem sources but is not a production-hardened implementation of URL/Windows/indexed maps or source-map edge cases.
78
+ - Compiled input paths are matched against Bun's output map paths; filename discrepancies and source map changes in real Civet need further validation.
79
+ - Watch/incremental builds must trigger a new Bun build so `onEnd` runs again. Bun's own watcher may need special handling for plugin-loaded files.
80
+ - Other plugins with overlapping `onLoad` filters can conflict. When chaining other transforms, each intermediate stage needs its own upstream mapping.
81
+
82
+ ## Package contents
83
+
84
+ The npm tarball contains `src/`, `README.md`, and `LICENSE`, not internal handoff docs or tests. The separate development source ZIP contains tests, examples, and `HANDOFF.md`.
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "bun-plugin-civet-sourcemaps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Bun bundler plugin that maps compiled Civet/TSX back to original .civet source files",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.d.mts",
9
+ "default": "./src/index.mjs"
10
+ }
11
+ },
12
+ "main": "./src/index.mjs",
13
+ "types": "./src/index.d.mts",
14
+ "files": ["src", "README.md", "LICENSE"],
15
+ "peerDependencies": {
16
+ "@danielx/civet": "^0.11.16"
17
+ },
18
+ "engines": {
19
+ "bun": ">=1.4.4"
20
+ },
21
+ "scripts": {
22
+ "test": "bun test",
23
+ "test:integration": "bun tests/integration.test.mjs"
24
+ },
25
+ "keywords": ["bun", "plugin", "civet", "sourcemap", "typescript"],
26
+ "license": "MIT"
27
+ }
@@ -0,0 +1,98 @@
1
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { decodeMappings, encodeMappings, tracePosition } from './vlq.mjs';
4
+
5
+ function filePath(source, root, basedir) {
6
+ if (source.startsWith('file://')) return fileURLToPath(source);
7
+ if (/^[a-zA-Z]+:\/\//.test(source)) return source;
8
+ const combined = root ? `${root.replace(/\/$/, '')}/${source}` : source;
9
+ return isAbsolute(combined) ? resolve(combined) : resolve(basedir, combined);
10
+ }
11
+ function relPath(file, basedir) {
12
+ if (/^[a-zA-Z]+:\/\//.test(file)) return file;
13
+ const rel = relative(basedir, file).split(sep).join('/');
14
+ return rel.startsWith('.') ? rel : `./${rel}`;
15
+ }
16
+ function normalizeRecord(record) {
17
+ if (record && record.map) return record;
18
+ throw new Error('Expected an upstream record with map and intermediatePath');
19
+ }
20
+
21
+ /**
22
+ * Compose Bun's final map with the onLoad compiler maps.
23
+ * upstreamMaps: Map<absolute plugin input path, {map: SourceMapV3, intermediatePath: absolute path}>
24
+ * Only plugin-covered Bun inputs are remapped; other JS/TS modules are preserved.
25
+ */
26
+ export function composeMaps(bunMap, finalMapPath, upstreamMaps) {
27
+ if (bunMap.sections) throw new Error('Bun indexed/section maps are not yet supported');
28
+ if (bunMap.version !== 3) throw new Error('Expected Source Map v3');
29
+ const basedir = dirname(resolve(finalMapPath));
30
+ const decoded = decodeMappings(bunMap.mappings);
31
+ const sources = [], sourcesContent = [], names = [];
32
+ const sourceIndex = new Map(), nameIndex = new Map();
33
+ const cached = new Map();
34
+
35
+ const internSource = (p, content) => {
36
+ const label = relPath(p, basedir);
37
+ let n = sourceIndex.get(label);
38
+ if (n === undefined) {
39
+ n = sources.length;
40
+ sourceIndex.set(label, n);
41
+ sources.push(label);
42
+ sourcesContent.push(content ?? null);
43
+ } else if (sourcesContent[n] == null && content != null) {
44
+ sourcesContent[n] = content;
45
+ }
46
+ return n;
47
+ };
48
+ const internName = name => {
49
+ if (name == null) return null;
50
+ if (!nameIndex.has(name)) { nameIndex.set(name, names.length); names.push(name); }
51
+ return nameIndex.get(name);
52
+ };
53
+ const getUpstream = source => {
54
+ if (cached.has(source)) return cached.get(source);
55
+ const path = filePath(source, bunMap.sourceRoot ?? '', basedir);
56
+ const record = upstreamMaps.get(path);
57
+ if (!record) { cached.set(source, null); return null; }
58
+ const {map, intermediatePath} = normalizeRecord(record);
59
+ if (map.version !== 3 || map.sections) throw new Error(`Invalid/unsupported upstream map for ${path}`);
60
+ const value = {map, decoded: decodeMappings(map.mappings), dirname: dirname(resolve(intermediatePath))};
61
+ cached.set(source, value);
62
+ return value;
63
+ };
64
+
65
+ const composed = decoded.map((line) => line.map(seg => {
66
+ if (seg.length === 1) return [...seg];
67
+ const [genCol, bSource, bLine, bCol, bName] = seg;
68
+ const bunSource = bunMap.sources[bSource];
69
+ const upstream = getUpstream(bunSource);
70
+ if (!upstream) {
71
+ const p = filePath(bunSource, bunMap.sourceRoot ?? '', basedir);
72
+ const idx = internSource(p, bunMap.sourcesContent?.[bSource]);
73
+ const name = bName == null ? null : internName(bunMap.names[bName]);
74
+ return name == null ? [genCol, idx, bLine, bCol] : [genCol, idx, bLine, bCol, name];
75
+ }
76
+ const pos = tracePosition(upstream.decoded, bLine, bCol);
77
+ if (!pos) return [genCol]; // Never silently map generated-only code to unrelated original lines.
78
+ const [, upSource, origLine, origCol, upName] = pos;
79
+ const p = filePath(upstream.map.sources[upSource], upstream.map.sourceRoot ?? '', upstream.dirname);
80
+ const idx = internSource(p, upstream.map.sourcesContent?.[upSource]);
81
+ const name = upName == null ? (bName == null ? null : internName(bunMap.names[bName])) : internName(upstream.map.names[upName]);
82
+ return name == null ? [genCol, idx, origLine, origCol] : [genCol, idx, origLine, origCol, name];
83
+ }));
84
+
85
+ return {
86
+ version: 3, file: bunMap.file, sources, sourcesContent, names,
87
+ mappings: encodeMappings(composed),
88
+ ...(bunMap.debugId ? {debugId: bunMap.debugId} : {})
89
+ };
90
+ }
91
+
92
+ export function extractInlineMap(code) {
93
+ const match = /(?:\/\/#|\/\/@)\s*sourceMappingURL=data:application\/json(?:;charset=[^;,]+)?;base64,([A-Za-z0-9+/=]+)/g;
94
+ let found;
95
+ for (const next of code.matchAll(match)) found = next[1];
96
+ if (!found) throw new Error('Compiler did not return an inline data: sourcemap (inlineMap: true)');
97
+ return JSON.parse(Buffer.from(found, 'base64').toString('utf8'));
98
+ }
@@ -0,0 +1,36 @@
1
+ import type { BunPlugin } from 'bun';
2
+
3
+ export interface SourceMapV3 {
4
+ version: 3;
5
+ file?: string;
6
+ sources: string[];
7
+ sourcesContent?: (string | null)[];
8
+ sourceRoot?: string;
9
+ names: string[];
10
+ mappings: string;
11
+ debugId?: string;
12
+ }
13
+
14
+ export type CivetCompiler = (source: string, options: Record<string, unknown>) => string | Promise<string>;
15
+
16
+ export interface CivetSourceMapOptions {
17
+ /** Defaults to dynamically importing @danielx/civet's `compile`. Useful for tests or alternate compilers. */
18
+ compile?: CivetCompiler;
19
+ /** Additional Civet options; filename and inlineMap are overwritten for correct map composition. */
20
+ compileOptions?: Record<string, unknown>;
21
+ /** Module filter, default /\.civet$/. */
22
+ filter?: RegExp;
23
+ }
24
+
25
+ /** Bun.build plugin. Requires sourcemap: 'linked' or 'external' and filesystem writes. */
26
+ export declare function civetSourceMaps(options?: CivetSourceMapOptions): BunPlugin;
27
+
28
+ /** Compose Bun's Source Map v3 with source maps for transformed module inputs. */
29
+ export declare function composeMaps(
30
+ bunMap: SourceMapV3,
31
+ finalMapPath: string,
32
+ upstreamMaps: Map<string, { map: SourceMapV3; intermediatePath: string }>
33
+ ): SourceMapV3;
34
+
35
+ /** Parse a base64-embedded inline Source Map v3 in compiler output. */
36
+ export declare function extractInlineMap(code: string): SourceMapV3;
package/src/index.mjs ADDED
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { composeMaps, extractInlineMap } from './compose.mjs';
4
+
5
+ /**
6
+ * Bun.build plugin: compile .civet to TSX and compose Civet maps with Bun's maps in onEnd.
7
+ * No external source-map library is needed.
8
+ *
9
+ * @param {{compile?: Function, compileOptions?: object, filter?: RegExp}} options
10
+ * @returns {import('bun').BunPlugin}
11
+ */
12
+ export function civetSourceMaps(options = {}) {
13
+ const {compile: suppliedCompile, compileOptions = {}, filter = /\.civet$/} = options;
14
+ if (suppliedCompile !== undefined && typeof suppliedCompile !== 'function') {
15
+ throw new TypeError('The compile option must be a function');
16
+ }
17
+ if (!(filter instanceof RegExp)) throw new TypeError('filter must be a RegExp');
18
+ if (filter.global || filter.sticky) throw new TypeError('filter must not use the g or y flags');
19
+
20
+ // Captured per plugin invocation/build; never shared across plugin instances.
21
+ const registry = new Map();
22
+ let compilePromise;
23
+ const loadCompiler = async () => {
24
+ if (suppliedCompile) return suppliedCompile;
25
+ compilePromise ??= import('@danielx/civet').then(mod => {
26
+ if (typeof mod.compile !== 'function') throw new Error('@danielx/civet does not export compile()');
27
+ return mod.compile;
28
+ });
29
+ return compilePromise;
30
+ };
31
+
32
+ return {
33
+ name: 'civet-original-sourcemaps',
34
+ setup(build) {
35
+ build.onStart(() => registry.clear());
36
+ build.onLoad({filter}, async ({path}) => {
37
+ const source = await readFile(path, 'utf8');
38
+ const compile = await loadCompiler();
39
+ // Force maps and a stable origin even when a caller passes conflicting options.
40
+ const contents = await compile(source, {
41
+ ...compileOptions,
42
+ filename: resolve(path),
43
+ outputFilename: `${resolve(path)}.tsx`,
44
+ sourceMap: false,
45
+ inlineMap: true,
46
+ });
47
+ if (typeof contents !== 'string') {
48
+ throw new Error(`Civet compile() must return a string for ${path}`);
49
+ }
50
+ let map;
51
+ try {
52
+ map = extractInlineMap(contents);
53
+ } catch (error) {
54
+ throw new Error(`Missing or invalid inline source map for ${path}: ${error.message}`, {cause: error});
55
+ }
56
+ // Fallback for compilers that only provide a single source but omit its text.
57
+ if (map.sources?.length === 1 && map.sourcesContent?.[0] == null) {
58
+ map.sourcesContent = [source];
59
+ } else if (map.sources?.length === 1 && !map.sourcesContent) {
60
+ map.sourcesContent = [source];
61
+ }
62
+ registry.set(resolve(path), {map, intermediatePath: `${resolve(path)}.tsx`});
63
+ return {contents, loader: 'tsx'};
64
+ });
65
+
66
+ build.onEnd(async result => {
67
+ if (!result.success || registry.size === 0) return;
68
+ if (build.config.write === false) {
69
+ throw new Error('civetSourceMaps requires Bun.build write !== false (the maps must be writable)');
70
+ }
71
+ if (build.config.sourcemap !== 'linked' && build.config.sourcemap !== 'external') {
72
+ throw new Error('civetSourceMaps requires Bun.build({ sourcemap: "linked" }) or "external"');
73
+ }
74
+ const maps = result.outputs.filter(output => output.kind === 'sourcemap');
75
+ if (maps.length === 0) {
76
+ throw new Error('Bun produced no sourcemap artifacts: cannot preserve Civet coordinates');
77
+ }
78
+ // Prepare/validate all maps before writing any of them.
79
+ const rewrites = [];
80
+ for (const output of maps) {
81
+ const original = JSON.parse(await output.text());
82
+ const composed = composeMaps(original, output.path, registry);
83
+ rewrites.push({output, contents: JSON.stringify(composed)});
84
+ }
85
+ for (const {output, contents} of rewrites) await Bun.write(output.path, contents);
86
+ });
87
+ },
88
+ };
89
+ }
90
+
91
+ export {composeMaps, extractInlineMap} from './compose.mjs';
package/src/vlq.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // Source Map v3 Base64-VLQ codec. No runtime dependencies.
2
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
3
+ const dictionary = Object.fromEntries([...alphabet].map((c, i) => [c, i]));
4
+
5
+ export function decodeVLQ(text, offset) {
6
+ let value = 0, shift = 0, digit;
7
+ do {
8
+ if (offset >= text.length) throw new Error('Truncated VLQ segment');
9
+ digit = dictionary[text[offset++]];
10
+ if (digit === undefined) throw new Error('Invalid VLQ symbol');
11
+ value += (digit & 31) * 2 ** shift;
12
+ shift += 5;
13
+ } while (digit & 32);
14
+ return { value: value & 1 ? -(Math.floor(value / 2)) : Math.floor(value / 2), offset };
15
+ }
16
+
17
+ export function encodeVLQ(value) {
18
+ let unsigned = value < 0 ? -2 * value + 1 : 2 * value;
19
+ let out = '';
20
+ do {
21
+ let digit = unsigned % 32;
22
+ unsigned = Math.floor(unsigned / 32);
23
+ if (unsigned) digit |= 32;
24
+ out += alphabet[digit];
25
+ } while (unsigned);
26
+ return out;
27
+ }
28
+
29
+ // Each segment: [generatedColumn, sourceIndex?, originalLine?, originalColumn?, nameIndex?]
30
+ export function decodeMappings(mappings) {
31
+ let prevSource = 0, prevLine = 0, prevOriginalCol = 0, prevName = 0;
32
+ return mappings.split(';').map((line) => {
33
+ let prevGenCol = 0;
34
+ if (!line) return [];
35
+ return line.split(',').map((part) => {
36
+ let offset = 0;
37
+ const nums = [];
38
+ while (offset < part.length) {
39
+ const res = decodeVLQ(part, offset);
40
+ nums.push(res.value);
41
+ offset = res.offset;
42
+ }
43
+ if (nums.length !== 1 && nums.length !== 4 && nums.length !== 5) {
44
+ throw new Error(`Unsupported sourcemap segment with ${nums.length} fields`);
45
+ }
46
+ const segment = [prevGenCol += nums[0]];
47
+ if (nums.length > 1) {
48
+ segment.push(prevSource += nums[1], prevLine += nums[2], prevOriginalCol += nums[3]);
49
+ if (nums.length > 4) segment.push(prevName += nums[4]);
50
+ }
51
+ return segment;
52
+ });
53
+ });
54
+ }
55
+
56
+ export function encodeMappings(lines) {
57
+ let prevSource = 0, prevLine = 0, prevOrigCol = 0, prevName = 0;
58
+ return lines.map(line => {
59
+ let prevGenCol = 0;
60
+ return line.map(seg => {
61
+ let out = encodeVLQ(seg[0] - prevGenCol);
62
+ prevGenCol = seg[0];
63
+ if (seg.length > 1) {
64
+ out += encodeVLQ(seg[1] - prevSource) + encodeVLQ(seg[2] - prevLine) + encodeVLQ(seg[3] - prevOrigCol);
65
+ prevSource = seg[1]; prevLine = seg[2]; prevOrigCol = seg[3];
66
+ if (seg.length > 4) {
67
+ out += encodeVLQ(seg[4] - prevName);
68
+ prevName = seg[4];
69
+ }
70
+ }
71
+ return out;
72
+ }).join(',');
73
+ }).join(';');
74
+ }
75
+
76
+ export function tracePosition(decodedLines, line, column) {
77
+ const segments = decodedLines[line] ?? [];
78
+ // Greatest lower bound, as specified by Source Map v3 tracing.
79
+ let left = 0, right = segments.length;
80
+ while (left < right) {
81
+ const mid = (left + right) >>> 1;
82
+ if (segments[mid][0] <= column) left = mid + 1;
83
+ else right = mid;
84
+ }
85
+ const segment = segments[left - 1];
86
+ return segment?.length > 1 ? segment : null;
87
+ }