@siteqwality/cli 0.1.0

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SiteQwality
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. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @siteqwality/cli
2
+
3
+ Candidate version 0.1.0, Node.js 22+. Not published by this change. Use local tarballs until npm publication is authorized. After publication:
4
+
5
+ ```sh
6
+ npm install --save-dev @siteqwality/cli
7
+ ```
8
+
9
+ ## Commands
10
+
11
+ Inject before uploading, then deploy the exact injected JavaScript:
12
+
13
+ ```sh
14
+ npx siteqwality sourcemaps inject ./dist
15
+ export SITEQWALITY_APP_ID='your-application-uuid'
16
+ export SITEQWALITY_API_KEY='your-api-key'
17
+ export SITEQWALITY_RELEASE='web-2026.10.04'
18
+ npx siteqwality sourcemaps upload ./dist --delete-after
19
+ npx siteqwality releases new "$SITEQWALITY_RELEASE" --commit abc123 --env production
20
+ # Run after the application's deployment succeeds:
21
+ npx siteqwality releases deploy "$SITEQWALITY_RELEASE" --env production
22
+ npx siteqwality releases list
23
+ ```
24
+
25
+ Store the key in your CI secret store. Mutations require `write:rum`; release listing requires `read:rum`. The API enforces application ownership. `SITEQWALITY_API_URL` changes the API base, which defaults to `https://api.siteqwality.com`. HTTPS is required except for loopback test servers. The key is read from `SITEQWALITY_API_KEY` and is sent only to the configured API, never to storage PUT URLs. Redirects are not followed.
26
+
27
+ Authenticated commands accept `--app <uuid>`, `--api-url <url>`, `--retries <count>` (default 2, range 0 to 5), and `--timeout <ms>` (default 30000, maximum 300000). Upload also accepts `--release <version>`, `--commit <sha>`, `--env <environment>`, `--concurrency <count>` (default 4, range 1 to 16), and `--delete-after`. Explicit flags override environment defaults. JSON results go to stdout, errors to stderr with exit code 1. Keys are intentionally not accepted as command-line flags.
28
+
29
+ Upload creates the release idempotently before presigning. With no release it uses the reserved `__debug_id__` release. `releases deploy <version>` requires an existing release and records server time. It does not deploy application files or configure the browser SDK's release value.
30
+
31
+ ## Injection
32
+
33
+ The CLI recursively finds `.js`, `.mjs` and `.cjs` files with relative `sourceMappingURL` comments or adjacent hidden `<file>.map` files. Files without maps are skipped; a missing explicitly referenced map fails. It validates all pairs before writing. Symlinks, remote/inline maps, shared maps and paths outside the output directory are rejected.
34
+
35
+ Injection preserves the shebang, strict and custom directives, original mapped positions and `sourcesContent`. IDs are deterministic UUIDv8 values derived from original JavaScript bytes. The runtime registration uses `globalThis._sqDebugIds` and a captured error stack. Its syntax requires a runtime supporting logical assignment, as used by the SDK contract. A failed registry write is contained so application code still executes.
36
+
37
+ Unchanged injected pairs can be processed repeatedly. If injected JavaScript changes or a pair's IDs disagree, rebuild from clean output. Map JSON contains the same `debugId` as the JavaScript comment and runtime registration. Indexed maps are unsupported.
38
+
39
+ ## Upload and deletion
40
+
41
+ Uploads follow the Phase 4 API contract:
42
+
43
+ 1. Create the app-scoped release.
44
+ 2. Presign at most 50 maps per batch, with unique filenames and Debug IDs. Declare exact gzip byte sizes and JSON/gzip headers.
45
+ 3. PUT those bytes with the returned storage headers and bounded concurrency.
46
+ 4. Confirm that batch's returned upload IDs and verify ready status, Debug IDs and byte sizes.
47
+ 5. Only after every batch succeeds, optionally remove selected maps. The CLI first checks that all selected JavaScript and map files remain unchanged.
48
+
49
+ Both raw JSON and gzip output are limited to 32 MiB per map. The client retries network failures, timeouts, HTTP 408/429 and 5xx responses with bounded backoff. It respects `Retry-After` up to 30 seconds and fails for longer requested waits. A storage 403 triggers a fresh presign and re-upload, bounded by `--retries`. Other permanent failures stop the command. An incomplete or mismatched presign/confirmation response fails closed. Failed uploads may leave pending server records; retrying gets new IDs. Earlier confirmed batches may remain ready, but no local map is removed when a later batch fails.
50
+
51
+ Confirmation proves storage and metadata acceptance. It does not prove live symbolication. Backend deployment and an integrated SDK/API smoke test are separate release gates.
52
+
53
+ ## JavaScript API
54
+
55
+ ```js
56
+ import { inject, injectDirectory, uploadDirectory, ApiClient } from '@siteqwality/cli';
57
+ const { code, map, debugId } = inject(generatedJavaScript, originalSourceMap);
58
+ await injectDirectory('./dist');
59
+ await uploadDirectory('./dist', {
60
+ app: process.env.SITEQWALITY_APP_ID,
61
+ apiKey: process.env.SITEQWALITY_API_KEY,
62
+ release: 'web-v1',
63
+ deleteAfter: false,
64
+ });
65
+ ```
66
+
67
+ The programmatic API takes explicit credentials. Bundler adapters and CLI commands additionally resolve environment defaults.
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { Command, InvalidArgumentError } from 'commander';
3
+ import { ApiClient, injectDirectory, uploadDirectory } from '../src/index.js';
4
+
5
+ const program = new Command()
6
+ .name('siteqwality')
7
+ .version('0.1.0')
8
+ .description('SiteQwality Debug ID source maps and releases');
9
+ const integer = (value) => {
10
+ if (!/^\d+$/.test(value)) throw new InvalidArgumentError('Expected an integer');
11
+ return Number(value);
12
+ };
13
+ function authenticated(command) {
14
+ return command
15
+ .option('--app <uuid>', 'Application UUID', process.env.SITEQWALITY_APP_ID)
16
+ .option('--api-url <url>', 'SiteQwality API base URL', process.env.SITEQWALITY_API_URL)
17
+ .option('--retries <count>', 'Transient retries per request, 0 to 5', integer, 2)
18
+ .option('--timeout <ms>', 'Request timeout, 1 to 300000 milliseconds', integer, 30000);
19
+ }
20
+ function options(command) {
21
+ return {
22
+ ...command,
23
+ apiKey: process.env.SITEQWALITY_API_KEY,
24
+ apiBase: command.apiUrl,
25
+ timeoutMs: command.timeout,
26
+ };
27
+ }
28
+ const print = (result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
29
+ const maps = program
30
+ .command('sourcemaps')
31
+ .description('Inject Debug IDs and upload matching external source maps');
32
+ maps
33
+ .command('inject <directory>')
34
+ .description('Inject deterministic IDs into JavaScript and matching maps')
35
+ .action(async (directory) => print(await injectDirectory(directory)));
36
+ authenticated(
37
+ maps
38
+ .command('upload <directory>')
39
+ .description('Gzip, presign, PUT and confirm previously injected maps'),
40
+ )
41
+ .option(
42
+ '--release <version>',
43
+ 'Product release; defaults to __debug_id__',
44
+ process.env.SITEQWALITY_RELEASE,
45
+ )
46
+ .option('--commit <sha>', 'Release commit SHA')
47
+ .option('--env <environment>', 'Release environment')
48
+ .option('--concurrency <count>', 'Parallel PUT requests, 1 to 16', integer, 4)
49
+ .option('--delete-after', 'Delete maps only after every upload is confirmed', false)
50
+ .action(async (directory, opts) => print(await uploadDirectory(directory, options(opts))));
51
+ const releases = program.command('releases').description('Manage app-scoped releases');
52
+ authenticated(releases.command('new <version>').description('Create or update a release'))
53
+ .option('--commit <sha>', 'Commit SHA')
54
+ .option('--env <environment>', 'Release environment')
55
+ .action(async (version, opts) =>
56
+ print(await new ApiClient(options(opts)).createRelease(version, opts)),
57
+ );
58
+ authenticated(
59
+ releases
60
+ .command('deploy <version>')
61
+ .description('Record server deployment time for an existing release'),
62
+ )
63
+ .option('--env <environment>', 'Release environment')
64
+ .action(async (version, opts) =>
65
+ print(await new ApiClient(options(opts)).deployRelease(version, opts.env)),
66
+ );
67
+ authenticated(releases.command('list').description('List releases, newest created first')).action(
68
+ async (opts) => print(await new ApiClient(options(opts)).listReleases()),
69
+ );
70
+ try {
71
+ await program.parseAsync();
72
+ } catch (error) {
73
+ process.stderr.write(`siteqwality: ${error.message}\n`);
74
+ process.exitCode = 1;
75
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@siteqwality/cli",
3
+ "version": "0.1.0",
4
+ "description": "SiteQwality release and Debug ID source-map tooling",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "license": "MIT",
10
+ "bin": {
11
+ "siteqwality": "./bin/siteqwality.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.d.ts",
16
+ "import": "./src/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "src",
21
+ "bin",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/siteqwality/sdk-cli.git",
31
+ "directory": "packages/cli"
32
+ },
33
+ "dependencies": {
34
+ "@ampproject/remapping": "^2.3.0",
35
+ "acorn": "^8.15.0",
36
+ "commander": "^14.0.0",
37
+ "magic-string": "^0.30.19"
38
+ }
39
+ }
package/src/api.js ADDED
@@ -0,0 +1,141 @@
1
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+ export const isUuid = (value) =>
3
+ typeof value === 'string' && UUID.test(value) && !/^0{8}-0{4}-0{4}-0{4}-0{12}$/.test(value);
4
+ export function safeUrl(value) {
5
+ const url = new URL(value);
6
+ const local = ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
7
+ if (
8
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) ||
9
+ url.username ||
10
+ url.password ||
11
+ url.hash
12
+ )
13
+ throw new Error('Use HTTPS (or loopback HTTP for local tests) without URL credentials');
14
+ return url;
15
+ }
16
+ export function releaseName(version) {
17
+ if (
18
+ typeof version !== 'string' ||
19
+ !version.trim() ||
20
+ [...version].length > 255 ||
21
+ ['.', '..'].includes(version) ||
22
+ /[\x00-\x1f\x7f]/.test(version)
23
+ )
24
+ throw new Error('Invalid release version');
25
+ return encodeURIComponent(version);
26
+ }
27
+ export class HttpError extends Error {
28
+ constructor(status, retryAfter) {
29
+ super(`Request failed with HTTP ${status}`);
30
+ this.status = status;
31
+ this.retryAfter = retryAfter;
32
+ }
33
+ }
34
+ export class ApiClient {
35
+ constructor(options = {}) {
36
+ const base = safeUrl(options.apiBase || 'https://api.siteqwality.com');
37
+ if (base.search) throw new Error('API base must not contain query parameters');
38
+ if (!isUuid(options.app)) throw new Error('A valid application UUID is required');
39
+ if (
40
+ typeof options.apiKey !== 'string' ||
41
+ !options.apiKey.trim() ||
42
+ /[\r\n]/.test(options.apiKey)
43
+ )
44
+ throw new Error('An API key is required');
45
+ this.base = base.href.replace(/\/+$/, '');
46
+ this.app = options.app;
47
+ this.key = options.apiKey;
48
+ this.retries = options.retries ?? 2;
49
+ this.retryDelayMs = options.retryDelayMs ?? 500;
50
+ this.timeoutMs = options.timeoutMs ?? 30_000;
51
+ if (!Number.isInteger(this.retries) || this.retries < 0 || this.retries > 5)
52
+ throw new Error('retries must be 0 to 5');
53
+ if (!Number.isFinite(this.retryDelayMs) || this.retryDelayMs < 0 || this.retryDelayMs > 30_000)
54
+ throw new Error('Invalid retry delay');
55
+ if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 300_000)
56
+ throw new Error('Invalid request timeout');
57
+ }
58
+ async request(url, init, json = false) {
59
+ for (let attempt = 0; ; attempt++) {
60
+ try {
61
+ const response = await fetch(url, {
62
+ ...init,
63
+ redirect: 'manual',
64
+ credentials: 'omit',
65
+ signal: AbortSignal.timeout(this.timeoutMs),
66
+ });
67
+ if (!response.ok) {
68
+ const after = response.headers.get('retry-after');
69
+ await response.body?.cancel();
70
+ throw new HttpError(response.status, after);
71
+ }
72
+ if (!json) {
73
+ await response.body?.cancel();
74
+ return;
75
+ }
76
+ const body = await response.json();
77
+ if (!body || !Object.hasOwn(body, 'data')) throw new Error('Invalid API response envelope');
78
+ return body.data;
79
+ } catch (err) {
80
+ const transient =
81
+ err instanceof HttpError
82
+ ? [408, 429].includes(err.status) || err.status >= 500
83
+ : err instanceof TypeError || err.name === 'TimeoutError';
84
+ if (!transient || attempt >= this.retries) {
85
+ if (err instanceof HttpError) throw err;
86
+ throw new Error('Request failed or returned an invalid response');
87
+ }
88
+ let wait = this.retryDelayMs * 2 ** attempt;
89
+ if (err.retryAfter) {
90
+ const parsed = /^\d+$/.test(err.retryAfter)
91
+ ? Number(err.retryAfter) * 1000
92
+ : Date.parse(err.retryAfter) - Date.now();
93
+ if (Number.isFinite(parsed)) wait = Math.max(wait, parsed);
94
+ }
95
+ if (wait > 30_000)
96
+ throw new Error('Server requested a long retry delay; retry this command later');
97
+ await new Promise((resolve) => setTimeout(resolve, wait));
98
+ }
99
+ }
100
+ }
101
+ api(suffix, method = 'GET', body) {
102
+ return this.request(
103
+ `${this.base}/rum/${this.app}${suffix}`,
104
+ {
105
+ method,
106
+ headers: { Authorization: `Bearer ${this.key}`, 'Content-Type': 'application/json' },
107
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
108
+ },
109
+ true,
110
+ );
111
+ }
112
+ async createRelease(version, { commit, env } = {}) {
113
+ releaseName(version);
114
+ for (const value of [commit, env])
115
+ if (value !== undefined && (typeof value !== 'string' || [...value].length > 64))
116
+ throw new Error('Release metadata is limited to 64 characters');
117
+ return this.api('/releases', 'POST', {
118
+ version,
119
+ ...(commit === undefined ? {} : { commit_sha: commit }),
120
+ ...(env === undefined ? {} : { env }),
121
+ });
122
+ }
123
+ async deployRelease(version, env) {
124
+ const name = releaseName(version);
125
+ if (env !== undefined && (typeof env !== 'string' || [...env].length > 64))
126
+ throw new Error('Release environment is limited to 64 characters');
127
+ return this.api(`/releases/${name}/deploy`, 'POST', env === undefined ? {} : { env });
128
+ }
129
+ listReleases() {
130
+ return this.api('/releases');
131
+ }
132
+ presign(version, files) {
133
+ return this.api(`/releases/${releaseName(version)}/sourcemaps`, 'POST', { files });
134
+ }
135
+ confirm(ids) {
136
+ return this.api('/sourcemaps/confirm', 'POST', { ids });
137
+ }
138
+ put(url, headers, body) {
139
+ return this.request(safeUrl(url).href, { method: 'PUT', headers, body });
140
+ }
141
+ }
@@ -0,0 +1,17 @@
1
+ import { tokenizer } from 'acorn';
2
+ /** Read real comments, excluding lookalikes in strings and template literals. */
3
+ export function sourceMapReference(code) {
4
+ let reference;
5
+ const tokens = tokenizer(code, {
6
+ ecmaVersion: 'latest',
7
+ allowHashBang: true,
8
+ onComment(_block, text) {
9
+ const match = text.match(/^\s*[@#]\s*sourceMappingURL=(\S+)\s*$/);
10
+ if (match) reference = match[1];
11
+ },
12
+ });
13
+ while (tokens.getToken().type.label !== 'eof') {
14
+ /* Consume comments across the whole file. */
15
+ }
16
+ return reference;
17
+ }
package/src/files.js ADDED
@@ -0,0 +1,85 @@
1
+ import { readdir, readFile, realpath, lstat, writeFile, rename, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { inject } from './inject.js';
5
+ import { sourceMapReference } from './comments.js';
6
+
7
+ export function inside(root, file) {
8
+ const relative = path.relative(root, file);
9
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
10
+ throw new Error('File is outside output directory');
11
+ return relative;
12
+ }
13
+
14
+ export async function collectPairs(directory) {
15
+ const root = await realpath(directory);
16
+ const files = [];
17
+ async function walk(dir) {
18
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
19
+ const full = path.join(dir, entry.name);
20
+ if (entry.isSymbolicLink()) throw new Error('Output directory contains a symlink');
21
+ if (entry.isDirectory()) await walk(full);
22
+ else if (entry.isFile() && /\.(?:c|m)?js$/.test(entry.name)) files.push(full);
23
+ }
24
+ }
25
+ await walk(root);
26
+ const pairs = [];
27
+ const seen = new Set();
28
+ for (const jsPath of files.sort()) {
29
+ const code = await readFile(jsPath, 'utf8');
30
+ const reference = sourceMapReference(code);
31
+ if (reference && /^[a-z][a-z\d+.-]*:|^\/|[?#\\]/i.test(reference))
32
+ throw new Error('Unsupported external or inline source map');
33
+ const mapPath = reference
34
+ ? path.resolve(path.dirname(jsPath), decodeURIComponent(reference))
35
+ : `${jsPath}.map`;
36
+ inside(root, mapPath);
37
+ let stat;
38
+ try {
39
+ stat = await lstat(mapPath);
40
+ } catch (err) {
41
+ if (!reference && err.code === 'ENOENT') continue;
42
+ throw err;
43
+ }
44
+ if (stat.isSymbolicLink()) throw new Error('Source map is a symlink');
45
+ if (!stat.isFile()) throw new Error('Expected a source map file');
46
+ inside(root, await realpath(mapPath));
47
+ if (seen.has(mapPath))
48
+ throw new Error('Multiple JavaScript files reference the same source map');
49
+ seen.add(mapPath);
50
+ const rawMap = await readFile(mapPath, 'utf8');
51
+ pairs.push({
52
+ jsPath,
53
+ mapPath,
54
+ filename: inside(root, mapPath).split(path.sep).join('/'),
55
+ code,
56
+ rawMap,
57
+ });
58
+ }
59
+ if (!pairs.length) throw new Error('No JavaScript files with external source maps found');
60
+ return pairs;
61
+ }
62
+
63
+ async function atomicWrite(file, text) {
64
+ const tmp = `${file}.sq-${randomUUID()}.tmp`;
65
+ const { mode } = await lstat(file);
66
+ try {
67
+ await writeFile(tmp, text, { flag: 'wx', mode });
68
+ await rename(tmp, file);
69
+ } finally {
70
+ await rm(tmp, { force: true });
71
+ }
72
+ }
73
+
74
+ export async function injectDirectory(directory) {
75
+ const pairs = await collectPairs(directory);
76
+ // Validate and transform every pair before changing any file.
77
+ const outputs = pairs.map((pair) => ({ ...pair, ...inject(pair.code, pair.rawMap) }));
78
+ for (const output of outputs) {
79
+ const mapText = JSON.stringify(output.map);
80
+ if (output.code !== pairs.find((p) => p.jsPath === output.jsPath).code)
81
+ await atomicWrite(output.jsPath, output.code);
82
+ if (mapText !== output.rawMap) await atomicWrite(output.mapPath, mapText);
83
+ }
84
+ return outputs.map(({ filename, debugId }) => ({ filename, debugId }));
85
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ export interface SourceMap {
2
+ version: 3;
3
+ file?: string;
4
+ sourceRoot?: string;
5
+ sources: string[];
6
+ names: string[];
7
+ mappings: string;
8
+ sourcesContent?: (string | null)[];
9
+ debugId?: string;
10
+ [key: string]: unknown;
11
+ }
12
+ export interface ApiOptions {
13
+ app: string;
14
+ apiKey: string;
15
+ apiBase?: string;
16
+ retries?: number;
17
+ retryDelayMs?: number;
18
+ timeoutMs?: number;
19
+ }
20
+ export interface ReleaseMetadata {
21
+ commit?: string;
22
+ env?: string;
23
+ }
24
+ export interface UploadOptions extends ApiOptions, ReleaseMetadata {
25
+ release?: string;
26
+ concurrency?: number;
27
+ deleteAfter?: boolean;
28
+ }
29
+ export interface Release {
30
+ id: string;
31
+ application_id: string;
32
+ version: string;
33
+ created_at: string;
34
+ source_map_count: number;
35
+ commit_sha: string | null;
36
+ env: string | null;
37
+ deployed_at: string | null;
38
+ first_seen_at: string | null;
39
+ last_seen_at: string | null;
40
+ }
41
+ export interface UploadFile {
42
+ filename: string;
43
+ debug_id: string;
44
+ size_bytes: number;
45
+ content_type: 'application/json';
46
+ content_encoding: 'gzip';
47
+ }
48
+ export interface PresignedFile extends UploadFile {
49
+ id: string;
50
+ upload_url: string;
51
+ s3_key: string;
52
+ expires_in_seconds: number;
53
+ headers: Record<string, string>;
54
+ }
55
+ export interface ConfirmedFile {
56
+ id: string;
57
+ status: 'ready';
58
+ debug_id: string;
59
+ size_bytes: number;
60
+ }
61
+ export interface UploadResult {
62
+ uploaded: number;
63
+ debugIds: string[];
64
+ }
65
+ export interface FilePair {
66
+ jsPath: string;
67
+ mapPath: string;
68
+ filename: string;
69
+ code: string;
70
+ rawMap: string;
71
+ }
72
+ export function validateMap(map: unknown): asserts map is SourceMap;
73
+ export function inject(
74
+ code: string,
75
+ inputMap: SourceMap | string,
76
+ ): { code: string; map: SourceMap; debugId: string };
77
+ export function collectPairs(directory: string): Promise<FilePair[]>;
78
+ export function injectDirectory(
79
+ directory: string,
80
+ ): Promise<{ filename: string; debugId: string }[]>;
81
+ export function uploadMaps(
82
+ maps: { filename: string; map: SourceMap }[],
83
+ options: UploadOptions,
84
+ ): Promise<UploadResult>;
85
+ export function uploadDirectory(directory: string, options: UploadOptions): Promise<UploadResult>;
86
+ export class ApiClient {
87
+ constructor(options: ApiOptions);
88
+ createRelease(version: string, metadata?: ReleaseMetadata): Promise<Release>;
89
+ deployRelease(version: string, env?: string): Promise<Release>;
90
+ listReleases(): Promise<Release[]>;
91
+ presign(version: string, files: UploadFile[]): Promise<{ files: PresignedFile[] }>;
92
+ confirm(ids: string[]): Promise<{ files: ConfirmedFile[] }>;
93
+ put(url: string, headers: Record<string, string>, body: Uint8Array): Promise<void>;
94
+ }
95
+
96
+ export function sourceMapReference(code: string): string | undefined;
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { inject, validateMap } from './inject.js';
2
+ export { injectDirectory, collectPairs } from './files.js';
3
+ export { ApiClient } from './api.js';
4
+ export { uploadMaps, uploadDirectory } from './upload.js';
5
+
6
+ export { sourceMapReference } from './comments.js';
package/src/inject.js ADDED
@@ -0,0 +1,93 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parse } from 'acorn';
3
+ import MagicString from 'magic-string';
4
+ import remapping from '@ampproject/remapping';
5
+
6
+ function debugId(code) {
7
+ const bytes = createHash('sha256')
8
+ .update('siteqwality-debug-id-v1\0')
9
+ .update(code)
10
+ .digest()
11
+ .subarray(0, 16);
12
+ bytes[6] = (bytes[6] & 15) | 128;
13
+ bytes[8] = (bytes[8] & 63) | 128;
14
+ const hex = bytes.toString('hex');
15
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
16
+ }
17
+
18
+ function registrationFor(id) {
19
+ return `\n/* sq-debug-id:${id} */\n;try{(globalThis._sqDebugIds ||= {})[new Error().stack]="${id}"}catch{}\n/* sq-debug-id:end */\n`;
20
+ }
21
+
22
+ export function validateMap(map) {
23
+ if (
24
+ !map ||
25
+ map.version !== 3 ||
26
+ map.sections ||
27
+ !Array.isArray(map.sources) ||
28
+ !Array.isArray(map.names) ||
29
+ typeof map.mappings !== 'string'
30
+ ) {
31
+ throw new Error('Expected a flat version 3 source map');
32
+ }
33
+ }
34
+
35
+ /** Insert after directives, composing an edit map with the original map. */
36
+ export function inject(code, inputMap) {
37
+ const map = typeof inputMap === 'string' ? JSON.parse(inputMap) : inputMap;
38
+ validateMap(map);
39
+ const existing = code.match(
40
+ /\n\/\* sq-debug-id:([0-9a-f-]{36}) \*\/\n;try\{[^\n]+\}catch\{\}\n\/\* sq-debug-id:end \*\/\n/,
41
+ );
42
+ if (existing) {
43
+ const id = existing[1];
44
+ if (existing[0] !== registrationFor(id))
45
+ throw new Error('Injected registration changed; rebuild before injecting');
46
+ const suffix = `\n//# debugId=${id}\n`;
47
+ if (!code.endsWith(suffix) || map.debugId !== id) throw new Error('Debug ID pair mismatch');
48
+ const original =
49
+ code.slice(0, existing.index) +
50
+ code.slice(existing.index + existing[0].length, -suffix.length);
51
+ if (debugId(original) !== id)
52
+ throw new Error('Injected JavaScript changed; rebuild before injecting');
53
+ return { code, map, debugId: id };
54
+ }
55
+ if (map.debugId || /sq-debug-id:|\/\/[@#]\s*debugId=/.test(code))
56
+ throw new Error('Unrecognized Debug ID; start from a clean build');
57
+ const parserOptions = {
58
+ ecmaVersion: 'latest',
59
+ allowHashBang: true,
60
+ allowReturnOutsideFunction: true,
61
+ };
62
+ let ast;
63
+ try {
64
+ ast = parse(code, { ...parserOptions, sourceType: 'script' });
65
+ } catch {
66
+ ast = parse(code, { ...parserOptions, sourceType: 'module' });
67
+ }
68
+ let offset = code.startsWith('#!')
69
+ ? code.indexOf('\n') === -1
70
+ ? code.length
71
+ : code.indexOf('\n') + 1
72
+ : 0;
73
+ for (const statement of ast.body) {
74
+ if (statement.type !== 'ExpressionStatement' || typeof statement.directive !== 'string') break;
75
+ offset = statement.end;
76
+ }
77
+ const id = debugId(code);
78
+ const registration = registrationFor(id);
79
+ const edit = new MagicString(code);
80
+ edit.appendLeft(offset, registration);
81
+ edit.append(`\n//# debugId=${id}\n`);
82
+ const composed = remapping(
83
+ [
84
+ JSON.parse(edit.generateMap({ hires: true, source: map.file || 'bundle.js' }).toString()),
85
+ map,
86
+ ],
87
+ () => null,
88
+ );
89
+ const outputMap = { ...map, ...JSON.parse(composed.toString()), debugId: id };
90
+ // remapping resolves sourceRoot into sources; retaining it would resolve paths twice.
91
+ delete outputMap.sourceRoot;
92
+ return { code: edit.toString(), map: outputMap, debugId: id };
93
+ }
package/src/upload.js ADDED
@@ -0,0 +1,165 @@
1
+ import { gzipSync } from 'node:zlib';
2
+ import { readFile, unlink, lstat, realpath } from 'node:fs/promises';
3
+ import { ApiClient, isUuid, safeUrl } from './api.js';
4
+ import { collectPairs } from './files.js';
5
+ import { inject, validateMap } from './inject.js';
6
+
7
+ function filename(value) {
8
+ if (
9
+ typeof value !== 'string' ||
10
+ !value ||
11
+ Buffer.byteLength(value) > 1024 ||
12
+ /[\x00-\x1f\x7f\\]/.test(value) ||
13
+ value.split('/').some((part) => !part || part === '.' || part === '..')
14
+ )
15
+ throw new Error('Invalid source-map filename');
16
+ }
17
+ async function concurrent(items, limit, work) {
18
+ let next = 0,
19
+ failure;
20
+ await Promise.all(
21
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
22
+ while (!failure && next < items.length) {
23
+ const item = items[next++];
24
+ try {
25
+ await work(item);
26
+ } catch (err) {
27
+ failure ||= err;
28
+ }
29
+ }
30
+ }),
31
+ );
32
+ if (failure) throw failure;
33
+ }
34
+ function validatePresign(data, batch) {
35
+ if (!Array.isArray(data?.files) || data.files.length !== batch.length)
36
+ throw new Error('Incomplete presign response');
37
+ const ids = new Set();
38
+ return batch.map((file) => {
39
+ const matches = data.files.filter((item) => item.filename === file.filename);
40
+ const row = matches[0];
41
+ if (
42
+ matches.length !== 1 ||
43
+ !isUuid(row.id) ||
44
+ ids.has(row.id) ||
45
+ row.debug_id !== file.debug_id
46
+ )
47
+ throw new Error('Presign response mismatch');
48
+ ids.add(row.id);
49
+ safeUrl(row.upload_url);
50
+ if (!row.headers || typeof row.headers !== 'object' || Array.isArray(row.headers))
51
+ throw new Error('Missing upload headers');
52
+ const headers = Object.entries(row.headers);
53
+ const lowered = Object.fromEntries(headers.map(([k, v]) => [k.toLowerCase(), v]));
54
+ if (
55
+ headers.some(
56
+ ([k, v]) =>
57
+ !['content-type', 'content-encoding'].includes(k.toLowerCase()) || typeof v !== 'string',
58
+ ) ||
59
+ headers.length !== 2 ||
60
+ lowered['content-type'] !== 'application/json' ||
61
+ lowered['content-encoding'] !== 'gzip'
62
+ )
63
+ throw new Error('Unexpected presigned upload header');
64
+ return { ...row, bytes: file.bytes, size_bytes: file.size_bytes };
65
+ });
66
+ }
67
+
68
+ export async function uploadMaps(maps, options) {
69
+ if (!Array.isArray(maps) || maps.length === 0) throw new Error('No source maps to upload');
70
+ const concurrency = options.concurrency ?? 4;
71
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16)
72
+ throw new Error('concurrency must be 1 to 16');
73
+ const names = new Set(),
74
+ debugIds = new Set();
75
+ const files = maps.map(({ filename: name, map }) => {
76
+ filename(name);
77
+ validateMap(map);
78
+ if (!isUuid(map.debugId)) throw new Error('Inject Debug IDs before uploading maps');
79
+ if (names.has(name) || debugIds.has(map.debugId))
80
+ throw new Error('Duplicate filename or Debug ID');
81
+ names.add(name);
82
+ debugIds.add(map.debugId);
83
+ const json = Buffer.from(JSON.stringify(map));
84
+ if (json.length > 33_554_432) throw new Error('Source map exceeds 32 MB inflated limit');
85
+ const bytes = gzipSync(json);
86
+ if (bytes.length > 33_554_432) throw new Error('Source map exceeds 32 MB upload limit');
87
+ return {
88
+ filename: name,
89
+ debug_id: map.debugId,
90
+ size_bytes: bytes.length,
91
+ content_type: 'application/json',
92
+ content_encoding: 'gzip',
93
+ bytes,
94
+ };
95
+ });
96
+ const client = new ApiClient(options);
97
+ const version = options.release ?? '__debug_id__';
98
+ await client.createRelease(version, { commit: options.commit, env: options.env });
99
+ const allIds = new Set();
100
+ for (let offset = 0; offset < files.length; offset += 50) {
101
+ const batch = files.slice(offset, offset + 50);
102
+ let uploads;
103
+ for (let generation = 0; ; generation++) {
104
+ uploads = validatePresign(
105
+ await client.presign(
106
+ version,
107
+ batch.map(({ bytes, ...metadata }) => metadata),
108
+ ),
109
+ batch,
110
+ );
111
+ for (const item of uploads) {
112
+ if (allIds.has(item.id)) throw new Error('Duplicate upload ID across batches');
113
+ allIds.add(item.id);
114
+ }
115
+ try {
116
+ await concurrent(uploads, concurrency, (file) =>
117
+ client.put(file.upload_url, file.headers, file.bytes),
118
+ );
119
+ break;
120
+ } catch (err) {
121
+ // S3 URLs expire. Request new IDs/URLs, rather than retrying an expired signature.
122
+ if (err.status !== 403 || generation >= client.retries) throw err;
123
+ }
124
+ }
125
+ const confirmed = await client.confirm(uploads.map((file) => file.id));
126
+ if (!Array.isArray(confirmed?.files) || confirmed.files.length !== uploads.length)
127
+ throw new Error('Incomplete confirmation response');
128
+ for (const file of uploads) {
129
+ const matching = confirmed.files.filter((item) => item.id === file.id);
130
+ if (
131
+ matching.length !== 1 ||
132
+ matching[0].status !== 'ready' ||
133
+ matching[0].debug_id !== file.debug_id ||
134
+ matching[0].size_bytes !== file.size_bytes
135
+ )
136
+ throw new Error('Confirmation metadata mismatch');
137
+ }
138
+ }
139
+ return { uploaded: files.length, debugIds: [...debugIds] };
140
+ }
141
+
142
+ export async function uploadDirectory(directory, options) {
143
+ const pairs = await collectPairs(directory);
144
+ const maps = pairs.map((pair) => {
145
+ const map = JSON.parse(pair.rawMap);
146
+ if (!map.debugId) throw new Error('Run sourcemaps inject before upload');
147
+ inject(pair.code, map); // Verify the existing ID still describes these exact JavaScript bytes.
148
+ return { filename: pair.filename, map };
149
+ });
150
+ const result = await uploadMaps(maps, options);
151
+ if (options.deleteAfter) {
152
+ // Validate every file before deleting any, including changes made during network requests.
153
+ for (const pair of pairs) {
154
+ if (
155
+ (await lstat(pair.mapPath)).isSymbolicLink() ||
156
+ (await realpath(pair.mapPath)) !== pair.mapPath ||
157
+ (await readFile(pair.mapPath, 'utf8')) !== pair.rawMap ||
158
+ (await readFile(pair.jsPath, 'utf8')) !== pair.code
159
+ )
160
+ throw new Error('Output changed during upload; maps were not deleted');
161
+ }
162
+ for (const pair of pairs) await unlink(pair.mapPath);
163
+ }
164
+ return result;
165
+ }