Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/scopes-proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"webpack-sources": minor
---

Add **experimental** support for the [TC39 Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md) (`originalScopes` / `generatedRanges`).

`SourceMapSource` now reads these fields from an input source map and forwards them through `streamChunks` via two new optional callbacks — `onOriginalScope` and `onGeneratedRange` — which `ConcatSource`, `CachedSource`, and `PrefixSource` propagate while shifting generated-range coordinates as needed. Maps produced via `getFromStreamChunks` / `streamAndGetSourceAndMap` round-trip `originalScopes` and `generatedRanges` when the inputs carry them.

All scope decoders, encoders, and the shared VLQ alphabet live in one place: `lib/helpers/scopes.js`. The decoders inline the VLQ state machine (no per-token closure) and the encoder has a single-sextet fast path for small values.

**⚠️ Experimental.** The proposal is still evolving in TC39 — the wire format, flag bits, callback shape, and helper names may change in future minor releases. See the "Source Map Scopes Proposal (experimental)" section of the README for details.
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,90 @@ CompatSource.from(sourceLike: any | Source)
```

If `sourceLike` is a real Source it returns it unmodified. Otherwise it returns it wrapped in a CompatSource.

## Source Map Scopes Proposal (experimental)

> **⚠️ Experimental.** `webpack-sources` has initial support for the
> [TC39 Source Map Scopes Proposal](https://github.com/tc39/source-map/blob/main/proposals/scopes.md)
> (`originalScopes` / `generatedRanges`). The proposal is still evolving, so
> the **wire format, flag bits, callback shape, and helper names may change**
> in future minor releases. Do not depend on this as part of a stable API
> surface yet.

When a `SourceMapSource` is constructed with an input source map that carries
`originalScopes` and/or `generatedRanges`, those fields are forwarded through
the `streamChunks` pipeline and back out on the map produced by `.map()` /
`.sourceAndMap()`:

<!-- eslint-skip -->
```typescript
const map = {
version: 3,
sources: ["a.js"],
names: [],
mappings: "AAAA;AACA;",
// Experimental Scopes Proposal fields:
originalScopes: ["…"], // one VLQ string per source
generatedRanges: "…", // one VLQ string for the generated file
};
const src = new SourceMapSource(generatedCode, "a.js", map);
const out = src.map(); // out.originalScopes / out.generatedRanges preserved
```

The scope data is propagated through derived sources as follows:

- **`SourceMapSource`** — reads scopes/ranges from the input source map and
emits them.
- **`ConcatSource`** — forwards original scopes verbatim and shifts each
child's generated-range line/column by the cumulative offset.
- **`PrefixSource`** — shifts generated-range columns by the prefix length
on non-line-start positions.
- **`CachedSource`** — passes the callbacks through both the cache-miss and
cache-hit paths.
- **`ReplaceSource`** — forwards original scopes (positions refer to
original sources and are stable across replacements). Generated ranges
are dropped because their generated columns would need remapping through
each replacement.
- **`RawSource` / `OriginalSource`** — accept the callbacks as no-ops
(these sources have no scope data of their own).
- **Combined source maps** (`SourceMapSource` with an inner map) — accept
the callbacks but drop the scope/range data. Remapping scopes/ranges
through the combined coordinate transform is not implemented yet.

### `streamChunks` callbacks

`Source.prototype.streamChunks` has two **optional** trailing parameters for
Scopes Proposal data. Passing them is the only way to receive scope/range
events; omitting them is fully backwards compatible.

<!-- eslint-skip -->
```typescript
source.streamChunks(
options,
onChunk,
onSource,
onName,
// Experimental. `flags >= 0` is a scope start; `flags === -1` is a
// scope end.
onOriginalScope?: (
sourceIndex: number,
line: number,
column: number,
flags: number,
kind: number,
name: number,
variables: number[],
) => void,
// Experimental. `flags >= 0` is a range start; `flags === -1` is a
// range end. `definition` / `callsite` tuples are reused internally —
// copy them if you need to retain data across invocations.
onGeneratedRange?: (
generatedLine: number,
generatedColumn: number,
flags: number,
definition?: [sourceIndex: number, scopeIndex: number],
callsite?: [sourceIndex: number, line: number, column: number],
bindings?: number[][],
) => void,
);
```
17 changes: 16 additions & 1 deletion lib/CachedSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const {

/** @typedef {import("./Source").HashLike} HashLike */
/** @typedef {import("./Source").MapOptions} MapOptions */
/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */
/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */
/** @typedef {import("./Source").RawSourceMap} RawSourceMap */
/** @typedef {import("./Source").SourceAndMap} SourceAndMap */
/** @typedef {import("./Source").SourceValue} SourceValue */
Expand Down Expand Up @@ -377,9 +379,18 @@ class CachedSource extends Source {
* @param {OnChunk} onChunk called for each chunk of code
* @param {OnSource} onSource called for each source
* @param {OnName} onName called for each name
* @param {OnOriginalScope=} onOriginalScope called for each original scope
* @param {OnGeneratedRange=} onGeneratedRange called for each generated range
* @returns {GeneratedSourceInfo} generated source info
*/
streamChunks(options, onChunk, onSource, onName) {
streamChunks(
options,
onChunk,
onSource,
onName,
onOriginalScope,
onGeneratedRange,
) {
const key = getCacheKey(options);
if (
this._cachedMaps.has(key) &&
Expand All @@ -396,6 +407,8 @@ class CachedSource extends Source {
onName,
Boolean(options && options.finalSource),
true,
onOriginalScope,
onGeneratedRange,
);
}
return streamChunksOfRawSource(
Expand All @@ -413,6 +426,8 @@ class CachedSource extends Source {
onChunk,
onSource,
onName,
onOriginalScope,
onGeneratedRange,
);
this._cachedSource = sourceAndMap.source;
this._cachedMaps.set(key, {
Expand Down
97 changes: 96 additions & 1 deletion lib/ConcatSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const streamChunks = require("./helpers/streamChunks");
/** @typedef {import("./CompatSource").SourceLike} SourceLike */
/** @typedef {import("./Source").HashLike} HashLike */
/** @typedef {import("./Source").MapOptions} MapOptions */
/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */
/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */
/** @typedef {import("./Source").RawSourceMap} RawSourceMap */
/** @typedef {import("./Source").SourceAndMap} SourceAndMap */
/** @typedef {import("./Source").SourceValue} SourceValue */
Expand Down Expand Up @@ -161,16 +163,27 @@ class ConcatSource extends Source {
* @param {OnChunk} onChunk called for each chunk of code
* @param {OnSource} onSource called for each source
* @param {OnName} onName called for each name
* @param {OnOriginalScope=} onOriginalScope called for each original scope
* @param {OnGeneratedRange=} onGeneratedRange called for each generated range
* @returns {GeneratedSourceInfo} generated source info
*/
streamChunks(options, onChunk, onSource, onName) {
streamChunks(
options,
onChunk,
onSource,
onName,
onOriginalScope,
onGeneratedRange,
) {
if (!this._isOptimized) this._optimize();
if (this._children.length === 1) {
return /** @type {ConcatSource[]} */ (this._children)[0].streamChunks(
options,
onChunk,
onSource,
onName,
onOriginalScope,
onGeneratedRange,
);
}
let currentLineOffset = 0;
Expand All @@ -186,6 +199,8 @@ class ConcatSource extends Source {
/** @type {number[]} */
const nameIndexMapping = [];
let lastMappingLine = 0;
const currentLineOffsetBeforeItem = currentLineOffset;
const currentColumnOffsetBeforeItem = currentColumnOffset;
const { generatedLine, generatedColumn, source } = streamChunks(
item,
options,
Expand Down Expand Up @@ -264,6 +279,86 @@ class ConcatSource extends Source {
}
nameIndexMapping[i] = globalIndex;
},
onOriginalScope
? (innerSourceIndex, line, column, flags, kind, name, variables) => {
const globalSourceIndex =
innerSourceIndex < sourceIndexMapping.length
? sourceIndexMapping[innerSourceIndex]
: -1;
if (globalSourceIndex < 0) return;
const globalName =
name >= 0 && name < nameIndexMapping.length
? nameIndexMapping[name]
: -1;
/** @type {number[] | undefined} */
let globalVariables;
if (variables && variables.length > 0) {
globalVariables = variables.map((v) =>
v >= 0 && v < nameIndexMapping.length
? nameIndexMapping[v]
: -1,
);
}
onOriginalScope(
globalSourceIndex,
line,
column,
flags,
kind,
globalName,
globalVariables || variables,
);
}
: undefined,
onGeneratedRange
? (
innerGeneratedLine,
innerGeneratedColumn,
flags,
definition,
callsite,
bindings,
) => {
const line = innerGeneratedLine + currentLineOffsetBeforeItem;
const column =
innerGeneratedLine === 1
? innerGeneratedColumn + currentColumnOffsetBeforeItem
: innerGeneratedColumn;
/** @type {[number, number] | undefined} */
let mappedDefinition = definition;
if (definition !== undefined) {
const globalSourceIndex =
definition[0] >= 0 &&
definition[0] < sourceIndexMapping.length
? sourceIndexMapping[definition[0]]
: -1;
mappedDefinition =
globalSourceIndex < 0
? undefined
: [globalSourceIndex, definition[1]];
}
/** @type {[number, number, number] | undefined} */
let mappedCallsite = callsite;
if (callsite !== undefined) {
const globalSourceIndex =
callsite[0] >= 0 && callsite[0] < sourceIndexMapping.length
? sourceIndexMapping[callsite[0]]
: -1;
mappedCallsite =
globalSourceIndex < 0
? undefined
: [globalSourceIndex, callsite[1], callsite[2]];
}
onGeneratedRange(
line,
column,
flags,
mappedDefinition,
mappedCallsite,
bindings,
);
}
: undefined,
);
if (source !== undefined) code += source;
if (
Expand Down
15 changes: 14 additions & 1 deletion lib/OriginalSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const {

/** @typedef {import("./Source").HashLike} HashLike */
/** @typedef {import("./Source").MapOptions} MapOptions */
/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */
/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */
/** @typedef {import("./Source").RawSourceMap} RawSourceMap */
/** @typedef {import("./Source").SourceAndMap} SourceAndMap */
/** @typedef {import("./Source").SourceValue} SourceValue */
Expand Down Expand Up @@ -120,9 +122,20 @@ class OriginalSource extends Source {
* @param {OnChunk} onChunk called for each chunk of code
* @param {OnSource} onSource called for each source
* @param {OnName} _onName called for each name
* @param {OnOriginalScope=} _onOriginalScope called for each original scope (no scopes on OriginalSource)
* @param {OnGeneratedRange=} _onGeneratedRange called for each generated range (no ranges on OriginalSource)
* @returns {GeneratedSourceInfo} generated source info
*/
streamChunks(options, onChunk, onSource, _onName) {
streamChunks(
options,
onChunk,
onSource,
_onName,

_onOriginalScope,

_onGeneratedRange,
) {
if (this._value === undefined) {
this._value =
/** @type {Buffer} */
Expand Down
38 changes: 37 additions & 1 deletion lib/PrefixSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const streamChunks = require("./helpers/streamChunks");

/** @typedef {import("./Source").HashLike} HashLike */
/** @typedef {import("./Source").MapOptions} MapOptions */
/** @typedef {import("./Source").OnGeneratedRange} OnGeneratedRange */
/** @typedef {import("./Source").OnOriginalScope} OnOriginalScope */
/** @typedef {import("./Source").RawSourceMap} RawSourceMap */
/** @typedef {import("./Source").SourceAndMap} SourceAndMap */
/** @typedef {import("./Source").SourceValue} SourceValue */
Expand Down Expand Up @@ -85,9 +87,18 @@ class PrefixSource extends Source {
* @param {OnChunk} onChunk called for each chunk of code
* @param {OnSource} onSource called for each source
* @param {OnName} onName called for each name
* @param {OnOriginalScope=} onOriginalScope called for each original scope
* @param {OnGeneratedRange=} onGeneratedRange called for each generated range
* @returns {GeneratedSourceInfo} generated source info
*/
streamChunks(options, onChunk, onSource, onName) {
streamChunks(
options,
onChunk,
onSource,
onName,
onOriginalScope,
onGeneratedRange,
) {
const prefix = this._prefix;
const prefixOffset = prefix.length;
const linesOnly = Boolean(options && options.columns === false);
Expand Down Expand Up @@ -134,6 +145,31 @@ class PrefixSource extends Source {
},
onSource,
onName,
onOriginalScope,
onGeneratedRange
? (
innerGeneratedLine,
innerGeneratedColumn,
flags,
definition,
callsite,
bindings,
) => {
const line = innerGeneratedLine;
const column =
innerGeneratedColumn === 0
? 0
: innerGeneratedColumn + prefixOffset;
onGeneratedRange(
line,
column,
flags,
definition,
callsite,
bindings,
);
}
: undefined,
);
return {
generatedLine,
Expand Down
Loading
Loading