diff --git a/.lore.md b/.lore.md index d4f2071f4..ba33fda2b 100644 --- a/.lore.md +++ b/.lore.md @@ -5,11 +5,14 @@ ### Architecture -* **403/401 enrichment pipeline in infrastructure.ts — centralized, no interactive auto-fix**: \`src/lib/api/infrastructure.ts\` centralizes HTTP error enrichment. \`enrichDetail()\` dispatches: 403→\`enrich403Detail\`, 401→\`enrich401Detail\`, others pass raw. \`enrich403Detail()\` three branches: (1) \`rawDetail.includes('disabled this feature')\` → org-policy message (not auth issue); (2) \`isEnvTokenActive()\` → \`extractRequiredScopes(rawDetail)\`; scopes found → definite missing-scope message; no scopes → hedged message; (3) OAuth → re-auth suggestion. \`throwApiError()\` and \`throwRawApiError()\` both set \`ApiError.enriched403=true\`. No interactive auto-fix. \`buildPermissionError()\` in \`project/delete.ts\` NEVER suggests \`sentry auth login\` — re-auth via OAuth won't change permissions; issue is insufficient org role or custom token missing \`project:admin\` scope. OAuth \`auth login\` always grants required scopes, so scope hints only apply to env-var tokens. +* **403/401 enrichment pipeline in infrastructure.ts — centralized, no interactive auto-fix**: \`src/lib/api/infrastructure.ts\` centralizes HTTP error enrichment. \`enrichDetail()\` dispatches: 403→\`enrich403Detail\`, 401→\`enrich401Detail\`, others pass raw. \`enrich403Detail()\` three branches: (1) \`rawDetail.includes('disabled this feature')\` → org-policy message; (2) \`isEnvTokenActive()\` → \`extractRequiredScopes(rawDetail)\`; scopes found → definite missing-scope message; no scopes → hedged message; (3) OAuth → re-auth suggestion. \`throwApiError()\` and \`throwRawApiError()\` both set \`ApiError.enriched403=true\`. No interactive auto-fix. \`buildPermissionError()\` in \`project/delete.ts\` NEVER suggests \`sentry auth login\` — re-auth via OAuth won't change permissions. OAuth \`auth login\` always grants required scopes, so scope hints only apply to env-var tokens. 401 errors: fix is always re-authenticate — scope hints do NOT apply. * **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth token precedence in \`src/lib/db/auth.ts\`: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. \`runInteractiveLogin\` catches OAuth flow errors internally and returns falsy on failure; login command sets \`process.exitCode = 1\` and returns normally (does NOT reject). Tests expecting \`rejects.toThrow()\` will fail — assert via fetch-call inspection instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var. + +* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24] → dist-build/bin.js → \[fossilize --no-bundle --hole-punch] → Node SEA binary → gzip\`. CRITICAL ORDER: hole-punch BEFORE signing — hole-punching after signing invalidates macOS code signature (AMFI SIGKILL). fossilize 0.8.0+ runs hole-punch via \`--hole-punch\` between chmod and sign+notarize. Strip debug symbols handled inside fossilize (v0.7.0+). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails. UPX RULED OUT — destroys ELF notes. ALL\_TARGETS: darwin-arm64/x64, linux-arm64/x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. \`FOSSILIZE\_SIGN=y\` on push to main/release. \`useSnapshot:true\` BROKEN; \`useCodeCache:true\` ~15% startup improvement. fossilize 0.8.1 fixes cross-compile strip crash. + * **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. Telemetry opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before imports. Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE ... RETURNING\`. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add install-script-only vars with \`installOnly: true\`. @@ -26,16 +29,13 @@ * **SDK invoke path bypasses Stricli parsing — no defaults, no parsePeriod**: \`src/lib/sdk-invoke.ts\` \`buildInvoker()\` calls command \`func()\` directly with pre-built flags, skipping Stricli's \`parseInputsForFlag\`. Stricli's default application (including \`parsePeriod('90d')\` for \`kind:'parsed'\` flags) never runs. Systemic fix: refactor \`resolveCommand()\` to cache BOTH the loader AND \`target.parameters.flags\`. In \`buildInvoker\`, before each \`func.call\`, iterate flags: for each with \`kind:'parsed'\` and a \`default\`, if SDK caller passed \`undefined\`, call \`flag.parse(flag.default)\`. \`cleanRawFlags()\` strips only injected globals (\`log-level\`, \`verbose\`, \`org\`, \`project\`) — \`ALWAYS\_STRIP = new Set(\[LOG\_LEVEL\_KEY])\` strips unconditionally; command never sees \`log-level\`. Command-defined flags like \`period\`, \`limit\`, \`query\`, \`json\`, \`fields\` pass through intact. \`issue list\` defines its own \`period\` flag inline (\`default: '90d'\`), not via shared \`LIST\_PERIOD\_FLAG\` (\`default: '7d'\`). -* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` via \`getControlSiloUrl()\`. (3) Chunk upload returns camelCase (\`chunkSize\`) — exception to snake\_case. (4) 204/205 responses throw \`ApiError\` not \`TypeError\`. (5) Magic \`@\` selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\`; \`SELECTOR\_MAP\` case-insensitive. (6) \`issue resolve --in\`: omitted→immediate, \`\\`→inRelease, \`@next\`→inNextRelease, \`@commit\`→auto-detect git HEAD. (7) 403 is NOT retryable (only 408/429/5xx are). (8) \`ApiError.enriched403=true\` set by \`throwApiError\`/\`throwRawApiError\` — command-layer code checks this to avoid double-enriching. Repo matching uses \`listRepositoriesCached\` (7-day SQLite cache, schema v14); always use \`listAllRepositories\` (paginated) — never \`listRepositories\` (caps ~25). +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` via \`getControlSiloUrl()\`. (3) Chunk upload returns camelCase (\`chunkSize\`) — exception to snake\_case. (4) 204/205 responses throw \`ApiError\` not \`TypeError\`. (5) Magic \`@\` selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\`; \`SELECTOR\_MAP\` case-insensitive. (6) \`issue resolve --in\`: omitted→immediate, \`\\`→inRelease, \`@next\`→inNextRelease, \`@commit\`→auto-detect git HEAD. (7) 403 is NOT retryable (only 408/429/5xx are). (8) \`ApiError.enriched403=true\` set by \`throwApiError\`/\`throwRawApiError\`. Repo matching uses \`listRepositoriesCached\` (7-day SQLite cache); always use \`listAllRepositories\` (paginated) — never \`listRepositories\` (caps ~25). \`classifySilenced\` only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\`. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\`. - -* **sentry-client.ts: retry loop, timeout, backoff, and body-snapshot behavior**: \`src/lib/sentry-client.ts\`: \`REQUEST\_TIMEOUT\_MS=30\_000\`, \`MAX\_RETRIES=2\`, \`MAX\_BACKOFF\_MS=10\_000\`. \`RETRYABLE\_STATUS\_CODES=\[408,429,500,502,503,504]\` — 403 is NOT retryable. \`backoffDelay(attempt)=Math.min(1000\*2\*\*attempt, MAX\_BACKOFF\_MS)\`. \`ENDPOINT\_TIMEOUT\_OVERRIDES\`: \`/\\/autofix\\/?/\`→120s. \`buildAttemptFactory\`: \`Request\`→cloned per attempt; \`ReadableStream\` body→drained to \`ArrayBuffer\` snapshot (preserves multipart boundary for sourcemap uploads); other bodies pass through. \`handleUnauthorized\`: returns \`false\` if \`RETRY\_MARKER\_HEADER\` already set (prevents infinite 401 loop). Retry loop always returns \`'done'\` or \`'throw'\` — \`'Exhausted all retry attempts'\` throw is unreachable. \`fetchWithRetry\` calls \`refreshToken()\` upfront before loop. + +* **sentry local command: Hono+Spotlight SDK server with SSE tail output**: \`sentry local\` (default: \`serve\`) and \`sentry local run\` — both \`auth: false\`. Default port 8969. Uses \`@spotlightjs/spotlight/sdk\` (\`createSpotlightBuffer\`/\`pushToSpotlightBuffer\`) for envelope buffering; custom Hono HTTP server for ingest. Endpoints: \`POST /stream\`, \`POST /api/:projectId/envelope\[/]\`, \`GET /stream\` (SSE), \`GET /health\`. CORS restricted to localhost origins only. Browser SDK \`sendBeacon\` workaround: overrides \`text/plain\` → \`application/x-sentry-envelope\` when \`sentry\_client\` query param starts with \`sentry.javascript.browser\`. \`sentry local run\` injects \`SENTRY\_SPOTLIGHT\`, \`NEXT\_PUBLIC\_SENTRY\_SPOTLIGHT\`, \`SENTRY\_TRACES\_SAMPLE\_RATE=1\` into child env. Attach mode: if server already running, connects as SSE consumer (manual SSE parser — no \`EventSource\`). Formatters in \`src/lib/formatters/local.ts\`: \`sanitize()\` strips ANSI/control/bidi chars; source inferred from \`sdk.name\` → \`\[SERVER]\`/\`\[BROWSER]\`/\`\[MOBILE]\`. -* **src/bin.ts: SQLite warning suppression, stream error handling, and safety net**: \`src/bin.ts\` (47 lines): (1) SQLite \`ExperimentalWarning\` suppressed by patching \`process.emit\` BEFORE any import — filters \`event==='warning'\` + \`args\[0].name==='ExperimentalWarning'\` + \`args\[0].message.includes('SQLite')\`. (2) \`handleStreamError(err)\`: \`EPIPE\`→\`process.exit(0)\` (normal pipe close e.g. \`| head\`); \`EIO\`→\`process.exit(1)\` (non-recoverable); others re-thrown. Registered on both \`process.stdout\` and \`process.stderr\`. (3) \`startCli().catch(() => { process.exitCode = 1; })\` is a safety net only — \`startCli\` handles its own errors. - - -* **src/cli.ts middleware stack: seerTrial → rcImport → autoAuth (innermost-first)**: \`src/cli.ts\` middleware order: \`\[seerTrialMiddleware, rcImportMiddleware, autoAuthMiddleware]\` — applied innermost-first so \`autoAuthMiddleware\` is outermost (catches errors from command AND inner retries). \`rcImportMiddleware\`: fires only on \`AuthError{reason:'not\_authenticated'}\` + \`!skipAutoAuth\` + \`isatty(0)\`; on decline re-throws so \`autoAuthMiddleware\` can offer OAuth. \`autoAuthMiddleware\`: catches \`not\_authenticated|expired\` + \`!skipAutoAuth\` + \`isatty(0)\` — uses \`isatty(0)\` not \`process.stdin.isTTY\` (can be undefined in Bun). \`promptImportConsent()\`: only \`false\`→\`'declined'\` permanently suppresses; \`Symbol(clack:cancel)\`→\`'cancelled'\` does not suppress. +* **src/bin.ts: SQLite warning suppression, stream error handling, and safety net**: \`src/bin.ts\`: (1) SQLite \`ExperimentalWarning\` suppressed by patching \`process.emit\` BEFORE any import — filters \`event==='warning'\` + \`args\[0].name==='ExperimentalWarning'\` + \`args\[0].message.includes('SQLite')\`. (2) \`handleStreamError(err)\`: \`EPIPE\`→\`process.exit(0)\`; \`EIO\`→\`process.exit(1)\`; others re-thrown. Registered on both \`process.stdout\` and \`process.stderr\`. (3) \`startCli().catch(() => { process.exitCode = 1; })\` is a safety net only. (4) \`src/cli.ts\` middleware order: \`\[seerTrialMiddleware, rcImportMiddleware, autoAuthMiddleware]\` — innermost-first so \`autoAuthMiddleware\` is outermost. \`rcImportMiddleware\`: fires on \`AuthError{reason:'not\_authenticated'}\` + \`!skipAutoAuth\` + \`isatty(0)\`. \`autoAuthMiddleware\`: catches \`not\_authenticated|expired\` + \`!skipAutoAuth\` + \`isatty(0)\` (not \`process.stdin.isTTY\`). \`promptImportConsent()\`: only \`false\`→\`'declined'\` permanently suppresses. ### Decision @@ -44,6 +44,9 @@ ### Gotcha + +* **--json schema stability: collapse=organization drops nested org fields**: --json schema + response cache gotchas: (1) \`?collapse=organization\` shrinks \`organization\` to \`{id,slug}\` — silent --json regression. \`jsonTransform\` re-hydrates \`organization.name\` via \`resolveOrgDisplayName\` against \`org\_regions\` cache. (2) \`buildCacheKey()\` normalizes URL with sorted query params, so \`invalidateCachedResponse(baseUrl)\` misses entries with query suffixes. Use \`invalidateCachedResponsesMatching(prefix)\` (raw \`startsWith()\`); \`buildApiUrl()\` always emits trailing slash → safe prefix. (3) When \`jsonTransform\` is set, \`jsonExclude\` and \`filterFields\` are NOT applied — transform must call \`filterFields(result, fields)\` and omit excluded keys itself. (4) \`atomicWriteCacheFile\` writes to \`${finalPath}.${process.pid}.${randomUUID()}.tmp\` then renames — atomic on POSIX, near-atomic on Windows (same volume). Orphaned \`.tmp\` files on crash not cleaned by \`cleanupCache()\` (filters \`.json\` only); swept by stale-tmp pass (>60s threshold). Corrupt files pushed to \`entries\` with \`expired:true\` so eviction counts them. + * **existsSync+realpathSync TOCTOU: catch ENOENT instead**: Trap: \`if (!existsSync(p)) return resolve(p); return realpathSync(p)\` looks safe but has a TOCTOU race. Also: \`realpathSync\` inside async is inconsistent. Fix: call \`await realpath(p)\` (node:fs/promises) directly; catch \`ENOENT\` to fall back to \`resolve(p)\`; log non-ENOENT errors via \`logger.debug(msg, error)\` before falling back. When mocking in vitest, mock \`node:fs/promises\` not \`node:fs\`. RELATED: In cleanup/unlink catch blocks, only log non-ENOENT errors — \`ENOENT\` during cleanup is expected. Pattern: \`if ((error as NodeJS.ErrnoException).code !== 'ENOENT') logger.debug(msg, error)\`. Pre-existing silent \`catch { // Ignore }\` blocks must be fixed to log non-ENOENT errors. Confirmed fixed in PR #1046 (\`fix/install-binary-symlink-self-copy\`). @@ -53,33 +56,33 @@ * **parseWithHash short-circuits before the main validateResourceId guard — must self-validate (CLI-1G1)**: GitHub-style \`org/project#SHORTID\` issue identifiers handled by \`parseWithHash()\` in \`src/lib/arg-parsing.ts\`, inserted in \`parseIssueArg\` AFTER the \`@\`-selector block and BEFORE the \`validateResourceId(input.replace(/\\//g,''))\` guard (line ~1115, which rejects \`#\`). Because it runs before that guard, \`parseWithHash\` MUST validate BOTH the project prefix AND the fragment itself. \`validateResourceId\` permits \`:\`, so \`:\` mixed with \`#\` is rejected explicitly. Semantics: \`org/project#ID\` → delegates to \`parseWithSlash('org/project/ID')\`; \`project#ID\` → \`project-search\` via \`parseProjectIdentifier\`; \`#ID\` → bare identifier via \`parseBareIssueIdentifier\`. \`parseProjectIdentifier\` is shared with \`parseWithColon\`. BEHAVIORAL CHANGE: \`CLI-G#anchor\` went from \`ValidationError\` → \`project-search{projectSlug:'cli-g', suffix:'ANCHOR'}\`. Test at \`arg-parsing.test.ts\` injection-hardening block updated accordingly. - -* **strip fails on Node SEA binaries — must strip BEFORE fossilize injection**: Node SEA binary build gotchas: (1) Strip BEFORE fossilize injection — after postject injects SEA blob, \`strip\` fails. fossilize handles stripping internally since v0.7.0. (2) UPX destroys ELF notes — NODE\_SEA\_BLOB stored as ELF note; use \`strip --strip-unneeded\` instead. UPX RULED OUT. (3) \`useSnapshot: true\` BROKEN. \`useCodeCache: true\` gives ~15% startup improvement but is platform-specific. (4) Suppress \`ExperimentalWarning: SQLite\` at very top of \`src/bin.ts\` BEFORE any imports. (5) Cross-strip from Linux to macOS silently fails; macOS strip requires re-codesigning. (6) CRITICAL ORDER: hole-punch MUST happen BEFORE signing — hole-punching after signing invalidates macOS code signature (AMFI SIGKILL). fossilize 0.8.0+ runs hole-punch via \`--hole-punch\` flag between chmod and sign+notarize. fossilize 0.8.1 fixes cross-compile strip crash. (7) Size: linux-x64 Node 24: raw ~108 MiB, gzip ~30 MiB. AVOID: \`--disable-single-executable-application\`, \`--v8-lite-mode\`, \`--without-ssl\`. - * **useTestConfigDir afterEach: never delete CONFIG\_DIR\_ENV\_VAR — always restore previous value**: Trap: deleting \`process.env.SENTRY\_CONFIG\_DIR\` in \`afterEach\` looks like proper cleanup. But \`preload.ts\` always sets \`SENTRY\_CONFIG\_DIR\`, so \`savedConfigDir\` is always defined — deleting it causes subsequent test files' module-level code or \`beforeEach\` hooks to read \`undefined\`. Fix: always restore the previous value, never delete. The \`else { delete process.env\[CONFIG\_DIR\_ENV\_VAR] }\` branch is intentionally omitted in \`test/helpers.ts\` \`useTestConfigDir\`. Same principle applies in \`test/fixture.ts\` \`setAuthToken()\` finally block — the delete there is acceptable only because it's a scoped try/finally restore, not a test lifecycle hook. + +* **Vitest worker pool requires pool:forks + UV\_USE\_IO\_URING=0 on GitHub Actions**: Vitest/CI gotchas: (1) GitHub Actions io\_uring crashes Node.js workers (exit 134/SIGABRT) — fix: \`pool: 'forks'\` in \`vitest.config.ts\` AND \`UV\_USE\_IO\_URING=0\` in CI. (2) Vitest 4: options must be second arg: \`test(name, { timeout }, fn)\`. (3) \`http.createServer(async ...)\` — unhandled rejections crash test server; wrap body in try/catch. (4) \`node:sqlite\` requires \`--experimental-sqlite\` on Node 22. (5) Lazy \`require()\` in test fixtures bypasses Vite's \`.js→.ts\` resolver — use top-level \`import\`. (6) \`spawn(process.execPath, \[workerScript.ts])\` fails under vitest/Node — use \`spawn('tsx', \[workerScript.ts])\`. (7) ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. \`test:e2e\` runs WITHOUT \`--isolate --parallel\`; \`test:unit\` runs WITH. \`mock.module()\` pollutes module registry — put in \`test/isolated/\`. + + +* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Literal prefilter is FILE-LEVEL gate; per-line verify breaks cross-newline patterns. (2) \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\`. (3) Wake-latch race: use latched \`pendingWake\` flag. (4) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (5) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator. Worker pool: lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads (~40% faster). \`new Worker(new URL(...))\` HANGS in SEA binaries — use Blob+URL.createObjectURL. \`ref()\`/\`unref()\` idempotent — only unref when \`inflight\` drops to 0. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. + ### Pattern - -* **Biome lint: use bare \`return\` instead of \`return undefined\` (noUselessUndefined)**: Biome's \`lint/nursery/noUselessUndefined\` rule flags \`return undefined;\` — always use bare \`return;\` in functions returning \`T | undefined\`. Auto-fixable but must be caught before CI. Also: Biome cognitive complexity limit is 15 — use helpers like \`applyGroupLimitAutoDefault\` to keep \`buildCommand\` under the limit. + +* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via pnpm patch: \`patchedDependencies\` in \`package.json\` under \`pnpm\` config block strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Always import from \`@sentry/node-core/light\`. Bumping SDK: remove old patches, \`pnpm patch @sentry/core\`, edit, \`pnpm patch-commit\`; repeat for node-core. \`check:patches\` validates version alignment AND content (greps installed files for known-bad strings). \`@stricli/core\` patch targets \`dist/index.js\` (ESM) only — \`dist/index.cjs\` intentionally unpatched. When bumping \`@stricli/core\`, patch line numbers shift — always verify offsets against the new \`dist/index.js\`. KNOWN: Stricli \`-H\` removal patch is fragile — any Stricli version bump will break it; upstream fix will NOT be accepted. - -* **Preserve ApiError type so classifySilenced can silence 4xx errors**: Preserve ApiError type for classifySilenced: \`classifySilenced\` only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\` and causes 403s to be captured. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\`. \`ValidationError\` without \`field\` collapses unfielded errors into one fingerprint; always pass \`field\`. \`ApiError.enriched403=true\` set by \`throwApiError\`/\`throwRawApiError\` — command-layer code checks this to avoid double-enriching. For graceful-fallback operations, use \`withTracingSpan\` (\`onlyIfParent: true\`) and \`captureException\` with \`level: 'warning'\` for non-fatal errors. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\`. 401 errors: fix is always re-authenticate or regenerate token — scope hints do NOT apply to 401s. + +* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. \`issue list --limit\` is global total: \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus. \`trimWithProjectGuarantee\` ensures ≥1 issue per project. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Hidden global \`--org\`/\`--project\` flags: defined in \`GLOBAL\_FLAGS\`, \`mergeGlobalFlags()\` injects hidden flag shapes, \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. \`applyGroupLimitAutoDefault\` helper keeps \`buildCommand\` under Biome's cognitive complexity limit of 15. -* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`.call()\` LSP false-positives pass \`tsc --noEmit\`. When API functions are renamed, update both spy target AND mock return shape. \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. Vitest: use \`vi.spyOn\` / mock fetch via \`globalThis.fetch\`. \`mock.module()\` pollutes module registry — put in \`test/isolated/\` and run via \`test:isolated\`. ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. Variadic flag pattern: \`kind: "parsed", parse: String, variadic: true, optional: true\` (see \`src/commands/explore.ts\`). Aliases: \`aliases: { s: "scope" }\` style — place INSIDE \`parameters\` (sibling to \`flags\`), NOT at top-level of \`buildCommand\` options (causes TS2353). \`-s\` is free in \`auth/login.ts\` (no conflict). +* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`.call()\` LSP false-positives pass \`tsc --noEmit\`. When API functions are renamed, update both spy target AND mock return shape. \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. Vitest: use \`vi.spyOn\` / mock fetch via \`globalThis.fetch\`. \`mock.module()\` pollutes module registry — put in \`test/isolated/\`. ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. Variadic flag pattern: \`kind: 'parsed', parse: String, variadic: true, optional: true\`. Aliases: \`aliases: { s: 'scope' }\` — place INSIDE \`parameters\` (sibling to \`flags\`), NOT at top-level of \`buildCommand\` options (causes TS2353). \`-s\` is free in \`auth/login.ts\`. ### Preference - -* **Always document architectural invariants as code comments at the point of enforcement**: The user consistently embeds precise behavioral assertions directly in source code as comments at the exact lines where invariants are enforced (e.g., 'LOG\_LEVEL\_KEY is always stripped', '--fields is always injected when output: { human: ... } regardless', 'hints live exclusively on the return value — never on individual yields'). These comments are written in declarative, absolute language ('always', 'never', 'only') and reference the specific mechanism enforcing the rule. When reading or writing code, always add or preserve such inline comments at enforcement points — not in separate docs or at call sites — so the invariant is self-documenting where it matters most. - - -* **Always wait for CI and resolve bot comments before merging PRs**: Always wait for CI and resolve bot comments before merging PRs: Monitor CI checks (Unit Tests, E2E Tests, warden, Cursor Bugbot, Seer, Secret Scan) and wait for all to complete. Address any bot comments before merging. In getsentry/cli, human approval is not required — only passing CI. When merge state is UNKNOWN, wait and re-check. Do not declare a PR merged until CI is confirmed green and all bot feedback is resolved. + +* **Always migrate Bun-specific APIs and tooling to Node.js equivalents**: Bun→Node.js migration complete. Replace Bun APIs: \`Bun.spawn\`→\`node:child\_process\`, \`Bun.sleep\`→\`node:timers/promises\`, \`bun:sqlite\`→\`node:sqlite\`, \`bun run\`→\`pnpm run\`/\`tsx\`, \`Bun.file().text()\`→\`readFile(path,'utf-8')\`, \`Bun.write()\`→\`writeFile()\`, \`Bun.which()\`→Node-compatible pkg, \`Bun.Glob\`→\`tinyglobby\`/\`picomatch\`, \`Bun.randomUUIDv7()\`→\`uuidv7\`, \`Bun.semver.order()\`→\`semver.compare()\`, \`Bun.zstdCompressSync()\`→zlib/\`zstd-napi\`. Exception: \`script/build.ts\` uses fossilize (not \`Bun.build\`) and stays on Bun for build-binary CI job. \`script/bundle.ts\` uses esbuild via tsx. \`packageManager\`: \`pnpm@10.11.0\`. bun.lock deleted, vitest.config.ts added. \`.npmrc\`: \`node-linker=hoisted\`. \`patchedDependencies\` moved to \`pnpm\` config block. \`NODE\_VERSION='lts'\`. \`new Worker(new URL(...))\` HANGS in SEA — use Blob+URL.createObjectURL. -* **Prefers Bun-native APIs; use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS = 50; architecture tree documented; error exit code ranges: 1x=auth**: Project conventions (AGENTS.md): Use \`pnpm run\`/\`pnpm install\`/\`pnpm add -D\`. Use \`buildCommand\` from \`lib/command.js\` (never \`@stricli/core\` directly); \`buildRouteMap\` from \`lib/route-map.js\`. Silent catch blocks prohibited — every catch must re-throw, call \`log.debug()\`, or return a fallback with \`log.debug()\`. Every new \`src/lib/\*\*/\*.ts\` must start with module-level JSDoc. Test isolation via \`useTestConfigDir()\`. Prefer property-based/model-based tests (fast-check); \`DEFAULT\_NUM\_RUNS=50\`. Error exit codes: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. Use \`EXIT.\*\` constants — never hardcode numeric exit codes outside errors.ts. All packages in \`devDependencies\` (CI enforces via \`check:deps\`). NEVER merge if CI failing. \`new Worker(new URL(...))\` HANGS in SEA — use Blob+URL.createObjectURL. When creating a new check script, add to BOTH \`package.json\` AND \`.github/workflows/ci.yml\`. Follow existing codebase patterns; flag and justify deviations. Before merging, run structured checklist review. +* **Prefers Bun-native APIs; use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS = 50; architecture tree documented; error exit code ranges: 1x=auth**: Project conventions (AGENTS.md): Use \`pnpm run\`/\`pnpm install\`/\`pnpm add -D\`. Use \`buildCommand\` from \`lib/command.js\` (NEVER \`@stricli/core\` directly); \`buildRouteMap\` from \`lib/route-map.js\`. Silent catch blocks prohibited — every catch must re-throw, call \`log.debug()\`, or return a fallback with \`log.debug()\`. Every new \`src/lib/\*\*/\*.ts\` must start with module-level JSDoc. Test isolation via \`useTestConfigDir()\`. Prefer property-based/model-based tests (fast-check); \`DEFAULT\_NUM\_RUNS=50\`. Error exit codes: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. Use \`EXIT.\*\` constants — never hardcode numeric exit codes. All packages in \`devDependencies\`. NEVER merge if CI failing. Add new check scripts to BOTH \`package.json\` AND \`.github/workflows/ci.yml\`. \`bun run lint\` for local lint. \`return undefined\` → bare \`return\`. ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. Always write tests alongside implementation. Document design decisions in \`.lore.md\`. -* **Telemetry implementation invariants: handler cleanup, uid check, non-blocking, redaction**: Telemetry invariants (\`src/lib/telemetry.ts\`): (1) \`initSentry()\` ALWAYS removes \`currentBeforeExitHandler\` before registering new one. (2) \`isOwnedByRoot()\` returns \`false\` immediately on Windows. (3) NEVER block CLI execution for telemetry — all drains are best-effort, wrapped in try/catch. (4) \`SENSITIVE\_ARGV\_FLAGS\` (\`token\`, \`auth-token\`) NEVER sent — \`redactArgv()\` handles both \`--flag=value\` and \`--flag \\` forms; raw \`process.argv\` must NEVER reach telemetry without \`redactArgv()\`. \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\` to skip \`@sentry/node-core\` lazy-require (~280ms). \`reportUnknownCommand()\` wrapped in try/catch — telemetry must never crash CLI. \`SENSITIVE\_ARGV\_FLAGS\` in \`cli.ts\` is a superset of \`SENSITIVE\_FLAGS\` in \`telemetry.ts\`. +* **Telemetry implementation invariants: handler cleanup, uid check, non-blocking, redaction**: Telemetry invariants (\`src/lib/telemetry.ts\`): (1) \`initSentry()\` ALWAYS removes \`currentBeforeExitHandler\` before registering new one. (2) \`isOwnedByRoot()\` returns \`false\` immediately on Windows. (3) NEVER block CLI execution — all drains are best-effort, wrapped in try/catch. (4) \`SENSITIVE\_ARGV\_FLAGS\` (\`token\`, \`auth-token\`) NEVER sent — \`redactArgv()\` handles both \`--flag=value\` and \`--flag \\` forms; raw \`process.argv\` must NEVER reach telemetry without \`redactArgv()\`. \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\` to skip \`@sentry/node-core\` lazy-require (~280ms). \`reportUnknownCommand()\` wrapped in try/catch. \`SENSITIVE\_ARGV\_FLAGS\` in \`cli.ts\` is a superset of \`SENSITIVE\_FLAGS\` in \`telemetry.ts\`. tool\_calls table (v31): call\_id, tool, status, error\_type, error\_message, duration\_ms, session\_id, project\_path. diff --git a/AGENTS.md b/AGENTS.md index 0287d7041..798b0250b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1002,33 +1002,5 @@ duplication and staleness that caused five overlapping PRs to pile up: ## Long-term Knowledge -### Architecture - - -* **env-registry.ts drives --help env var section + docs**: \`src/lib/env-registry.ts\` (\`ENV\_VAR\_REGISTRY\`) is the single source for all env vars the CLI honors. Entries have \`{name, description, example?, defaultValue?, installOnly?, topLevel?, briefDescription?}\`. \`topLevel: true\` + \`briefDescription\` surfaces in \`sentry --help\` Environment Variables section (via \`formatEnvVarsSection()\` in \`help.ts\`) and in \`sentry help --json\` as \`envVars\` array on the full-tree envelope. Docs generator consumes the full registry for \`configuration.md\`. When adding a new env var, add it here with \`installOnly: true\` if install-script-only. Reserve \`topLevel: true\` for core-path vars only (auth, targeting, URL, key display/logging). - - -* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, etc.) live in \`hex-id.ts\`. - - -* **Three Sentry APIs for span custom attributes with different capabilities**: \*\*Three Sentry span APIs with different capabilities\*\*: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\&field=X\` — list/search. Critical: \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\`/\`-F\` list, not \`Object.keys()\`. See \`orderFieldNames()\` in \`explore.ts\`. - -### Gotcha - - -* **api.ts: plain Error throws inside func() bypass CliError handling**: \*\*api.ts: plain Error throws inside func() bypass CliError handling\*\*: \`src/commands/api.ts\` throws plain \`new Error(...)\` in validation paths called from \`func()\` — this bypasses \`app.ts\`'s \`instanceof CliError\` check, causing user to see stack traces AND Sentry bug reports. Fix: use \`ValidationError\` for user-input errors inside \`func()\`. Plain \`Error\` is only OK in Stricli \`parse:\` callbacks where Stricli catches them. - - -* **Biome lint differs between local lint:fix and CI lint**: \*\*Biome lint differs between local lint:fix and CI lint\*\*: \`lint:fix\` hides CI issues; always run \`bun run lint\` before pushing. Key gotchas: (1) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (2) \`noIncrementDecrement\` — use \`i += 1\`. (3) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers, don't biome-ignore. (4) \`noUselessUndefined\` then \`noEmptyBlockStatements\` — use \`function noop() {}\`. (5) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`. - - -* **buildCommand wrapper: loader() returns wrapped async fn, not the generator**: \*\*buildCommand wrapper: loader() returns wrapped async fn, not generator\*\*: \`cmd.loader()\` returns the wrapped async fn, not \`async \*func()\`. Wrapper iterates generator internally and writes to \`ctx.stdout\`. Tests: \`await func.call(ctx, flags, ...args)\` like a promise — don't iterate. Auth guard runs first; \`test/preload.ts:100\` sets fake \`SENTRY\_AUTH\_TOKEN\`. Tests must save/restore only env vars they mutate. - -### Pattern - - -* **Merging mock.module() test files with static-import counterparts**: \*\*Bun test mocking traps\*\*: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — pre-existing static imports won't re-bind. (3) Destructured imports capture binding at load. (4) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. - - -* **URL-encoded paren assertions: decode before contains-check**: \*\*URL-encoded paren assertions in tests\*\*: Aggregate field names like \`count()\` become \`count%28%29\` via \`encodeURIComponent\` — use \`expect(decodeURIComponent(url)).toContain("field=count()")\`. Sentry pagination Link header format: \`\; rel="next"; cursor="0:50:0"\` — cursor is in a separate attribute, NOT in URL query. Use \`parseSentryLinkHeader()\` from \`src/lib/api/infrastructure.ts\` to extract. +For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) (gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) in the project root. diff --git a/src/commands/sourcemap/upload.ts b/src/commands/sourcemap/upload.ts index 3f48352d4..e36d0f557 100644 --- a/src/commands/sourcemap/upload.ts +++ b/src/commands/sourcemap/upload.ts @@ -27,6 +27,7 @@ import { buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, + type InjectResult, injectDirectory, } from "../../lib/sourcemap/inject.js"; @@ -105,6 +106,102 @@ function stripPrefix(path: string, prefix: string): string { return path; } +/** Context shared across artifact-file construction for one upload. */ +type ArtifactContext = { + /** Absolute upload directory (paths are made relative to this). */ + resolvedDir: string; + /** URL prefix applied to every artifact URL (e.g. `"~/"`). */ + urlPrefix: string; + /** Directory prefix to strip from relative paths (may be empty). */ + pathPrefixToStrip: string; + /** True when `--no-rewrite` is set (upload as-is, no debug IDs injected). */ + noRewrite: boolean; +}; + +/** Compute a JS file's URL-space relative path (post-strip, forward slashes). */ +function jsRelativePath(jsPath: string, ctx: ArtifactContext): string { + const rel = relative(ctx.resolvedDir, jsPath).replaceAll("\\", "/"); + return ctx.pathPrefixToStrip ? stripPrefix(rel, ctx.pathPrefixToStrip) : rel; +} + +/** + * Build the `minified_source` + `source_map` artifact pair for a discovered + * file, dispatching on whether the sourcemap is inline or an external file. + */ +function buildArtifactPair( + result: InjectResult, + ctx: ArtifactContext +): ArtifactFile[] { + const { jsPath, map, debugId } = result; + const jsRelative = jsRelativePath(jsPath, ctx); + const debugIdField = debugId ? { debugId } : {}; + + if (map.kind === "inline") { + // Resolve the map bytes to upload: + // - rewrite path: inject produced the debug-ID-injected map. + // - --no-rewrite: upload the original inline map decoded at discovery. + // - rewrite aborted (directive not found): no `injectedMapContent` and not + // --no-rewrite → the JS was left unmodified, so upload nothing for it + // rather than attaching a debug ID / map the bundle doesn't carry. + let content = result.injectedMapContent; + if (!content) { + if (ctx.noRewrite) { + content = Buffer.from(map.decoded.json); + } else { + return []; + } + } + // Synthetic map URL — the server matches by debug ID, so the filename is + // cosmetic. Derived from the (post-strip) JS URL. + const mapRelative = `${jsRelative}.map`; + return [ + { + path: jsPath, + ...debugIdField, + type: "minified_source", + url: `${ctx.urlPrefix}${jsRelative}`, + sourcemapFilename: posixRelative(posixDirname(jsRelative), mapRelative), + }, + { + // path is informational for inline maps; content is used instead. + path: jsPath, + content, + ...debugIdField, + type: "source_map", + url: `${ctx.urlPrefix}${mapRelative}`, + }, + ]; + } + + // External map on disk. + let mapRelative = relative(ctx.resolvedDir, map.mapPath).replaceAll( + "\\", + "/" + ); + if (ctx.pathPrefixToStrip) { + mapRelative = stripPrefix(mapRelative, ctx.pathPrefixToStrip); + } + return [ + { + path: jsPath, + // Empty debugId when --no-rewrite: files uploaded without debug IDs, + // relying on release/URL-based matching instead. + ...debugIdField, + type: "minified_source", + url: `${ctx.urlPrefix}${jsRelative}`, + // Sourcemap header is resolved relative to the JS file's URL. Compute + // from post-strip URL-space paths so --strip-prefix doesn't break it. + sourcemapFilename: posixRelative(posixDirname(jsRelative), mapRelative), + }, + { + path: map.mapPath, + ...debugIdField, + type: "source_map", + url: `${ctx.urlPrefix}${mapRelative}`, + }, + ]; +} + export const uploadCommand = buildCommand({ docs: { brief: "Upload sourcemaps to Sentry", @@ -281,8 +378,14 @@ export const uploadCommand = buildCommand({ } const { org, project } = resolved; - const results = flags["no-rewrite"] - ? pairs.map((p) => ({ ...p, injected: false, debugId: "" })) + const results: InjectResult[] = flags["no-rewrite"] + ? pairs.map((p) => ({ + jsPath: p.jsPath, + map: p.map, + mapPath: p.map.kind === "external" ? p.map.mapPath : undefined, + injected: false, + debugId: "", + })) : await injectDirectory(dir, { extensions, ignoreMatcher, @@ -300,49 +403,24 @@ export const uploadCommand = buildCommand({ pathPrefixToStrip = `${pathPrefixToStrip}/`; } if (flags["strip-common-prefix"]) { - const allRelative = results.flatMap(({ jsPath, mapPath }) => [ - relative(resolvedDir, jsPath).replaceAll("\\", "/"), - relative(resolvedDir, mapPath).replaceAll("\\", "/"), - ]); + // Only the JS path participates for inline maps (no standalone .map file). + const allRelative = results.flatMap((r) => { + const rels = [relative(resolvedDir, r.jsPath).replaceAll("\\", "/")]; + if (r.mapPath) { + rels.push(relative(resolvedDir, r.mapPath).replaceAll("\\", "/")); + } + return rels; + }); pathPrefixToStrip = computeCommonPrefix(allRelative); } - const artifactFiles: ArtifactFile[] = results.flatMap( - ({ jsPath, mapPath, debugId }) => { - // Normalize to forward slashes for URLs (handles Windows backslashes) - let jsRelative = relative(resolvedDir, jsPath).replaceAll("\\", "/"); - let mapRelative = relative(resolvedDir, mapPath).replaceAll("\\", "/"); - - if (pathPrefixToStrip) { - jsRelative = stripPrefix(jsRelative, pathPrefixToStrip); - mapRelative = stripPrefix(mapRelative, pathPrefixToStrip); - } - - // Sourcemap header is resolved relative to the JS file's URL. - // Compute from post-strip URL-space paths so --strip-prefix - // doesn't break the reference. - const sourcemapRef = posixRelative( - posixDirname(jsRelative), - mapRelative - ); - return [ - { - path: jsPath, - // Empty debugId when --no-rewrite: files uploaded without debug IDs, - // relying on release/URL-based matching instead. - ...(debugId ? { debugId } : {}), - type: "minified_source" as const, - url: `${urlPrefix}${jsRelative}`, - sourcemapFilename: sourcemapRef, - }, - { - path: mapPath, - ...(debugId ? { debugId } : {}), - type: "source_map" as const, - url: `${urlPrefix}${mapRelative}`, - }, - ] satisfies ArtifactFile[]; - } + const artifactFiles: ArtifactFile[] = results.flatMap((result) => + buildArtifactPair(result, { + resolvedDir, + urlPrefix, + pathPrefixToStrip, + noRewrite: flags["no-rewrite"] ?? false, + }) ); await uploadSourcemaps({ @@ -353,12 +431,19 @@ export const uploadCommand = buildCommand({ files: artifactFiles, }); + // Count actually-uploaded pairs (one minified_source entry per pair). + // buildArtifactPair returns no entries for inline pairs whose rewrite was + // aborted, so this excludes skipped pairs that results.length would count. + const filesUploaded = artifactFiles.filter( + (f) => f.type === "minified_source" + ).length; + yield new CommandOutput({ org, project, release: flags.release, dist: flags.dist, - filesUploaded: results.length, + filesUploaded, }); }, }); diff --git a/src/lib/api/sourcemaps.ts b/src/lib/api/sourcemaps.ts index 41768a107..9c6ceccd3 100644 --- a/src/lib/api/sourcemaps.ts +++ b/src/lib/api/sourcemaps.ts @@ -77,8 +77,18 @@ export type AssembleResponse = z.infer; /** A source file to include in the artifact bundle. */ export type ArtifactFile = { - /** Filesystem path to the file. */ + /** + * Filesystem path to the file. Read from disk unless {@link ArtifactFile.content} + * is set; for inline sourcemaps (which have no `.map` file) this is + * informational only. + */ path: string; + /** + * In-memory file content. When set, {@link buildArtifactBundle} uses this + * instead of reading from `path`. Used for inline sourcemaps that have no + * standalone `.map` file on disk. + */ + content?: Buffer; /** Debug ID injected into this file (from {@link injectDebugId}). Omitted when uploading without rewriting. */ debugId?: string; /** @@ -359,7 +369,8 @@ export async function buildArtifactBundle( for (const file of files) { const bundlePath = urlToBundlePath(file.url); - const content = await readFile(file.path); + // Prefer in-memory content (inline sourcemaps); otherwise read from disk. + const content = file.content ?? (await readFile(file.path)); await zip.addEntry(bundlePath, content); } diff --git a/src/lib/sourcemap/debug-id.ts b/src/lib/sourcemap/debug-id.ts index 2371d4194..25608c2df 100644 --- a/src/lib/sourcemap/debug-id.ts +++ b/src/lib/sourcemap/debug-id.ts @@ -13,11 +13,17 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; +import { logger } from "../logger.js"; +import { + type DecodedInlineMap, + encodeInlineSourcemap, +} from "./inline-sourcemap.js"; + +const log = logger.withTag("sourcemap.debug-id"); /** Comment prefix used to identify an existing debug ID in a JS file. */ const DEBUGID_COMMENT_PREFIX = "//# debugId="; -/** Regex to extract an existing debug ID from a JS file. */ /** Regex to extract an existing debug ID from a JS file. @internal */ export const EXISTING_DEBUGID_RE = /\/\/# debugId=([0-9a-fA-F-]{36})/; @@ -60,6 +66,34 @@ export function getDebugIdSnippet(debugId: string): string { return `;!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`; } +/** + * Prepend the runtime IIFE snippet to a JS file's content, preserving a + * leading hashbang (`#!`) line if present. + * + * The snippet must run before any other code but after the hashbang, which + * must remain the first line for the file to stay executable. + * + * @param jsContent - Original JS file content + * @param snippet - The IIFE snippet from {@link getDebugIdSnippet} + * @returns The JS content with the snippet prepended + * @internal + */ +export function prependDebugIdSnippet( + jsContent: string, + snippet: string +): string { + if (jsContent.startsWith("#!")) { + const newlineIdx = jsContent.indexOf("\n"); + // Handle hashbang without trailing newline (entire file is the #! line) + const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1; + const hashbang = jsContent.slice(0, splitAt); + const rest = jsContent.slice(splitAt); + const sep = newlineIdx === -1 ? "\n" : ""; + return `${hashbang}${sep}${snippet}\n${rest}`; + } + return `${snippet}\n${jsContent}`; +} + /** * Inject a Sentry debug ID into a JavaScript file and its companion * sourcemap. @@ -112,54 +146,161 @@ export async function injectDebugId( newJs = jsContent; } else { // Full mode: prepend the runtime IIFE snippet (for user-facing injection). - const snippet = getDebugIdSnippet(debugId); - // Preserve hashbang if present, insert snippet after it - if (jsContent.startsWith("#!")) { - const newlineIdx = jsContent.indexOf("\n"); - // Handle hashbang without trailing newline (entire file is the #! line) - const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1; - const hashbang = jsContent.slice(0, splitAt); - const rest = jsContent.slice(splitAt); - const sep = newlineIdx === -1 ? "\n" : ""; - newJs = `${hashbang}${sep}${snippet}\n${rest}`; - } else { - newJs = `${snippet}\n${jsContent}`; - } + newJs = prependDebugIdSnippet(jsContent, getDebugIdSnippet(debugId)); } // Append debug ID comment at the end newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; // --- Mutate sourcemap --- - // Parse, adjust mappings, add debug ID fields - const map = JSON.parse(mapContent) as { - mappings: string; - sources?: (string | null)[]; - debug_id?: string; - debugId?: string; - }; + const map = JSON.parse(mapContent) as SourcemapJson; + mutateSourcemap(map, debugId, { offsetMappings: !skipSnippet }); + + // Write both files concurrently + await Promise.all([ + writeFile(jsPath, newJs), + writeFile(mapPath, JSON.stringify(map)), + ]); + + return { debugId, wasInjected: true }; +} - // Normalize Windows backslashes in the sources array so uploaded - // sourcemaps have consistent forward-slash paths regardless of build - // platform. Bundlers on Windows (esbuild, Bun) may produce paths like - // "src\\bin.ts". No-op on Linux/macOS. +/** Minimal shape of a sourcemap JSON object that we mutate during injection. */ +type SourcemapJson = { + mappings?: string; + sources?: (string | null)[]; + debug_id?: string; + debugId?: string; +}; + +/** + * Mutate a parsed sourcemap in place to carry a debug ID. + * + * - Normalizes Windows backslashes in `sources` to forward slashes so + * uploaded paths are platform-consistent (esbuild/Bun on Windows emit + * `"src\\bin.ts"`). No-op on Linux/macOS. + * - When `offsetMappings` is true, prepends one `;` to `mappings` to account + * for the injected IIFE snippet line (each `;` is a VLQ line boundary). + * - Sets both `debug_id` and `debugId` fields. + * + * @param map - The parsed sourcemap object (mutated in place) + * @param debugId - The debug ID to embed + * @param options.offsetMappings - Prepend a `;` to `mappings` (snippet added a line) + */ +function mutateSourcemap( + map: SourcemapJson, + debugId: string, + options: { offsetMappings: boolean } +): void { if (map.sources) { map.sources = map.sources.map((s) => (s ? s.replaceAll("\\", "/") : s)); } - - if (!skipSnippet) { - // Prepend one `;` to mappings — tells decoders "no mappings for the - // first line" (the injected snippet line). Each `;` in VLQ mappings - // represents a line boundary. + if (options.offsetMappings && typeof map.mappings === "string") { map.mappings = `;${map.mappings}`; } map.debug_id = debugId; map.debugId = debugId; +} - // Write both files concurrently - await Promise.all([ - writeFile(jsPath, newJs), - writeFile(mapPath, JSON.stringify(map)), - ]); +/** + * Regex matching a `//# sourceMappingURL=data:...;base64,...` directive at the + * **start of a line** (optionally indented). + * + * Anchored with `^` under the multiline flag so it only matches a directive + * that *begins* a line — mirroring discovery's line-based parser + * (`parseSourceMappingDirective`). This prevents rewriting a false-positive + * `data:` URL embedded mid-line inside a string/template literal while leaving + * the authoritative trailing directive untouched. Global so all matches can be + * iterated and only the **last** one rewritten (spec: last directive wins). + * + * @internal + */ +const INLINE_DIRECTIVE_RE = + /^[ \t]*\/\/[#@][ \t]*sourceMappingURL[ \t]*=[ \t]*data:application\/json(?:;charset=[\w-]+)?;base64,[A-Za-z0-9+/=]+/gm; - return { debugId, wasInjected: true }; +/** + * Inject a debug ID into a JS file whose sourcemap is inline (a base64 + * `data:` URL) rather than a companion `.map` file. + * + * The decoded map is provided by the caller (see `tryDecodeInlineSourcemap`). + * This performs the same JS mutations as {@link injectDebugId} (IIFE snippet + + * `//# debugId=` comment) and additionally re-encodes the debug-ID-injected + * map back into the `sourceMappingURL=data:...;base64,` directive **in + * place**, so the file stays self-contained. Only the **last** inline + * directive is rewritten. + * + * Idempotent — files already carrying a `//# debugId=` comment are unchanged. + * + * @param jsPath - Path to the JavaScript file + * @param decoded - The decoded inline sourcemap and its re-encode metadata + * @returns The debug ID, whether it was newly injected, and the injected map + * content (for upload as a standalone artifact). When the directive cannot + * be located for rewrite, returns an empty `debugId` and no + * `injectedMapContent` so callers attach nothing inconsistent. + */ +export async function injectInlineDebugId( + jsPath: string, + decoded: DecodedInlineMap +): Promise<{ + debugId: string; + wasInjected: boolean; + injectedMapContent?: Buffer; +}> { + // Full read required: the directive lives in the file body and must be + // rewritten in place. + const jsContent = await readFile(jsPath, "utf-8"); + + const debugId = contentToDebugId(decoded.json); + + // Idempotent: if already injected, return the existing ID without writing. + const existingMatch = jsContent.match(EXISTING_DEBUGID_RE); + if (existingMatch?.[1]) { + return { + debugId: existingMatch[1], + wasInjected: false, + injectedMapContent: Buffer.from(decoded.json), + }; + } + + // Locate the LAST inline directive to rewrite. If it can't be found (the + // discovery parser and this regex disagree on an edge case), abort WITHOUT + // modifying the file. Return an EMPTY debug ID and no map content so the + // upload path attaches nothing — otherwise a debug ID and pre-injection map + // would be uploaded for a bundle that has no snippet/comment/updated map. + const matches = [...jsContent.matchAll(INLINE_DIRECTIVE_RE)]; + const last = matches.at(-1); + if (last?.index === undefined) { + log.debug( + `inline sourcemap directive not found for rewrite in ${jsPath}; leaving file unmodified` + ); + return { debugId: "", wasInjected: false }; + } + + // Mutate the decoded map (IIFE adds one top line — same offset as external). + const map = decoded.map as SourcemapJson; + mutateSourcemap(map, debugId, { offsetMappings: true }); + const newDataUrl = encodeInlineSourcemap(map, decoded.dataUrlPrefix); + + // Rewrite the directive in place, before prepending the snippet / appending + // the comment so the regex operated on the original body. We splice the last + // match by index (String.replace would hit the first). + const start = last.index; + const end = start + last[0].length; + const prefixEnd = last[0].indexOf("data:"); + const directivePrefix = last[0].slice(0, prefixEnd); + const rewritten = + jsContent.slice(0, start) + + directivePrefix + + newDataUrl + + jsContent.slice(end); + + let newJs = prependDebugIdSnippet(rewritten, getDebugIdSnippet(debugId)); + newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`; + + await writeFile(jsPath, newJs); + + return { + debugId, + wasInjected: true, + injectedMapContent: Buffer.from(JSON.stringify(map)), + }; } diff --git a/src/lib/sourcemap/inject.ts b/src/lib/sourcemap/inject.ts index 7b63a8b21..d0f64fcdf 100644 --- a/src/lib/sourcemap/inject.ts +++ b/src/lib/sourcemap/inject.ts @@ -12,23 +12,55 @@ import { NODE_MODULES_DIRNAME } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; import { walkFiles } from "../scan/index.js"; -import { EXISTING_DEBUGID_RE, injectDebugId } from "./debug-id.js"; +import { + EXISTING_DEBUGID_RE, + injectDebugId, + injectInlineDebugId, +} from "./debug-id.js"; +import { + type DecodedInlineMap, + isInlineSourcemapUrl, + tryDecodeInlineSourcemap, +} from "./inline-sourcemap.js"; const log = logger.withTag("sourcemap.inject"); /** Default JavaScript file extensions to scan. */ const DEFAULT_EXTENSIONS = new Set([".js", ".cjs", ".mjs"]); +/** + * The location of a JavaScript file's sourcemap. + * + * - `external`: a companion `.map` file on disk (convention `.map` or a + * relative `sourceMappingURL` directive). + * - `inline`: a base64 data URL embedded in the JS file itself (no `.map` file). + */ +export type MapSource = + | { kind: "external"; mapPath: string } + | { kind: "inline"; jsPath: string; decoded: DecodedInlineMap }; + /** Result of injecting a single file pair. */ export type InjectResult = { /** Path to the JavaScript file. */ jsPath: string; - /** Path to the companion sourcemap. */ - mapPath: string; + /** Discriminated location of the sourcemap (external file vs inline data URL). */ + map: MapSource; + /** + * Path to the companion sourcemap on disk. Set only for external maps; + * `undefined` for inline maps (which have no standalone file). + */ + mapPath?: string; /** Whether debug IDs were injected (false if already present or skipped). */ injected: boolean; /** The debug ID (injected or pre-existing). */ debugId: string; + /** + * The debug-ID-injected sourcemap content, as a Buffer. Populated only for + * inline maps (which have no `.map` file on disk) so the upload path can + * ship it as a standalone artifact. `undefined` for external maps — read + * those from `mapPath` instead. + */ + injectedMapContent?: Buffer; }; /** Options for directory-level injection. */ @@ -66,86 +98,293 @@ export async function injectDirectory( const filePairs = await discoverFilePairs(dir, extensions, ig); const results: InjectResult[] = []; - for (const { jsPath, mapPath } of filePairs) { + for (const { jsPath, map } of filePairs) { + const mapPath = map.kind === "external" ? map.mapPath : undefined; if (options.dryRun) { // Check if file already has a debug ID without modifying it const js = await readFile(jsPath, "utf-8"); const existing = js.match(EXISTING_DEBUGID_RE); const wouldInject = !existing; const id = existing?.[1] ?? "(pending)"; - results.push({ jsPath, mapPath, injected: wouldInject, debugId: id }); + results.push({ + jsPath, + map, + mapPath, + injected: wouldInject, + debugId: id, + }); continue; } - const { debugId, wasInjected } = await injectDebugId(jsPath, mapPath); - results.push({ jsPath, mapPath, injected: wasInjected, debugId }); + if (map.kind === "external") { + const r = await injectDebugId(jsPath, map.mapPath); + results.push({ + jsPath, + map, + mapPath, + injected: r.wasInjected, + debugId: r.debugId, + }); + } else { + const r = await injectInlineDebugId(jsPath, map.decoded); + results.push({ + jsPath, + map, + mapPath, + injected: r.wasInjected, + debugId: r.debugId, + injectedMapContent: r.injectedMapContent, + }); + } } return results; } /** A discovered JS + sourcemap pair. */ -export type FilePair = { jsPath: string; mapPath: string }; +export type FilePair = { jsPath: string; map: MapSource }; /** - * Regex matching `//# sourceMappingURL=` or `//@ sourceMappingURL=`. + * Classification of a parsed `sourceMappingURL` directive. * - * Uses global + multiline flags so we can iterate all matches and take the - * **last** one — the source map spec says the last directive is authoritative. - * Concatenated bundles or string literals in the file tail may produce earlier - * false positives; only the final match matters. + * - `external`: a file path (relative or convention-based). + * - `inline`: a base64 `data:` URL embedding the sourcemap. + * - `remote`: an `http(s)://` URL. */ -const SOURCE_MAPPING_URL_RE = /\/\/[#@]\s*sourceMappingURL\s*=\s*(\S+)\s*$/gm; +export type SourceMappingDirective = { + kind: "external" | "inline" | "remote"; + /** The directive value (path or data/remote URL). */ + value: string; +}; /** - * Matches a `sourceMappingURL` that points at a remote `http(s)://` location - * (as opposed to an inline `data:` URL or a local companion path). + * Maximum bytes to scan backward when locating the `sourceMappingURL` + * directive. Inline data URLs embed the whole sourcemap, so the directive + * line can be multiple megabytes; we must read it in full to rewrite it in + * place. The cap guards against pathological single-line files while + * comfortably covering real-world inline maps. */ -const REMOTE_SOURCE_MAPPING_URL_RE = /^https?:\/\//i; +const MAX_DIRECTIVE_SCAN_BYTES = 64 * 1024 * 1024; + +/** Size of each backward read chunk. */ +const DIRECTIVE_CHUNK_BYTES = 64 * 1024; + +const NEWLINE = 0x0a; // "\n" +const CARRIAGE_RETURN = 0x0d; // "\r" /** - * Read the last ~512 bytes of a file efficiently. + * Iterate the lines of a buffer from the end toward the start. * - * We only need the very end of the JS file to find the - * `sourceMappingURL` directive. Reading just the tail avoids - * loading multi-megabyte bundles into memory. + * Yields each line as a subarray (without the delimiting `\n`). A single + * trailing newline at the very end is ignored so the first yielded line is + * the last non-empty line. */ -async function readFileTail(filePath: string, maxBytes = 512): Promise { +function* linesFromEnd(buf: Buffer): Generator { + let end = buf.length; + // Ignore one trailing newline (and CR) at EOF. + if (end > 0 && buf[end - 1] === NEWLINE) { + end -= 1; + if (end > 0 && buf[end - 1] === CARRIAGE_RETURN) { + end -= 1; + } + } + while (end > 0) { + const nlIdx = buf.lastIndexOf(NEWLINE, end - 1); + const start = nlIdx + 1; + yield buf.subarray(start, end); + if (nlIdx === -1) { + return; + } + end = nlIdx; + } +} + +/** + * Read the tail of a file as a Buffer, scanning backward from EOF until the + * `sourceMappingURL` directive line is captured in full (or the scan cap is + * hit). + * + * A `sourceMappingURL` directive is always a single line, and base64 data + * contains no newlines, so even a multi-megabyte inline data URL is a single + * line. The authoritative directive is at (or near) the end of the file, but + * may be followed by trailing content (an injected `//# debugId=` comment, + * blank lines, a license banner, etc.) — so we read enough of the tail to + * contain it. + * + * Returns the full buffered tail; callers locate the directive within it via + * {@link findSourceMappingDirective}. To keep allocation linear, chunks are + * accumulated without concatenation; we concatenate and re-scan **only** after + * reading a chunk that contains a newline (a new line boundary may complete a + * directive line). For a large inline map — a single newline-free line — this + * means a single concat once the chunk holding the line's start is read, + * rather than a quadratic concat-per-chunk. + */ +async function readDirectiveTail(filePath: string): Promise { const fh = await open(filePath, "r"); try { - const fstat = await fh.stat(); - const fileSize = fstat.size; + const fileSize = (await fh.stat()).size; if (fileSize === 0) { - return ""; + return Buffer.alloc(0); + } + const chunks: Buffer[] = []; + let collected = 0; + let end = fileSize; + while (end > 0 && collected < MAX_DIRECTIVE_SCAN_BYTES) { + const readSize = Math.min(DIRECTIVE_CHUNK_BYTES, end); + const offset = end - readSize; + const buf = Buffer.alloc(readSize); + await fh.read(buf, 0, readSize, offset); + chunks.unshift(buf); + collected += readSize; + end = offset; + + // A directive line is only "complete" once its leading newline is + // buffered. Re-scanning is worthwhile only when the chunk we just read + // introduced a new line boundary — otherwise (mid-blob of a giant inline + // line) there is nothing new to find. This keeps allocation linear. + if (buf.indexOf(NEWLINE) !== -1) { + const combined = Buffer.concat(chunks); + if (findSourceMappingDirective(combined)) { + return combined; + } + } } - const readSize = Math.min(maxBytes, fileSize); - const offset = fileSize - readSize; - const buf = Buffer.alloc(readSize); - await fh.read(buf, 0, readSize, offset); - return buf.toString("utf-8"); + return Buffer.concat(chunks); } finally { await fh.close(); } } /** - * Extract the **last** `sourceMappingURL` value from the tail of a JS file. + * Find and parse the authoritative `sourceMappingURL` directive within a + * buffered file tail. + * + * Scans lines from the end and returns the **last** `sourceMappingURL` + * directive (the source map spec says the last directive wins). Trailing + * content after the directive — an injected `//# debugId=` comment, blank + * lines, license banners, or other code — does not prevent discovery. + * + * Note: the first line yielded by {@link linesFromEnd} may be truncated if its + * start is not yet buffered, but `parseSourceMappingDirective` requires the + * line to *begin* with the directive marker, so a partially-buffered directive + * line simply doesn't match until {@link readDirectiveTail} has read far + * enough back to include its start. + */ +function findSourceMappingDirective( + tail: Buffer +): SourceMappingDirective | undefined { + for (const line of linesFromEnd(tail)) { + const directive = parseSourceMappingDirective(line); + if (directive) { + return directive; + } + } + return; +} + +/** Whether `buf` contains the ASCII `prefix` starting at byte offset `from`. */ +function bytesStartsWith(buf: Buffer, prefix: string, from: number): boolean { + if (from + prefix.length > buf.length) { + return false; + } + for (let i = 0; i < prefix.length; i += 1) { + if (buf[from + i] !== prefix.charCodeAt(i)) { + return false; + } + } + return true; +} + +/** Skip ASCII spaces/tabs starting at `from`; returns the new index. */ +function skipSpaces(buf: Buffer, from: number): number { + let i = from; + while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x09)) { + i += 1; + } + return i; +} + +/** + * Parse a `sourceMappingURL` directive from a single line (as bytes). * - * The source map spec says the last directive is authoritative. Concatenated - * bundles may have multiple directives; we iterate all matches and return - * the final one. + * Matches `//# sourceMappingURL=` or `//@ sourceMappingURL=` + * with optional single spaces around the marker, tolerating a trailing CR. + * Operates at the byte level so multi-megabyte inline lines are not converted + * to strings until the (short) value is extracted. * - * Returns `undefined` if no directive is found. + * Whitespace handling matches real bundler output (esbuild/webpack/rollup/ + * terser); the pathological arbitrary-whitespace cases the old regex allowed + * are intentionally not supported. + * + * @returns The classified directive, or `undefined` when the line is not a + * `sourceMappingURL` directive. */ -async function extractSourceMappingUrl( +export function parseSourceMappingDirective( + line: Buffer +): SourceMappingDirective | undefined { + // Trim trailing whitespace/CR at the byte level. + let endIdx = line.length; + while ( + endIdx > 0 && + (line[endIdx - 1] === 0x20 || + line[endIdx - 1] === 0x09 || + line[endIdx - 1] === CARRIAGE_RETURN || + line[endIdx - 1] === NEWLINE) + ) { + endIdx -= 1; + } + + let i = 0; + i = skipSpaces(line, i); + // Require "//" then "#" or "@". + if (!bytesStartsWith(line, "//", i)) { + return; + } + i += 2; + const marker = line[i]; + if (marker !== 0x23 /* # */ && marker !== 0x40 /* @ */) { + return; + } + i += 1; + i = skipSpaces(line, i); + if (!bytesStartsWith(line, "sourceMappingURL", i)) { + return; + } + i += "sourceMappingURL".length; + i = skipSpaces(line, i); + if (line[i] !== 0x3d /* = */) { + return; + } + i += 1; + i = skipSpaces(line, i); + if (i >= endIdx) { + return; + } + + const value = line.toString("utf-8", i, endIdx); + let kind: SourceMappingDirective["kind"] = "external"; + if (isInlineSourcemapUrl(value)) { + kind = "inline"; + } else if (value.startsWith("http://") || value.startsWith("https://")) { + kind = "remote"; + } + return { kind, value }; +} + +/** + * Extract and classify the authoritative `sourceMappingURL` directive from a + * JS file. + * + * Reads the file tail (where the directive lives, possibly followed by an + * injected `//# debugId=` comment) and parses the directive. Returns + * `undefined` when no directive is present or the file cannot be read. + */ +async function extractSourceMappingDirective( jsPath: string -): Promise { +): Promise { try { - const tail = await readFileTail(jsPath); - let lastUrl: string | undefined; - for (const match of tail.matchAll(SOURCE_MAPPING_URL_RE)) { - lastUrl = match[1]; - } - return lastUrl; - } catch { + const tail = await readDirectiveTail(jsPath); + return findSourceMappingDirective(tail); + } catch (error) { + log.debug(`failed to read directive tail from ${jsPath}`, error); return; } } @@ -157,7 +396,8 @@ async function fileExists(path: string): Promise { try { const s = await stat(path); return s.isFile(); - } catch { + } catch (error) { + log.debug(`stat failed for ${path}`, error); return false; } } @@ -166,46 +406,54 @@ async function fileExists(path: string): Promise { * Find the companion sourcemap for a JS file. * * Resolution order: - * 1. Convention: `.map` on disk - * 2. `//# sourceMappingURL=` directive in the JS file - * (only external file references — `data:` URLs are logged and skipped) + * 1. Convention: `.map` on disk → external + * 2. `//# sourceMappingURL=` directive in the JS file: + * - inline `data:` URL → decoded inline map (non-fatal on decode failure) + * - relative file reference → external + * - remote `http(s)://` URL → skipped * - * @returns The map path if a companion exists, undefined otherwise. + * @returns A {@link MapSource} if a sourcemap is found, undefined otherwise. */ -async function findCompanionMap(jsPath: string): Promise { +async function findCompanionMap( + jsPath: string +): Promise { // Fast path: convention-based naming (most bundlers use this) const conventionPath = `${jsPath}.map`; if (await fileExists(conventionPath)) { - return conventionPath; + return { kind: "external", mapPath: conventionPath }; } - // Slow path: parse sourceMappingURL from the file tail - const url = await extractSourceMappingUrl(jsPath); - if (!url) { + // Slow path: parse the authoritative sourceMappingURL directive. + const directive = await extractSourceMappingDirective(jsPath); + if (!directive) { return; } - // Skip data: URLs (inline sourcemaps) — we can't inject debug IDs - // into inline sourcemaps without re-encoding the entire base64 blob - // back into the JS file. Log and move on. - if (url.startsWith("data:")) { - log.debug( - `skipping inline sourcemap in ${jsPath} (data: URL not supported for injection)` - ); - return; + // Inline data: URL — decode and inject in place. Decode failures are + // non-fatal: bundled terser/babel output can contain template literals + // that look like inline sourcemap directives but are not valid base64 JSON. + if (directive.kind === "inline") { + const decoded = tryDecodeInlineSourcemap(directive.value); + if (!decoded) { + log.warn( + `skipping ${jsPath}: inline sourcemap is not valid base64 JSON; leaving file unmodified` + ); + return; + } + return { kind: "inline", jsPath, decoded }; } - // Skip absolute URLs (http/https) — can't inject into remote maps - if (url.startsWith("http://") || url.startsWith("https://")) { - log.debug(`skipping remote sourcemap URL in ${jsPath}: ${url}`); + // Skip remote URLs (http/https) — can't inject into remote maps. + if (directive.kind === "remote") { + log.debug(`skipping remote sourcemap URL in ${jsPath}: ${directive.value}`); return; } - // Strip query strings and fragments (e.g. "app.js.map?v=abc123") - // that bundlers like Vite/Rollup may append. indexOf returns -1 when - // no delimiter is found, and slice(0, -1) would chop the last char — - // so only slice when the delimiter actually exists. - let cleanUrl = url; + // External relative reference. Strip query strings and fragments + // (e.g. "app.js.map?v=abc123") that bundlers like Vite/Rollup may append. + // indexOf returns -1 when no delimiter is found, and slice(0, -1) would + // chop the last char — so only slice when the delimiter actually exists. + let cleanUrl = directive.value; const qIdx = cleanUrl.indexOf("?"); if (qIdx !== -1) { cleanUrl = cleanUrl.slice(0, qIdx); @@ -219,7 +467,7 @@ async function findCompanionMap(jsPath: string): Promise { const jsDir = dirname(jsPath); const resolvedMapPath = resolvePath(jsDir, cleanUrl); if (await fileExists(resolvedMapPath)) { - return resolvedMapPath; + return { kind: "external", mapPath: resolvedMapPath }; } return; @@ -312,26 +560,43 @@ export async function discoverFilePairs( continue; } } - const mapPath = await findCompanionMap(entry.absolutePath); - if (mapPath) { - // Guard against sourceMappingURL directives that resolve outside the - // upload directory (e.g. "../../other/app.js.map"). Convention-based - // maps (foo.js.map) are always adjacent so they're inherently safe. - // Trailing separator prevents prefix collisions (e.g. /dist vs /dist-backup). - // Use path.sep for Windows compatibility (backslash separators). - const dirPrefix = absDir.endsWith(sep) ? absDir : `${absDir}${sep}`; - if (!mapPath.startsWith(dirPrefix)) { - log.debug( - `skipping sourcemap outside directory: ${mapPath} (resolved from ${entry.absolutePath})` - ); - continue; - } - pairs.push({ jsPath: entry.absolutePath, mapPath }); + const map = await findCompanionMap(entry.absolutePath); + if (map && isMapInsideDir(map, absDir, entry.absolutePath)) { + pairs.push({ jsPath: entry.absolutePath, map }); } } return pairs; } +/** + * Whether a resolved {@link MapSource} is safe to include — i.e. an external + * map resolves inside the upload directory. + * + * Guards against `sourceMappingURL` directives that resolve outside the + * upload directory (e.g. `"../../other/app.js.map"`). Convention-based maps + * (`foo.js.map`) are always adjacent and inline maps live inside the JS file, + * so both are inherently in-dir. The trailing separator prevents prefix + * collisions (e.g. `/dist` vs `/dist-backup`); `path.sep` keeps it correct on + * Windows. + */ +function isMapInsideDir( + map: MapSource, + absDir: string, + jsPath: string +): boolean { + if (map.kind === "inline") { + return true; + } + const dirPrefix = absDir.endsWith(sep) ? absDir : `${absDir}${sep}`; + if (map.mapPath.startsWith(dirPrefix)) { + return true; + } + log.debug( + `skipping sourcemap outside directory: ${map.mapPath} (resolved from ${jsPath})` + ); + return false; +} + /** * Throw {@link ValidationError} if `dir` doesn't exist or isn't a * readable directory. Distinct messages per failure mode so the user @@ -502,11 +767,12 @@ export async function resolveDirectorySourcemaps( } } - const sourceMappingUrl = await extractSourceMappingUrl(jsPath); - const inline = sourceMappingUrl?.startsWith("data:") ?? false; - const remote = - !!sourceMappingUrl && REMOTE_SOURCE_MAPPING_URL_RE.test(sourceMappingUrl); - const mapPath = await findCompanionMap(jsPath); + const directive = await extractSourceMappingDirective(jsPath); + const sourceMappingUrl = directive?.value; + const inline = directive?.kind === "inline"; + const remote = directive?.kind === "remote"; + const map = await findCompanionMap(jsPath); + const mapPath = map?.kind === "external" ? map.mapPath : undefined; let debugId: string | undefined; try { diff --git a/src/lib/sourcemap/inline-sourcemap.ts b/src/lib/sourcemap/inline-sourcemap.ts new file mode 100644 index 000000000..a5002ea97 --- /dev/null +++ b/src/lib/sourcemap/inline-sourcemap.ts @@ -0,0 +1,112 @@ +/** + * Inline (data: URL) sourcemap decoding and encoding. + * + * Bundlers can embed a sourcemap directly in a JavaScript file as a base64 + * data URL (in a `sourceMappingURL` comment) instead of emitting a companion + * `.map` file. The directive value looks like: + * + * data:application/json;base64, + * data:application/json;charset=utf-8;base64, + * + * This module decodes such URLs into a parsed sourcemap object (for debug-ID + * injection) and re-encodes a mutated map back into a data URL, preserving the + * original charset prefix. + * + * Decoding is **non-fatal**: malformed base64 or non-JSON payloads return + * `undefined` rather than throwing. Bundled terser/babel output can contain + * template literals that look like `sourceMappingURL=data:...` directives but + * are not valid sourcemaps — these must be skipped, never crash the run. + */ + +import { logger } from "../logger.js"; + +const log = logger.withTag("sourcemap.inline"); + +/** + * Matches a sourcemap `data:` URL, capturing the optional charset and the + * base64 blob. Anchored so the entire value must be a well-formed data URL. + */ +const INLINE_SOURCEMAP_DATA_URL_RE = + /^data:application\/json(?:;charset=[\w-]+)?;base64,([A-Za-z0-9+/=]+)$/; + +/** ASCII prefix shared by all inline sourcemap data URLs. */ +const INLINE_SOURCEMAP_PREFIX = "data:application/json"; + +/** A decoded inline sourcemap and the metadata needed to re-encode it. */ +export type DecodedInlineMap = { + /** Parsed sourcemap JSON object (mutated in place during injection). */ + map: Record; + /** + * The decoded JSON string. Hashed to derive the debug ID so the value is + * byte-consistent with the on-disk (external) sourcemap case. + */ + json: string; + /** + * The directive prefix up to and including `base64,`, e.g. + * `"data:application/json;charset=utf-8;base64,"`. Preserved on re-encode so + * the charset is not lost. + */ + dataUrlPrefix: string; +}; + +/** + * True when a `sourceMappingURL` value is an inline base64 data URL. + * + * Uses a cheap ASCII prefix check; full validation happens in + * {@link tryDecodeInlineSourcemap}. + * + * @param url - The raw `sourceMappingURL` directive value + */ +export function isInlineSourcemapUrl(url: string): boolean { + return url.startsWith(INLINE_SOURCEMAP_PREFIX); +} + +/** + * Decode an inline base64 data URL into its JSON sourcemap. + * + * **Never throws.** Returns `undefined` on a base64-decode or `JSON.parse` + * failure so callers can warn and skip the file. Node's + * `Buffer.from(_, "base64")` silently drops invalid characters rather than + * throwing, so the real guard is the `JSON.parse` — garbage base64 decodes to + * non-JSON bytes and is rejected here. + * + * @param url - The raw `sourceMappingURL` directive value + * @returns The decoded map plus re-encode metadata, or `undefined` on failure + */ +export function tryDecodeInlineSourcemap( + url: string +): DecodedInlineMap | undefined { + const match = url.match(INLINE_SOURCEMAP_DATA_URL_RE); + if (!match) { + return; + } + const blob = match[1]; + if (!blob) { + return; + } + const dataUrlPrefix = url.slice(0, url.length - blob.length); + try { + const json = Buffer.from(blob, "base64").toString("utf-8"); + const map = JSON.parse(json) as Record; + return { map, json, dataUrlPrefix }; + } catch (error) { + log.debug("inline sourcemap decode/parse failed", error); + return; + } +} + +/** + * Re-encode a (mutated) sourcemap object back into a base64 data URL, + * preserving the original prefix (and thus charset). + * + * @param map - The sourcemap object to encode + * @param dataUrlPrefix - The prefix captured by {@link tryDecodeInlineSourcemap} + * @returns A `data:application/json...;base64,<...>` URL + */ +export function encodeInlineSourcemap( + map: unknown, + dataUrlPrefix: string +): string { + const base64 = Buffer.from(JSON.stringify(map)).toString("base64"); + return `${dataUrlPrefix}${base64}`; +} diff --git a/test/commands/sourcemap/upload.test.ts b/test/commands/sourcemap/upload.test.ts index d0f807e54..17e6d4114 100644 --- a/test/commands/sourcemap/upload.test.ts +++ b/test/commands/sourcemap/upload.test.ts @@ -209,12 +209,23 @@ describe("sourcemap inject command — --allow-empty behavior", () => { await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); }); - test("sourceMappingURL: skips data: URLs gracefully", async () => { + test("sourceMappingURL: valid inline data: URL is injected (1 pair)", async () => { + // eyJ2ZXJzaW9uIjozfQ== === {"version":3} writeFileSync( join(dir, "inline.js"), "console.log(1)\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==\n" ); - // No convention map either — should fail with zero pairs + // No convention map — the inline map is discovered as a pair and injected. + const ctx = makeContext(); + await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); + }); + + test("sourceMappingURL: invalid inline base64 is skipped (zero pairs)", async () => { + writeFileSync( + join(dir, "bad-inline.js"), + "console.log(1)\n//# sourceMappingURL=data:application/json;base64,@@@not-base64@@@\n" + ); + // Non-fatal skip → no pairs → actionable ValidationError, not a crash. const ctx = makeContext(); await expect(func.call(ctx, {}, dir)).rejects.toBeInstanceOf( ValidationError @@ -358,6 +369,68 @@ describe("sourcemap upload command — --allow-empty behavior", () => { } }); + test("inline sourcemap: uploads decoded map as a source_map with content", async () => { + const jsPath = join(dir, "inline.js"); + const map = { version: 3, sources: ["a.ts"], names: [], mappings: "AAAA" }; + const dataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}`; + writeFileSync(jsPath, `console.log(1)\n//# sourceMappingURL=${dataUrl}\n`); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files).toHaveLength(2); + const js = files.find((f) => f.type === "minified_source"); + const mapFile = files.find((f) => f.type === "source_map"); + // The source_map entry carries in-memory content (no .map on disk). + expect(mapFile?.content).toBeInstanceOf(Buffer); + // Both entries share the injected debug ID. + expect(js?.debugId).toBeTruthy(); + expect(mapFile?.debugId).toBe(js?.debugId); + // The uploaded map carries the injected debug ID. + const uploaded = JSON.parse( + (mapFile?.content as Buffer).toString("utf-8") + ); + expect(uploaded.debug_id).toBe(js?.debugId); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--no-rewrite + inline map: uploads original map without debug ID", async () => { + const jsPath = join(dir, "inline-norw.js"); + const map = { version: 3, sources: ["b.ts"], names: [], mappings: "BBBB" }; + const dataUrl = `data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}`; + writeFileSync(jsPath, `console.log(1)\n//# sourceMappingURL=${dataUrl}\n`); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { "no-rewrite": true }, dir); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files).toHaveLength(2); + const js = files.find((f) => f.type === "minified_source"); + const mapFile = files.find((f) => f.type === "source_map"); + // No debug ID injected — relying on release/URL matching. + expect(js?.debugId).toBeUndefined(); + expect(mapFile?.debugId).toBeUndefined(); + // The source_map entry carries the original (un-injected) map content. + expect(mapFile?.content).toBeInstanceOf(Buffer); + const uploaded = JSON.parse( + (mapFile?.content as Buffer).toString("utf-8") + ); + expect(uploaded.version).toBe(3); + expect(uploaded.debug_id).toBeUndefined(); + } finally { + uploadSpy.mockRestore(); + } + }); + test("--dist flag: passes dist to uploadSourcemaps", async () => { mkdirSync(join(dir, "_astro")); writeFileSync(join(dir, "_astro", "app.js"), "console.log(1)\n"); diff --git a/test/lib/api/sourcemaps.test.ts b/test/lib/api/sourcemaps.test.ts index f307899bb..b7d2c6df2 100644 --- a/test/lib/api/sourcemaps.test.ts +++ b/test/lib/api/sourcemaps.test.ts @@ -132,6 +132,32 @@ describe("buildArtifactBundle", () => { expect(bytes.readUInt16LE(LOCAL_HEADER_METHOD_OFFSET)).toBe(0); }); + test("uses in-memory content and never reads disk for inline maps", async () => { + const mapBytes = Buffer.from( + JSON.stringify({ version: 3, sources: [], mappings: "AAAA" }) + ); + const out = join(tmpDir, "bundle-inline.zip"); + // `path` points at a nonexistent file — must not be read because + // `content` is provided. + await buildArtifactBundle( + out, + [ + { + path: join(tmpDir, "does-not-exist.map"), + content: mapBytes, + debugId: "00000000-0000-0000-0000-000000000002", + type: "source_map" as const, + url: "~/app.js.map", + }, + ], + { org: "o", project: "p", compression: "stored" } + ); + + // Build succeeded (no ENOENT) and the STORED archive contains the bytes. + const bytes = await readFile(out); + expect(bytes.includes(mapBytes)).toBe(true); + }); + test("STORED archive is larger than DEFLATE for the same redundant input", async () => { const files = await makePair(); const deflateOut = join(tmpDir, "bundle-deflate.zip"); diff --git a/test/lib/sourcemap/directive.test.ts b/test/lib/sourcemap/directive.test.ts new file mode 100644 index 000000000..a14360cdb --- /dev/null +++ b/test/lib/sourcemap/directive.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for the byte-level `sourceMappingURL` directive parser. + * + * The reader (`readLastLine`) is internal and exercised end-to-end via + * discovery tests (see inject.test.ts, including the >2MB inline-blob case). + * Here we test the directive classification directly. + */ + +import { describe, expect, test } from "vitest"; +import { parseSourceMappingDirective } from "../../../src/lib/sourcemap/inject.js"; + +/** Parse a directive from a UTF-8 string line. */ +function parse(line: string) { + return parseSourceMappingDirective(Buffer.from(line, "utf-8")); +} + +describe("parseSourceMappingDirective", () => { + test("classifies an external relative path", () => { + expect(parse("//# sourceMappingURL=app.js.map")).toEqual({ + kind: "external", + value: "app.js.map", + }); + }); + + test("classifies an inline data URL", () => { + const result = parse( + "//# sourceMappingURL=data:application/json;base64,e30=" + ); + expect(result?.kind).toBe("inline"); + expect(result?.value).toBe("data:application/json;base64,e30="); + }); + + test("classifies a remote URL", () => { + expect( + parse("//# sourceMappingURL=https://cdn.example.com/app.js.map")?.kind + ).toBe("remote"); + }); + + test("accepts the //@ marker variant", () => { + expect(parse("//@ sourceMappingURL=app.js.map")?.value).toBe("app.js.map"); + }); + + test("tolerates a trailing CR (CRLF line)", () => { + expect(parse("//# sourceMappingURL=app.js.map\r")?.value).toBe( + "app.js.map" + ); + }); + + test("returns undefined for a non-directive line", () => { + expect(parse("console.log(1)")).toBeUndefined(); + expect(parse("// just a comment")).toBeUndefined(); + expect(parse("")).toBeUndefined(); + }); + + test("returns undefined when the value is missing", () => { + expect(parse("//# sourceMappingURL=")).toBeUndefined(); + }); +}); diff --git a/test/lib/sourcemap/inject.test.ts b/test/lib/sourcemap/inject.test.ts index 8dbe3ae4b..04c891a66 100644 --- a/test/lib/sourcemap/inject.test.ts +++ b/test/lib/sourcemap/inject.test.ts @@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -185,3 +186,166 @@ describe("injectDirectory — discovery", () => { expect(paths).toEqual(["bundle.js"]); }); }); + +describe("injectDirectory — inline sourcemaps", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-inject-inline-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + /** Build a `data:` URL for a sourcemap object. */ + function toDataUrl(map: unknown): string { + const b64 = Buffer.from(JSON.stringify(map)).toString("base64"); + return `data:application/json;base64,${b64}`; + } + + /** Write a JS file with an inline sourcemap and no companion .map. */ + function writeInline(rel: string, map: unknown, body = "console.log(1)\n") { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, `${body}//# sourceMappingURL=${toDataUrl(map)}\n`); + return full; + } + + const SAMPLE_MAP = { + version: 3, + sources: ["a.ts"], + mappings: "AAAA", + names: [], + }; + + test("discovers an inline-map JS file (no companion .map)", async () => { + writeInline("inline.js", SAMPLE_MAP); + const results = await injectDirectory(dir, { dryRun: true }); + expect(results).toHaveLength(1); + expect(results[0]?.map.kind).toBe("inline"); + expect(results[0]?.mapPath).toBeUndefined(); + }); + + test("injects a debug ID and rewrites the inline directive in place", async () => { + const jsPath = writeInline("inline.js", SAMPLE_MAP); + const results = await injectDirectory(dir); + expect(results).toHaveLength(1); + const { debugId, injected, injectedMapContent } = results[0] ?? {}; + expect(injected).toBe(true); + expect(debugId).toMatch(/^[0-9a-f-]{36}$/); + + const js = readFileSync(jsPath, "utf-8"); + // IIFE snippet + debugId comment present. + expect(js).toContain(`sentry-dbid-${debugId}`); + expect(js).toContain(`//# debugId=${debugId}`); + + // The rewritten inline directive carries the injected map. + const m = js.match( + /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/ + ); + expect(m).not.toBeNull(); + const rewritten = JSON.parse( + Buffer.from(m?.[1] ?? "", "base64").toString("utf-8") + ); + expect(rewritten.debug_id).toBe(debugId); + expect(rewritten.debugId).toBe(debugId); + expect(rewritten.mappings).toBe(`;${SAMPLE_MAP.mappings}`); + + // injectedMapContent matches the rewritten inline map. + expect( + JSON.parse((injectedMapContent ?? Buffer.alloc(0)).toString()) + ).toEqual(rewritten); + }); + + test("is idempotent across repeated injection", async () => { + const jsPath = writeInline("inline.js", SAMPLE_MAP); + await injectDirectory(dir); + const first = readFileSync(jsPath, "utf-8"); + const second = await injectDirectory(dir); + expect(second[0]?.injected).toBe(false); + expect(readFileSync(jsPath, "utf-8")).toBe(first); + }); + + test("discovers inline maps larger than the 2MB last-line window", async () => { + // Pad the sourcemap so its base64 data URL exceeds 2 MB, forcing the + // backward last-line reader to slide its window. + const bigMap = { + version: 3, + sources: ["a.ts"], + mappings: "AAAA", + sourcesContent: ["x".repeat(3 * 1024 * 1024)], + }; + writeInline("big-inline.js", bigMap); + const results = await injectDirectory(dir, { dryRun: true }); + expect(results).toHaveLength(1); + expect(results[0]?.map.kind).toBe("inline"); + }); + + test("discovers an inline directive followed by a trailing license banner", async () => { + const jsPath = join(dir, "banner.js"); + writeFileSync( + jsPath, + `console.log(1)\n//# sourceMappingURL=${toDataUrl(SAMPLE_MAP)}\n/*! some-lib v1.2.3 | MIT */\n` + ); + const results = await injectDirectory(dir, { dryRun: true }); + expect(results).toHaveLength(1); + expect(results[0]?.map.kind).toBe("inline"); + }); + + test("preserves a hashbang when injecting into an inline-map file", async () => { + const jsPath = writeInline( + "cli.js", + SAMPLE_MAP, + "#!/usr/bin/env node\nconsole.log(1)\n" + ); + await injectDirectory(dir); + const js = readFileSync(jsPath, "utf-8"); + expect(js.startsWith("#!/usr/bin/env node\n")).toBe(true); + // Snippet must follow the hashbang, not precede it. + expect(js.indexOf("sentry-dbid-")).toBeGreaterThan( + js.indexOf("#!/usr/bin/env node") + ); + }); + + test("only rewrites the real directive, not a mid-line false positive", async () => { + // A string literal embeds a fake `//# sourceMappingURL=data:...` mid-line. + // The whole-file regex could match it; line-anchored matching must not. + const fake = `data:application/json;base64,${Buffer.from('{"version":1}').toString("base64")}`; + const jsPath = writeInline( + "twin.js", + SAMPLE_MAP, + `const s = "//# sourceMappingURL=${fake}";\nconsole.log(s)\n` + ); + const results = await injectDirectory(dir); + expect(results[0]?.injected).toBe(true); + const js = readFileSync(jsPath, "utf-8"); + // The fake (version:1) directive in the string literal is untouched. + expect(js).toContain(`const s = "//# sourceMappingURL=${fake}"`); + // The real (last-line) inline map is the one that got the debug ID. + const realLine = js + .split("\n") + .find((l) => l.startsWith("//# sourceMappingURL=data:")); + const realB64 = realLine?.match(/base64,([A-Za-z0-9+/=]+)/)?.[1] ?? ""; + const realMap = JSON.parse( + Buffer.from(realB64, "base64").toString("utf-8") + ); + expect(realMap.debug_id).toBe(results[0]?.debugId); + expect(realMap.version).toBe(3); // the real SAMPLE_MAP, not the fake + }); + + test("skips invalid inline base64 non-fatally and keeps other pairs", async () => { + // Valid inline map. + writeInline("good.js", SAMPLE_MAP); + // Bogus inline directive (terser template-literal false positive). + const bad = join(dir, "bad.js"); + writeFileSync( + bad, + "console.log(2)\n//# sourceMappingURL=data:application/json;base64,@@@nope@@@\n" + ); + + const results = await injectDirectory(dir, { dryRun: true }); + const names = results.map((r) => r.jsPath.slice(dir.length + 1)).sort(); + expect(names).toEqual(["good.js"]); + }); +}); diff --git a/test/lib/sourcemap/inline-sourcemap.test.ts b/test/lib/sourcemap/inline-sourcemap.test.ts new file mode 100644 index 000000000..1537075f1 --- /dev/null +++ b/test/lib/sourcemap/inline-sourcemap.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for inline (data: URL) sourcemap decode/encode. + * + * Core round-trip invariants are exercised with property-based tests; the + * unit tests cover charset preservation, non-fatal failure modes, and the + * cheap prefix predicate. + */ + +import { dictionary, assert as fcAssert, property, string } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { + encodeInlineSourcemap, + isInlineSourcemapUrl, + tryDecodeInlineSourcemap, +} from "../../../src/lib/sourcemap/inline-sourcemap.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Build a data URL from a JSON-serializable value. */ +function toDataUrl(value: unknown, charset?: string): string { + const prefix = charset + ? `data:application/json;charset=${charset};base64,` + : "data:application/json;base64,"; + return `${prefix}${Buffer.from(JSON.stringify(value)).toString("base64")}`; +} + +describe("isInlineSourcemapUrl", () => { + test("true for data:application/json URLs", () => { + expect(isInlineSourcemapUrl("data:application/json;base64,e30=")).toBe( + true + ); + expect( + isInlineSourcemapUrl("data:application/json;charset=utf-8;base64,e30=") + ).toBe(true); + }); + + test("false for external/remote URLs", () => { + expect(isInlineSourcemapUrl("app.js.map")).toBe(false); + expect(isInlineSourcemapUrl("https://cdn.example.com/app.js.map")).toBe( + false + ); + }); +}); + +describe("tryDecodeInlineSourcemap", () => { + test("decodes a valid inline map", () => { + const map = { version: 3, mappings: "AAAA", sources: ["a.ts"] }; + const decoded = tryDecodeInlineSourcemap(toDataUrl(map)); + expect(decoded?.map).toEqual(map); + expect(decoded?.json).toBe(JSON.stringify(map)); + }); + + test("preserves the charset prefix", () => { + const decoded = tryDecodeInlineSourcemap(toDataUrl({}, "utf-8")); + expect(decoded?.dataUrlPrefix).toBe( + "data:application/json;charset=utf-8;base64," + ); + }); + + test("returns undefined for invalid base64 (non-fatal)", () => { + expect( + tryDecodeInlineSourcemap("data:application/json;base64,@@@not-base64@@@") + ).toBeUndefined(); + }); + + test("returns undefined when decoded bytes are not JSON (non-fatal)", () => { + // "not json" base64-encoded — decodes cleanly but is not JSON. + const blob = Buffer.from("not json").toString("base64"); + expect( + tryDecodeInlineSourcemap(`data:application/json;base64,${blob}`) + ).toBeUndefined(); + }); + + test("returns undefined for a non-data URL", () => { + expect(tryDecodeInlineSourcemap("app.js.map")).toBeUndefined(); + }); +}); + +describe("property: inline sourcemap round-trip", () => { + test("decode(encode(map)) deep-equals the original map", () => { + fcAssert( + property(dictionary(string(), string()), (obj) => { + const dataUrl = encodeInlineSourcemap( + obj, + "data:application/json;base64," + ); + const decoded = tryDecodeInlineSourcemap(dataUrl); + expect(decoded?.map).toEqual(obj); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("charset prefix survives a round-trip", () => { + fcAssert( + property(dictionary(string(), string()), (obj) => { + const prefix = "data:application/json;charset=utf-8;base64,"; + const decoded = tryDecodeInlineSourcemap( + encodeInlineSourcemap(obj, prefix) + ); + expect(decoded?.dataUrlPrefix).toBe(prefix); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +});