svelte-shaker 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,19 +47,21 @@ reachable value set of `variant` and removes the `.btn-danger` rule.
47
47
  npm i -D svelte-shaker # requires svelte@^5
48
48
  ```
49
49
 
50
- Nothing else to install. The plugin picks its engine and parser automatically
51
- (see [Options](#options)): a small/medium app runs on the native **Rust (WASM)
52
- engine** and parses with **rsvelte** (loaded from `@rsvelte/compiler`, a bundled
53
- WASM dependency no peer, no platform-specific binary); a large app runs on the
54
- **JS engine** with **svelte/compiler**. `engine` and `parser` let you pin either.
50
+ Nothing else to install. The plugin picks its engine automatically (see
51
+ [Options](#options)): if a **native (napi) Rust** binary loads it runs there —
52
+ parsing with rsvelte in process, fastest, no size ceiling; otherwise a small/medium
53
+ app runs on the **WASM Rust engine** (parsing with **rsvelte** from
54
+ `@rsvelte/compiler`, a bundled WASM dependency no peer, no platform-specific
55
+ binary) and a large app on the **JS engine** with **svelte/compiler**. `engine` and
56
+ `parser` let you pin the choice.
55
57
 
56
58
  ## Usage (Vite)
57
59
 
58
60
  Add the plugin **before** `svelte()`. By default it runs only in `vite build` —
59
61
  dev/HMR is a pass-through (opt into dev shaking with the `dev` option, see
60
- [Options](#options)). The engine and parser are chosen automatically per app size
61
- (Rust + rsvelte for small/medium, JS + svelte/compiler for large), and both fall
62
- back cleanly (see [Options](#options)).
62
+ [Options](#options)). The engine is chosen automatically (native Rust if a binary
63
+ loads, else WASM Rust for small/medium or JS for large), and every path falls back
64
+ cleanly (see [Options](#options)).
63
65
 
64
66
  ```ts
65
67
  // vite.config.ts
@@ -104,10 +106,10 @@ shaker({
104
106
  // or { maxVariants: 16, minSavings: 0.05 } to tune
105
107
  verbose: false, // true = per-file size breakdown after the build
106
108
 
107
- // Engine + parser are auto-selected by app size; set these only to pin.
108
- engine: 'auto', // 'auto' (Rust/WASM if <=~300 components, else JS) | 'js' | 'rust'
109
- parser: undefined, // default follows the engine (Rust->rsvelte, JS->svelte);
110
- // set 'rsvelte' | 'svelte' to pin one
109
+ // Engine is auto-selected; set these only to pin.
110
+ engine: 'auto', // 'auto' (native Rust if it loads, else WASM if <=~300, else JS) | 'js' | 'rust'
111
+ parser: undefined, // JS/WASM parse only (native always uses in-process rsvelte);
112
+ // default follows the engine (WASM->rsvelte, JS->svelte); set 'rsvelte' | 'svelte' to pin
111
113
 
112
114
  dev: false, // default off: dev is a pass-through. 'incremental' (re-parse only
113
115
  // changed files) | 'coarse' (re-analyze everything) opts in; never monomorphizes
@@ -118,27 +120,33 @@ That list is exhaustive: any other key **fails the build**, naming the key and t
118
120
  options that do exist. A typo would otherwise be ignored — and a misspelled
119
121
  `preserve` ships the component you meant to protect, over-shaken.
120
122
 
121
- - **`engine`** — which engine runs the shake. **`'auto'`** (default) picks by app
122
- size: a small/medium app (up to a few hundred components) uses the native
123
- **Rust (WASM) engine**, loaded from
124
- [`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
125
- dependency nothing to install); a **large** app stays on the **JS engine**,
126
- because the native engine's speed-up comes from a fast parse, but it must ship
127
- the whole-program AST across the JS↔WASM boundary as JSON, and past a few
128
- hundred components that round-trip (tens of MB) costs more than it saves.
129
- `'rust'` forces the native engine (throwing if `@rsvelte/compiler` can't load);
130
- `'js'` forces the JS engine. The two engines are differentially tested to shake
131
- **byte-identically**, so this is **speed-only** it never changes what ships.
132
- - **`parser`** which parser feeds the engine. The default **follows the
133
- engine**: **rsvelte** on the native engine (its AST crosses into Rust directly),
134
- **svelte/compiler** on the JS engine (where rsvelte's parse is ~2× slower with
135
- no downstream benefit). Set `'rsvelte'` or `'svelte'` to pin one regardless of
136
- engine. The choice is **soundness-neutral** the engine reads only UTF-16
137
- `start`/`end`, so both parsers are differentially tested to produce
138
- **byte-identical** output, never changing what renders. When rsvelte is the
139
- resolved parser and `@rsvelte/compiler` can't load, the plugin **throws** rather
140
- than silently swapping (so the same source can't shake differently on another
141
- machine); `parser: 'svelte'` is the explicit opt-out.
123
+ - **`engine`** — which engine runs the shake. There are two Rust engines (the same
124
+ analysis behind two frontends) plus the JS engine. The **native (napi)** engine
125
+ parses with rsvelte **in process** and keeps the ASTs Rust-side, so no
126
+ whole-program AST crosses a boundary — fastest, no size ceiling — but it ships as
127
+ a per-platform prebuilt binary that may not exist for every install. The **WASM**
128
+ engine is the same Rust engine, but the whole-program AST must cross the JS↔WASM
129
+ boundary as JSON (tens of MB past a few hundred components), so it only wins for
130
+ small/medium apps; the **JS** engine needs no boundary crossing, so it wins for a
131
+ large app when the native binary isn't available. **`'auto'`** (default) uses the
132
+ native engine if a binary loads (no size gate), else the WASM engine for a
133
+ small/medium app or the JS engine for a large one. **`'rust'`** forces a Rust
134
+ enginenative if it loads, else WASM (throwing if `@rsvelte/compiler` can't load
135
+ for the WASM fallback); **`'js'`** forces the JS engine. All three are
136
+ differentially tested to shake **byte-identically**, so this is **speed-only** it
137
+ never changes what ships.
138
+ - **`parser`** how the **JS / WASM** engines parse `.svelte`. It does **not** apply
139
+ to the native engine, which always parses with rsvelte in process. The default
140
+ **follows the engine**: **rsvelte** on the WASM engine (its AST crosses into Rust
141
+ directly), **svelte/compiler** on the JS engine (where rsvelte's parse is ~2× slower
142
+ with no downstream benefit). The choice is **soundness-neutral** the engine reads
143
+ only UTF-16 `start`/`end`, so both parsers are differentially tested to produce
144
+ **byte-identical** output, never changing what renders. `parser: 'svelte'` also
145
+ forces the native engine **off** (it can't honor svelte/compiler), so the shake uses
146
+ a JS/WASM engine with svelte/compiler. When rsvelte is the resolved JS-side parser
147
+ (`parser: 'rsvelte'`, or the WASM engine's default) and `@rsvelte/compiler` can't
148
+ load, the plugin **throws** rather than silently swapping (so the same source can't
149
+ shake differently on another machine); `parser: 'svelte'` is the explicit opt-out.
142
150
  - **`monomorphize`** — the one shaking knob, **on** by default. A measured
143
151
  net-win gate only specializes a component when that strictly shrinks the whole
144
152
  program, so monomorphization **never bloats**: whatever the knobs are set to,
package/dist/analyze.d.ts CHANGED
@@ -237,14 +237,32 @@ export declare function planFixpoint(models: Map<ComponentId, FileModel>): Map<C
237
237
  * import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
238
238
  * value set), keeping the produced model set — and thus the output — identical.
239
239
  */
240
- export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): Promise<AnalyzeInput>;
240
+ /**
241
+ * The three per-file facts the crawl needs to resolve edges — import specifiers,
242
+ * rendered `<Local>` tag names, and `<ns.X>` member tag names. `null` when the file
243
+ * has no instance script (nothing to attribute), matching the crawl's skip.
244
+ */
245
+ export interface CrawlFacts {
246
+ imports: ImportInfo[];
247
+ renderedTags: Set<string>;
248
+ memberTags: Set<string>;
249
+ }
250
+ /**
251
+ * Source of {@link CrawlFacts} for one `(id, code)`. The default is a JS parse +
252
+ * extraction ({@link jsCrawlFacts}); the native engine passes a provider backed by
253
+ * `ShakeSession` facts, so the crawl resolves the SAME edges without the JS parse.
254
+ */
255
+ export type FactsProvider = (id: ComponentId, code: string) => CrawlFacts | null;
256
+ /** The default facts provider: parse (svelte/compiler or rsvelte) and extract. */
257
+ export declare function jsCrawlFacts(id: ComponentId, code: string, parseCache?: ParseCache, parse?: Parse): CrawlFacts | null;
258
+ export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[], factsProvider?: FactsProvider): Promise<AnalyzeInput>;
241
259
  /**
242
260
  * Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
243
261
  * (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
244
262
  * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
245
263
  * pins it identical to the async path, so keep the two bodies in lockstep.
246
264
  */
247
- export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): AnalyzeInput;
265
+ export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[], factsProvider?: FactsProvider): AnalyzeInput;
248
266
  /**
249
267
  * Dead `{#if}` spans per component implied by `plans`, via the SAME shared
250
268
  * predicate the transform uses ({@link computeDeadSpans}). A bailed component
@@ -299,6 +317,22 @@ export declare function findNeverPassedProps(input: AnalyzeInput): Map<Component
299
317
  * maps cleanly; an external name with no matching declared local is dropped.
300
318
  */
301
319
  export declare function remapToLocalNames<V>(map: Map<string, V>, model: FileModel): Map<string, V>;
320
+ /**
321
+ * The bare component tag names this file RENDERS (`<Local/>`, excluding dotted
322
+ * `<ns.X/>` member tags). The Shell crawl uses this to resolve a barrel (a
323
+ * `.js`/`.ts` re-export, which costs a module read+parse) only for named imports
324
+ * actually rendered as a component — a value-only named import (a helper / type)
325
+ * is never a `<Local>` call site, so following it would read+parse a module for
326
+ * nothing. Skipping it only ever drops a non-call-site, so attribution (and the
327
+ * resulting models) are unchanged.
328
+ */
329
+ export declare function renderedComponentTagNames(ast: Root): Set<string>;
330
+ /**
331
+ * Every dotted component tag a file renders (`<ns.Child/>` -> `"ns.Child"`). The
332
+ * Shell resolves each through its namespace import's barrel; bare `<Child/>` tags
333
+ * have no dot and are bound by the plain import maps instead.
334
+ */
335
+ export declare function memberComponentTags(ast: Root): Set<string>;
302
336
  /**
303
337
  * Read one `<Child .../>` into a {@link CallSite}. Attributes are in source
304
338
  * order, so we resolve last-write-wins (a later `a={…}` overrides an earlier
@@ -323,3 +357,11 @@ export declare function readCallSite(component: AnyNode, owner?: ComponentId): C
323
357
  * monomorphization specialization (mono.ts) must honor this identically.
324
358
  */
325
359
  export declare function isFoldBlockedName(model: FileModel, name: string): boolean;
360
+ export interface ImportInfo {
361
+ value: string;
362
+ local: string;
363
+ /** `default` for a default import, the exported name for a named import, or
364
+ * `*` for a namespace import. */
365
+ imported: string;
366
+ }
367
+ export declare function importSources(instance: AnyNode): Generator<ImportInfo>;
package/dist/analyze.js CHANGED
@@ -150,20 +150,23 @@ function buildModels(input, parseCache) {
150
150
  }
151
151
  return models;
152
152
  }
153
- /**
154
- * The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
155
- * the component graph from `entries`, resolving every import edge and reading
156
- * every reachable `.svelte` file up front, into a batched {@link AnalyzeInput}.
157
- *
158
- * This is the half that STAYS in JS — it owns `this.resolve` / file IO for Vite
159
- * ecosystem compat (docs ARCHITECTURE §5/§9) — so the engine ({@link
160
- * analyzeInput}) consumes its output with no callback across the boundary. The
161
- * traversal mirrors the old crawl exactly: direct default-`.svelte` children and
162
- * the barrel children a file actually RENDERS are followed (an unrendered barrel
163
- * import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
164
- * value set), keeping the produced model set — and thus the output — identical.
165
- */
166
- export async function buildAnalyzeInput(entries, resolve, readFile, parseCache, parse, escaped = []) {
153
+ /** The default facts provider: parse (svelte/compiler or rsvelte) and extract. */
154
+ export function jsCrawlFacts(id, code, parseCache, parse) {
155
+ const ast = parseCached(id, code, parseCache, parse);
156
+ const instance = ast.instance;
157
+ if (!instance)
158
+ return null;
159
+ return {
160
+ imports: [...importSources(instance)],
161
+ renderedTags: renderedComponentTagNames(ast),
162
+ memberTags: memberComponentTags(ast),
163
+ };
164
+ }
165
+ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache, parse, escaped = [],
166
+ // The facts source. Defaults to a JS parse + extract; the native engine injects a
167
+ // `ShakeSession`-backed provider (docs M3). Internal — the public crawl is unchanged.
168
+ factsProvider) {
169
+ const getFacts = factsProvider ?? ((id, code) => jsCrawlFacts(id, code, parseCache, parse));
167
170
  const entryList = Array.isArray(entries) ? [...entries] : [entries];
168
171
  const files = [];
169
172
  const edges = [];
@@ -176,16 +179,15 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
176
179
  const id = queue.shift();
177
180
  const code = await readFile(id);
178
181
  files.push({ id, code });
179
- const ast = parseCached(id, code, parseCache, parse);
180
- const instance = ast.instance;
181
- if (!instance)
182
+ const facts = getFacts(id, code);
183
+ if (!facts)
182
184
  continue;
183
185
  // The bare component tags this file renders (`<Local …>`). Resolving a barrel
184
186
  // (a `.js`/`.ts` re-export) means READING and PARSING the target module to
185
187
  // chase the export, so we do it ONLY for named imports actually rendered as a
186
188
  // component here — a named import used as a value (a helper / type) can never
187
189
  // be a `<Local>` call site, so chasing it is pure waste.
188
- const renderedTags = renderedComponentTagNames(ast);
190
+ const renderedTags = facts.renderedTags;
189
191
  // Resolve this file's imports into the three attributable edge kinds. Direct
190
192
  // default `.svelte` and simple barrel/named imports bind a bare local; a
191
193
  // namespace import (`import * as ns`) binds no single component, so it is
@@ -193,7 +195,7 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
193
195
  const barrelLocals = new Map();
194
196
  const namespaceSources = new Map();
195
197
  const directChildren = [];
196
- for (const imp of importSources(instance)) {
198
+ for (const imp of facts.imports) {
197
199
  if (imp.imported === '*') {
198
200
  namespaceSources.set(imp.local, imp.value);
199
201
  continue;
@@ -223,7 +225,7 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
223
225
  // renders, so the engine attributes `<ns.X .../>` by name lookup.
224
226
  const nsChildren = [];
225
227
  if (namespaceSources.size > 0) {
226
- for (const tag of memberComponentTags(ast)) {
228
+ for (const tag of facts.memberTags) {
227
229
  const dot = tag.indexOf('.');
228
230
  const source = namespaceSources.get(tag.slice(0, dot));
229
231
  if (source == null)
@@ -253,7 +255,8 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
253
255
  * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
254
256
  * pins it identical to the async path, so keep the two bodies in lockstep.
255
257
  */
256
- export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, parse, escaped = []) {
258
+ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, parse, escaped = [], factsProvider) {
259
+ const getFacts = factsProvider ?? ((id, code) => jsCrawlFacts(id, code, parseCache, parse));
257
260
  const entryList = Array.isArray(entries) ? [...entries] : [entries];
258
261
  const files = [];
259
262
  const edges = [];
@@ -264,18 +267,17 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
264
267
  const id = queue.shift();
265
268
  const code = readFile(id);
266
269
  files.push({ id, code });
267
- const ast = parseCached(id, code, parseCache, parse);
268
- const instance = ast.instance;
269
- if (!instance)
270
+ const facts = getFacts(id, code);
271
+ if (!facts)
270
272
  continue;
271
273
  // See {@link buildAnalyzeInput}: resolve a barrel only for named imports
272
274
  // actually rendered as a `<Local>` component here, to avoid reading+parsing
273
275
  // modules behind value-only named imports.
274
- const renderedTags = renderedComponentTagNames(ast);
276
+ const renderedTags = facts.renderedTags;
275
277
  const barrelLocals = new Map();
276
278
  const namespaceSources = new Map();
277
279
  const directChildren = [];
278
- for (const imp of importSources(instance)) {
280
+ for (const imp of facts.imports) {
279
281
  if (imp.imported === '*') {
280
282
  namespaceSources.set(imp.local, imp.value);
281
283
  continue;
@@ -298,7 +300,7 @@ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, pa
298
300
  }
299
301
  const nsChildren = [];
300
302
  if (namespaceSources.size > 0) {
301
- for (const tag of memberComponentTags(ast)) {
303
+ for (const tag of facts.memberTags) {
302
304
  const dot = tag.indexOf('.');
303
305
  const source = namespaceSources.get(tag.slice(0, dot));
304
306
  if (source == null)
@@ -1269,7 +1271,7 @@ function collectChildCalls(ast, imports) {
1269
1271
  * nothing. Skipping it only ever drops a non-call-site, so attribution (and the
1270
1272
  * resulting models) are unchanged.
1271
1273
  */
1272
- function renderedComponentTagNames(ast) {
1274
+ export function renderedComponentTagNames(ast) {
1273
1275
  const names = new Set();
1274
1276
  walk(ast.fragment, null, {
1275
1277
  Component(node, { next }) {
@@ -1286,7 +1288,7 @@ function renderedComponentTagNames(ast) {
1286
1288
  * Shell resolves each through its namespace import's barrel; bare `<Child/>` tags
1287
1289
  * have no dot and are bound by the plain import maps instead.
1288
1290
  */
1289
- function memberComponentTags(ast) {
1291
+ export function memberComponentTags(ast) {
1290
1292
  const tags = new Set();
1291
1293
  walk(ast.fragment, null, {
1292
1294
  Component(node, { next }) {
@@ -1650,7 +1652,7 @@ function literalDefault(expr) {
1650
1652
  return { known: true, value: undefined };
1651
1653
  return { known: false };
1652
1654
  }
1653
- function* importSources(instance) {
1655
+ export function* importSources(instance) {
1654
1656
  const program = instance.content;
1655
1657
  for (const stmt of program?.body ?? []) {
1656
1658
  if (stmt.type !== 'ImportDeclaration')
@@ -0,0 +1,47 @@
1
+ import { type ReadFile, type Resolve } from './analyze.js';
2
+ import { type MonomorphizeOptions } from './mono.js';
3
+ import type { ComponentId } from './ir.js';
4
+ /** The long-lived native session: parse + retain ASTs, then shake to edits. */
5
+ interface ShakeSession {
6
+ parse: (inputJson: string) => string;
7
+ parseMore: (inputJson: string) => string;
8
+ shake: (configJson: string, ownSize: (payload: string) => number | null) => string;
9
+ }
10
+ /** The subset of the napi addon the plugin uses. */
11
+ interface NativeEngine {
12
+ ShakeSession: new () => ShakeSession;
13
+ }
14
+ /**
15
+ * Load the native Rust (napi) engine, or `null` if it can't be loaded (then the caller
16
+ * falls back to the WASM / JS engine). Two locations are tried in order:
17
+ * - the published `svelte-shaker-engine-scan-native` package (an
18
+ * `optionalDependency` — installed only when a prebuilt binary exists for this
19
+ * platform); this is the layout an npm consumer gets.
20
+ * - `../engine-scan-native/index.cjs` — the in-repo package, used from source in
21
+ * this package's own tests / dev (where the npm package isn't a dependency).
22
+ * Either loader resolves the platform `.node`; a missing binary throws (swallowed).
23
+ * A resolved-but-API-incompatible module (an older publish) is rejected so a version
24
+ * skew degrades to a fallback engine, never a crash.
25
+ */
26
+ export declare function tryLoadNativeEngine(): NativeEngine | null;
27
+ /** The output of a native monomorphization shake: the wired owner files + the variant
28
+ * residuals keyed by their request specifier (what the Shell's `load` hook serves). */
29
+ export interface NativeMonoResult {
30
+ files: Record<ComponentId, string>;
31
+ variants: Map<string, string>;
32
+ }
33
+ /**
34
+ * Whole-program shake via the native Rust engine — the counterpart of
35
+ * {@link svelteShakerWithMono} / {@link svelteShakerWasmWithMono}, handling
36
+ * monomorphization too (mono off → an empty variant set).
37
+ *
38
+ * The seed components are parsed ONCE by the session (a batched, parallel rsvelte
39
+ * parse); the crawl then resolves edges reading those facts instead of re-parsing in
40
+ * JS, parsing any file discovered outside the seed on demand (`parseMore`). The
41
+ * session retains every AST, so the shake needs no AST at the boundary — only the
42
+ * resolved graph and the `ownSize` callback cross. A final svelte/compiler revert
43
+ * cascade (the AUTHORITY) force-bails any residual unparseable output; the session's
44
+ * own inner rsvelte cascade means valid programs settle in one outer pass.
45
+ */
46
+ export declare function svelteShakerNativeWithMono(engine: NativeEngine, entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, mono: MonomorphizeOptions, escaped?: ComponentId[]): Promise<NativeMonoResult>;
47
+ export {};
@@ -0,0 +1,145 @@
1
+ import { createRequire } from 'node:module';
2
+ import { compile } from 'svelte/compiler';
3
+ import { buildAnalyzeInput, } from './analyze.js';
4
+ import {} from './mono.js';
5
+ import { revertCascade } from './revert-cascade.js';
6
+ // NODE-ONLY: loads the native Rust (napi) engine and drives it from the Vite plugin.
7
+ // Imported only by `vite.ts` (a Node entry), never by the environment-free engine, so
8
+ // the browser playground build stays clean. Unlike the WASM engine, this parses with
9
+ // rsvelte IN PROCESS and keeps the ASTs Rust-side (a `ShakeSession`), so no
10
+ // whole-program AST ever crosses the JS boundary — the crawl reads per-file FACTS from
11
+ // the session instead of re-parsing in JS (docs/RUST-MIGRATION.md M3).
12
+ const require = createRequire(import.meta.url);
13
+ /** Whether a loaded module exposes the `ShakeSession` API this engine drives —
14
+ * `parse` + `parseMore` + `shake` on the prototype. Guards against an OLDER published
15
+ * `svelte-shaker-engine-scan-native` (e.g. one predating `parseMore`) being resolved:
16
+ * an API-incompatible binary is rejected so the caller falls back to WASM/JS rather
17
+ * than crashing on a missing method. */
18
+ function hasSessionApi(mod) {
19
+ const ctor = mod.ShakeSession;
20
+ const proto = ctor?.prototype;
21
+ return (typeof ctor === 'function' &&
22
+ typeof proto?.parse === 'function' &&
23
+ typeof proto?.parseMore === 'function' &&
24
+ typeof proto?.shake === 'function');
25
+ }
26
+ /**
27
+ * Load the native Rust (napi) engine, or `null` if it can't be loaded (then the caller
28
+ * falls back to the WASM / JS engine). Two locations are tried in order:
29
+ * - the published `svelte-shaker-engine-scan-native` package (an
30
+ * `optionalDependency` — installed only when a prebuilt binary exists for this
31
+ * platform); this is the layout an npm consumer gets.
32
+ * - `../engine-scan-native/index.cjs` — the in-repo package, used from source in
33
+ * this package's own tests / dev (where the npm package isn't a dependency).
34
+ * Either loader resolves the platform `.node`; a missing binary throws (swallowed).
35
+ * A resolved-but-API-incompatible module (an older publish) is rejected so a version
36
+ * skew degrades to a fallback engine, never a crash.
37
+ */
38
+ export function tryLoadNativeEngine() {
39
+ for (const spec of ['svelte-shaker-engine-scan-native', '../engine-scan-native/index.cjs']) {
40
+ try {
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const mod = require(spec);
43
+ if (hasSessionApi(mod))
44
+ return { ShakeSession: mod.ShakeSession };
45
+ }
46
+ catch {
47
+ // Not resolvable here — try the next location.
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+ /** The compiled-byte size proxy the monomorphization net-win gate uses — the same call
53
+ * `mono.ts` / the WASM engine make, so the Rust gate decides byte-for-byte alike. */
54
+ function ownSize(id, source) {
55
+ try {
56
+ return compile(source, { generate: 'client', dev: false, filename: id }).js.code.length;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** `ShakeSession.shake`'s single-arg `ownSize` form: `[id, source]` JSON in (a napi
63
+ * multi-arg marshaling bug makes the single-arg payload the reliable shape). */
64
+ function ownSizePayload(payload) {
65
+ const [id, source] = JSON.parse(payload);
66
+ return ownSize(id, source);
67
+ }
68
+ /**
69
+ * One native facts record → the crawl's {@link CrawlFacts}. An unparseable file is
70
+ * treated as contributing nothing (its retained AST is Null, so the shake skips it —
71
+ * sound under-shake), matching the session's own parse-error handling; a file with no
72
+ * instance script simply has empty imports, so it resolves no edges either way.
73
+ */
74
+ function toCrawlFacts(f) {
75
+ if (f.parseError)
76
+ return null;
77
+ return {
78
+ imports: f.imports.map((i) => ({ value: i.source, local: i.local, imported: i.imported })),
79
+ renderedTags: new Set(f.renderedTags),
80
+ memberTags: new Set(f.memberTags),
81
+ };
82
+ }
83
+ /**
84
+ * Whole-program shake via the native Rust engine — the counterpart of
85
+ * {@link svelteShakerWithMono} / {@link svelteShakerWasmWithMono}, handling
86
+ * monomorphization too (mono off → an empty variant set).
87
+ *
88
+ * The seed components are parsed ONCE by the session (a batched, parallel rsvelte
89
+ * parse); the crawl then resolves edges reading those facts instead of re-parsing in
90
+ * JS, parsing any file discovered outside the seed on demand (`parseMore`). The
91
+ * session retains every AST, so the shake needs no AST at the boundary — only the
92
+ * resolved graph and the `ownSize` callback cross. A final svelte/compiler revert
93
+ * cascade (the AUTHORITY) force-bails any residual unparseable output; the session's
94
+ * own inner rsvelte cascade means valid programs settle in one outer pass.
95
+ */
96
+ export async function svelteShakerNativeWithMono(engine, entries, resolve, readFile, mono, escaped = []) {
97
+ const seedIds = Array.isArray(entries) ? entries : [entries];
98
+ // Batch-read + batch-parse the seed. Cache the source so the crawl's `readFile`
99
+ // does not read the seed a second time.
100
+ const codeCache = new Map();
101
+ const seedFiles = await Promise.all(seedIds.map(async (id) => {
102
+ const code = await readFile(id);
103
+ codeCache.set(id, code);
104
+ return { id, code };
105
+ }));
106
+ const session = new engine.ShakeSession();
107
+ const seedFacts = JSON.parse(session.parse(JSON.stringify({ files: seedFiles })));
108
+ const factsById = new Map();
109
+ for (const f of seedFacts.files)
110
+ factsById.set(f.id, toCrawlFacts(f));
111
+ const cachedRead = async (id) => {
112
+ const hit = codeCache.get(id);
113
+ if (hit !== undefined)
114
+ return hit;
115
+ const code = await readFile(id);
116
+ codeCache.set(id, code);
117
+ return code;
118
+ };
119
+ // Facts source for the crawl: a seed hit, or parse the newly discovered file into the
120
+ // session now (retaining its AST for the shake) and cache its facts.
121
+ const provider = (id, code) => {
122
+ if (factsById.has(id))
123
+ return factsById.get(id);
124
+ const res = JSON.parse(session.parseMore(JSON.stringify({ files: [{ id, code }] })));
125
+ const facts = res.files.length > 0 ? toCrawlFacts(res.files[0]) : null;
126
+ factsById.set(id, facts);
127
+ return facts;
128
+ };
129
+ const input = await buildAnalyzeInput(entries, resolve, cachedRead, undefined, undefined, escaped, provider);
130
+ const config = {
131
+ edges: input.edges,
132
+ entries: input.entries,
133
+ escaped: input.escaped ?? [],
134
+ mono,
135
+ };
136
+ let last;
137
+ const files = revertCascade(input.files, (forceBail) => {
138
+ last = JSON.parse(session.shake(JSON.stringify({ ...config, forceBail: [...forceBail] }), ownSizePayload));
139
+ return last.files;
140
+ });
141
+ // The engine already keys each variant by its `?shaker_variant=` request specifier
142
+ // (mono.rs `variant_specifier`), the same key the `load` hook serves — so pass it
143
+ // through, exactly as the WASM engine does.
144
+ return { files, variants: new Map(Object.entries(last.variants)) };
145
+ }
package/dist/vite.d.ts CHANGED
@@ -118,18 +118,24 @@ export interface ShakerOptions {
118
118
  monomorphize?: boolean | Partial<Omit<MonomorphizeOptions, 'enabled'>>;
119
119
  /**
120
120
  * Which engine runs the whole shake (analysis + transform, including
121
- * monomorphization). Default `'auto'`. The native Rust (WASM) engine
122
- * implements every passfor monomorphization it calls back into JS only for
123
- * the per-module compiled-size proxy the net-win gate needs so it is the
124
- * default fast path:
125
- * - `'auto'` use the native Rust engine for small/medium apps; a large app
126
- * (more than a few hundred components) stays on the JS engine, because the
127
- * whole-program AST that must cross the JS<->WASM boundary as JSON grows to
128
- * tens of MB and the round-trip then costs more than the faster parse saves.
129
- * Also falls back to JS if the WASM module can't be loaded.
130
- * - `'rust'` force the Rust engine; throws if the WASM module can't be loaded.
121
+ * monomorphization). Default `'auto'`. There are two Rust engines — the same
122
+ * analysis behind two frontends plus the JS engine:
123
+ * - the NATIVE (napi) engine parses with rsvelte IN PROCESS and keeps the ASTs
124
+ * Rust-side, so no whole-program AST crosses a boundary. It is the fastest and
125
+ * has no size ceiling, but ships as a per-platform prebuilt binary that may not
126
+ * exist for every install;
127
+ * - the WASM engine is the same Rust engine, but the whole-program AST must cross
128
+ * the JS<->WASM boundary as JSON tens of MB past a few hundred components — so
129
+ * it only wins for small/medium apps;
130
+ * - the JS engine needs no boundary crossing at all, so it wins for a large app
131
+ * when the native engine isn't available.
132
+ *
133
+ * - `'auto'` — the native engine if a binary loads (no size gate); otherwise the
134
+ * WASM engine for a small/medium app, or the JS engine for a large one.
135
+ * - `'rust'` — force a Rust engine: native if it loads, else WASM; throws if
136
+ * neither can be loaded.
131
137
  * - `'js'` — force the JS engine.
132
- * Both engines are differentially tested to produce byte-identical output, so the
138
+ * All three are differentially tested to produce byte-identical output, so the
133
139
  * choice only affects speed, never what is shaken.
134
140
  */
135
141
  engine?: 'auto' | 'js' | 'rust';
@@ -146,20 +152,23 @@ export interface ShakerOptions {
146
152
  */
147
153
  dev?: false | DevMode;
148
154
  /**
149
- * Which parser feeds the engine. Defaults to FOLLOW THE ENGINE: `'rsvelte'` on
150
- * the native (Rust) engine its AST crosses the WASM boundary directly and
151
- * `'svelte'` (svelte/compiler) on the JS engine, where rsvelte's parse is pure
152
- * overhead (~2x slower) with no downstream benefit. Set this to pin one parser
153
- * regardless of engine. rsvelte loads from `@rsvelte/compiler` (a bundled WASM
154
- * dependency nothing extra to install, no platform-specific binary).
155
+ * How the JS / WASM engines parse `.svelte`. Does NOT apply to the native engine,
156
+ * which always parses with rsvelte IN PROCESS (there is no JS-side parse to pick).
157
+ * Defaults to FOLLOW THE ENGINE: `'rsvelte'` on the WASM engine its AST crosses
158
+ * the boundary directly and `'svelte'` (svelte/compiler) on the JS engine, where
159
+ * rsvelte's parse is pure overhead (~2x slower) with no downstream benefit. The
160
+ * JS-side rsvelte parser loads from `@rsvelte/compiler` (a bundled WASM dependency —
161
+ * nothing extra to install, no platform-specific binary).
155
162
  *
156
- * The engine reads only UTF-16 `start`/`end`, never `loc`, so the choice can
157
- * never affect what renders — it is soundness-neutral, differentially tested to
158
- * produce byte-identical output either way. When rsvelte IS the resolved parser
159
- * (the native engine, or an explicit `parser: 'rsvelte'`) and `@rsvelte/compiler`
160
- * can't be loaded, the plugin THROWS rather than silently swapping to
161
- * svelte/compiler, so the same source can't shake differently on another machine:
162
- * set `parser: 'svelte'` to opt out.
163
+ * The engine reads only UTF-16 `start`/`end`, never `loc`, so the choice can never
164
+ * affect what renders — it is soundness-neutral, differentially tested to produce
165
+ * byte-identical output either way. `parser: 'svelte'` also forces the native
166
+ * engine OFF (it cannot honor svelte/compiler), so the shake uses a JS/WASM engine
167
+ * that parses with svelte/compiler. When rsvelte IS the resolved JS-side parser (an
168
+ * explicit `parser: 'rsvelte'`, or the WASM engine's default) and `@rsvelte/compiler`
169
+ * can't be loaded, the plugin THROWS rather than silently swapping to svelte/compiler,
170
+ * so the same source can't shake differently on another machine: set
171
+ * `parser: 'svelte'` to opt out.
163
172
  */
164
173
  parser?: 'svelte' | 'rsvelte';
165
174
  /**
package/dist/vite.js CHANGED
@@ -12,6 +12,7 @@ export { DEFAULT_DEV_ONLY } from './dev-only.js';
12
12
  import { DEFAULT_MONO_OPTIONS } from './mono.js';
13
13
  import { tryLoadRsvelteParser } from './rsvelte-parse.js';
14
14
  import { svelteShakerWasm, svelteShakerWasmWithMono, tryLoadWasmEngine } from './wasm-engine.js';
15
+ import { svelteShakerNativeWithMono, tryLoadNativeEngine } from './native-engine.js';
15
16
  /** kB with two decimals, the unit Vite itself uses in its build size report. */
16
17
  function formatKB(bytes) {
17
18
  return `${(bytes / 1024).toFixed(2)} kB`;
@@ -479,23 +480,49 @@ export function shaker(options = {}) {
479
480
  // is the default: `'auto'` uses it whenever it can be loaded and falls back
480
481
  // to JS otherwise, `'rust'` forces it (throwing if it can't load), `'js'`
481
482
  // forces the JS engine. Both engines produce byte-identical output.
483
+ // Decide the engine. The native (napi) Rust engine parses with rsvelte IN
484
+ // PROCESS and keeps the ASTs Rust-side, so no whole-program AST crosses a
485
+ // boundary — it is the fastest and has no size ceiling. The WASM engine is the
486
+ // SAME Rust engine but marshals the whole-program AST across the JS<->WASM
487
+ // boundary as JSON (tens of MB past a few hundred components), so `auto` only
488
+ // uses it below the size gate; past it the boundary-free JS engine wins.
489
+ // `'rust'` — native if it loads, else WASM (throws if neither can load).
490
+ // `'auto'` — native if it loads; else WASM below the gate; else JS.
491
+ // `'js'` — the JS engine.
492
+ // All three are differentially tested to emit byte-identical output.
482
493
  const engineChoice = options.engine ?? 'auto';
494
+ // `parser: 'svelte'` asks for svelte/compiler, which the native engine (always
495
+ // in-process rsvelte) cannot honor — so it forces the native engine off, and the
496
+ // shake falls back to a WASM/JS engine that parses with the requested parser.
497
+ const nativeAllowed = options.parser !== 'svelte';
498
+ let native = null;
483
499
  let wasm = null;
484
500
  if (engineChoice === 'rust') {
485
- wasm = tryLoadWasmEngine();
486
- if (!wasm)
487
- throw new Error('[vite-plugin-svelte-shaker] engine: "rust" was requested but the WASM engine ' +
488
- 'could not be loaded. Remove the option (or use engine: "js") to use the JS engine.');
501
+ native = nativeAllowed ? tryLoadNativeEngine() : null;
502
+ if (!native) {
503
+ wasm = tryLoadWasmEngine();
504
+ if (!wasm)
505
+ throw new Error('[vite-plugin-svelte-shaker] engine: "rust" was requested but neither the native ' +
506
+ 'nor the WASM Rust engine could be loaded. Remove the option (or use engine: "js") ' +
507
+ 'to use the JS engine.');
508
+ }
509
+ }
510
+ else if (engineChoice === 'auto') {
511
+ native = nativeAllowed ? tryLoadNativeEngine() : null;
512
+ if (!native && entryComponents.length <= RUST_ENGINE_MAX_COMPONENTS) {
513
+ wasm = tryLoadWasmEngine();
514
+ }
489
515
  }
490
- else if (engineChoice === 'auto' && entryComponents.length <= RUST_ENGINE_MAX_COMPONENTS) {
491
- // The native engine parses fast, but the whole-program AST must cross the
492
- // JS<->WASM boundary as JSON, and past a few hundred components that AST is
493
- // tens of MB (this app: ~600 components -> ~60 MB) the round-trip then
494
- // costs more than the faster parse saves, so `auto` keeps a large app on the
495
- // boundary-free JS engine. Speed-only: both engines emit byte-identical
496
- // output, so a borderline misjudgment only affects build time, never what
497
- // ships. `engine: 'rust'` overrides this to force the native engine.
498
- wasm = tryLoadWasmEngine();
516
+ if (native) {
517
+ // Native Rust engine (napi): parses with rsvelte in process, shakes over the
518
+ // retained ASTs, and returns only the edits byte-identical to the JS engine,
519
+ // monomorphization included (mono off -> an empty variant set). The `parser`
520
+ // option does not apply here (the session always parses in-process rsvelte).
521
+ const result = await svelteShakerNativeWithMono(native, entryComponents, resolve, read, mono, escaped);
522
+ shaken = result.files;
523
+ variantSources = result.variants;
524
+ reportSizes(shaken, read, root, options.verbose === true, log);
525
+ return;
499
526
  }
500
527
  if (wasm) {
501
528
  // Native Rust engine — byte-identical to the JS engine, including
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-shaker",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Tree shaking for Svelte components",
5
5
  "keywords": [
6
6
  "dead-code-elimination",
@@ -63,6 +63,9 @@
63
63
  "peerDependencies": {
64
64
  "svelte": "^5.56.6"
65
65
  },
66
+ "optionalDependencies": {
67
+ "svelte-shaker-engine-scan-native": "~0.2.0"
68
+ },
66
69
  "engines": {
67
70
  "node": ">=22.23.1"
68
71
  },