@everystack/server 0.2.17 → 0.2.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -40,6 +40,10 @@
40
40
  "types": "./src/stubs.ts",
41
41
  "default": "./src/stubs.ts"
42
42
  },
43
+ "./testing": {
44
+ "types": "./src/testing/index.ts",
45
+ "default": "./src/testing/index.ts"
46
+ },
43
47
  "./plugin": {
44
48
  "types": "./src/plugin.ts",
45
49
  "default": "./src/plugin.ts"
@@ -63,6 +67,7 @@
63
67
  "lint": "tsc --noEmit"
64
68
  },
65
69
  "peerDependencies": {
70
+ "esbuild": ">=0.20.0",
66
71
  "@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
67
72
  "@aws-sdk/client-s3": "3.1053.0",
68
73
  "@aws-sdk/s3-request-presigner": "3.1053.0",
@@ -76,6 +81,9 @@
76
81
  "sst": "4.13.1"
77
82
  },
78
83
  "peerDependenciesMeta": {
84
+ "esbuild": {
85
+ "optional": true
86
+ },
79
87
  "@aws-sdk/client-cloudfront-keyvaluestore": {
80
88
  "optional": true
81
89
  },
@@ -113,6 +121,7 @@
113
121
  "@types/jest": "29.5.14",
114
122
  "@types/node": "22.19.18",
115
123
  "drizzle-orm": "0.41.0",
124
+ "esbuild": "0.25.12",
116
125
  "jest": "29.7.0",
117
126
  "postgres": "3.4.9",
118
127
  "sst": "4.13.1",
@@ -0,0 +1,271 @@
1
+ /**
2
+ * @everystack/server/testing — server bundle-boundary assertion.
3
+ *
4
+ * Fail-closed guard for the runtime boundary: bundles each server entry with
5
+ * esbuild and asserts that no client/native-only module (react, react-native,
6
+ * expo, d3, react-query, …) appears in the Node graph. A new leak turns CI red
7
+ * with the exact import chain, instead of bloating or crashing the Lambda in
8
+ * production.
9
+ *
10
+ * This replaces the fail-OPEN `clientStubs` denylist: the denylist only
11
+ * triggered at runtime; this triggers at build time, before merge.
12
+ *
13
+ * Usage (in an app's server test suite):
14
+ *
15
+ * import { assertServerBundlesClean } from '@everystack/server/testing';
16
+ *
17
+ * it('keeps client code out of the Lambda bundle', async () => {
18
+ * await assertServerBundlesClean({
19
+ * entries: ['server/api.ts', 'server/worker.ts', 'server/ops.ts'],
20
+ * });
21
+ * });
22
+ *
23
+ * Requires `esbuild` to be resolvable from the caller (it ships with SST, so
24
+ * any everystack app already has it). Declared as an optional peer.
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ /**
31
+ * Client/native-only packages that must never appear in a Node/Lambda bundle.
32
+ * This is the boundary invariant — owned here, NOT derived from `clientStubs`,
33
+ * so the stub denylist can be deleted without weakening the guard.
34
+ */
35
+ export const CLIENT_ONLY_MODULES = [
36
+ 'react',
37
+ 'react-dom',
38
+ 'react-native',
39
+ 'react-native-web',
40
+ 'react-native-safe-area-context',
41
+ 'react-native-screens',
42
+ 'expo',
43
+ 'expo-router',
44
+ 'expo-status-bar',
45
+ '@tanstack/react-query',
46
+ ];
47
+
48
+ /** Package-name prefixes that are also forbidden (e.g. the d3 charting family). */
49
+ export const CLIENT_ONLY_PREFIXES = ['d3-'];
50
+
51
+ /**
52
+ * Infra deps that legitimately bundle into a Lambda. Externalized during the
53
+ * probe only to keep it fast and avoid resolving their (large) graphs — none
54
+ * reach the forbidden set, so excluding them cannot hide a leak.
55
+ */
56
+ export const DEFAULT_INFRA_EXTERNAL = [
57
+ 'sst',
58
+ 'drizzle-orm',
59
+ 'pg',
60
+ 'postgres',
61
+ 'sharp',
62
+ 'heic-decode',
63
+ '@aws-sdk/*',
64
+ 'aws-sdk',
65
+ ];
66
+
67
+ export interface BoundaryLeak {
68
+ /** The forbidden module specifier that was imported (e.g. "react-native"). */
69
+ module: string;
70
+ /** Import chain from the entry to the file that imports it (display paths). */
71
+ chain: string[];
72
+ }
73
+
74
+ export interface BoundaryResult {
75
+ entry: string;
76
+ /** Total modules in the entry's graph (excluding externals). */
77
+ moduleCount: number;
78
+ leaks: BoundaryLeak[];
79
+ /** Set if esbuild failed to bundle the entry at all. */
80
+ bundleError?: string;
81
+ }
82
+
83
+ export interface BoundaryOptions {
84
+ /** Server entry files, absolute or relative to `cwd`. */
85
+ entries: string[];
86
+ /** Base dir for relative entries and resolution. Default: process.cwd(). */
87
+ cwd?: string;
88
+ /** Override the forbidden package list. Default: CLIENT_ONLY_MODULES. */
89
+ forbidden?: string[];
90
+ /** Override the forbidden prefix list. Default: CLIENT_ONLY_PREFIXES. */
91
+ forbiddenPrefixes?: string[];
92
+ /** Override the infra externals. Default: DEFAULT_INFRA_EXTERNAL. */
93
+ infraExternal?: string[];
94
+ }
95
+
96
+ /** Reduce an import specifier to its bare package name (handles scopes + subpaths). */
97
+ function barePackage(spec: string): string {
98
+ if (spec.startsWith('@')) {
99
+ const [scope, name] = spec.split('/');
100
+ return name ? `${scope}/${name}` : scope;
101
+ }
102
+ return spec.split('/')[0];
103
+ }
104
+
105
+ function makeForbiddenMatcher(forbidden: string[], prefixes: string[]) {
106
+ const set = new Set(forbidden);
107
+ return (spec: string): boolean => {
108
+ const bare = barePackage(spec);
109
+ return set.has(bare) || prefixes.some((p) => bare.startsWith(p));
110
+ };
111
+ }
112
+
113
+ /** Resolve explicit `./foo.js` relative imports to `.ts` source (NodeNext style). */
114
+ function jsToTsPlugin(): import('esbuild').Plugin {
115
+ return {
116
+ name: 'js-to-ts',
117
+ setup(build) {
118
+ build.onResolve({ filter: /^\.\.?\// }, (args) => {
119
+ if (!args.path.endsWith('.js')) return undefined;
120
+ const base = path.resolve(args.resolveDir, args.path.slice(0, -3));
121
+ for (const cand of [base + '.ts', base + '.tsx', path.join(base, 'index.ts')]) {
122
+ if (fs.existsSync(cand)) return { path: cand };
123
+ }
124
+ return undefined;
125
+ });
126
+ },
127
+ };
128
+ }
129
+
130
+ let esbuildPromise: Promise<typeof import('esbuild')> | undefined;
131
+ async function loadEsbuild(): Promise<typeof import('esbuild')> {
132
+ if (!esbuildPromise) {
133
+ esbuildPromise = import('esbuild').catch(() => {
134
+ throw new Error(
135
+ "@everystack/server/testing requires 'esbuild' to be installed " +
136
+ '(it ships with SST). Add it as a devDependency to run the bundle-boundary assertion.',
137
+ );
138
+ });
139
+ }
140
+ return esbuildPromise;
141
+ }
142
+
143
+ const display = (p: string): string =>
144
+ p.replace(/^.*\/node_modules\//, '').replace(/^(\.\.\/)+/, '');
145
+
146
+ /**
147
+ * Bundle each entry and report any forbidden modules in its graph.
148
+ *
149
+ * The forbidden set is externalized so the build always completes (esbuild
150
+ * cannot parse react-native's Flow syntax, so a real RN leak would otherwise
151
+ * throw before producing a metafile). Leaks are then read from the metafile's
152
+ * import graph, with the chain back to the entry.
153
+ */
154
+ export async function analyzeServerBundles(opts: BoundaryOptions): Promise<BoundaryResult[]> {
155
+ const esbuild = await loadEsbuild();
156
+ const cwd = opts.cwd ?? process.cwd();
157
+ const forbidden = opts.forbidden ?? CLIENT_ONLY_MODULES;
158
+ const prefixes = opts.forbiddenPrefixes ?? CLIENT_ONLY_PREFIXES;
159
+ const infra = opts.infraExternal ?? DEFAULT_INFRA_EXTERNAL;
160
+ const isForbidden = makeForbiddenMatcher(forbidden, prefixes);
161
+
162
+ const results: BoundaryResult[] = [];
163
+ for (const rel of opts.entries) {
164
+ const entryPath = path.isAbsolute(rel) ? rel : path.resolve(cwd, rel);
165
+ if (!fs.existsSync(entryPath)) {
166
+ results.push({ entry: rel, moduleCount: 0, leaks: [], bundleError: 'entry not found' });
167
+ continue;
168
+ }
169
+ try {
170
+ const build = await esbuild.build({
171
+ entryPoints: [entryPath],
172
+ bundle: true,
173
+ platform: 'node',
174
+ format: 'esm',
175
+ metafile: true,
176
+ write: false,
177
+ logLevel: 'silent',
178
+ absWorkingDir: cwd,
179
+ // Externalize the forbidden set (exact names + prefix wildcards) so they
180
+ // stay as detectable import edges instead of being bundled. Without the
181
+ // wildcards a prefix-matched package (e.g. d3-scale) would be bundled and
182
+ // silently escape detection.
183
+ external: [...infra, ...forbidden, ...prefixes.map((p) => `${p}*`)],
184
+ plugins: [jsToTsPlugin()],
185
+ });
186
+ const inputs = build.metafile.inputs;
187
+ const keys = Object.keys(inputs);
188
+
189
+ // Leak edges: a bundled file that imports a forbidden (externalized) module.
190
+ const edges: { file: string; module: string }[] = [];
191
+ for (const [file, meta] of Object.entries(inputs)) {
192
+ for (const imp of meta.imports ?? []) {
193
+ if (isForbidden(imp.path)) edges.push({ file, module: imp.path });
194
+ }
195
+ }
196
+
197
+ // BFS from the entry to a leaking file, over the in-graph import edges.
198
+ const entryKey = keys.find((k) => path.resolve(cwd, k) === entryPath) ?? keys[0];
199
+ const adj = (f: string): string[] =>
200
+ (inputs[f]?.imports ?? []).map((i) => i.path).filter((p) => inputs[p]);
201
+ const chainTo = (target: string): string[] => {
202
+ const queue: string[][] = [[entryKey]];
203
+ const seen = new Set([entryKey]);
204
+ while (queue.length) {
205
+ const trail = queue.shift()!;
206
+ const head = trail[trail.length - 1];
207
+ if (head === target) return trail.map(display);
208
+ for (const next of adj(head)) {
209
+ if (!seen.has(next)) {
210
+ seen.add(next);
211
+ queue.push([...trail, next]);
212
+ }
213
+ }
214
+ }
215
+ return [display(target)];
216
+ };
217
+
218
+ const seenLeak = new Set<string>();
219
+ const leaks: BoundaryLeak[] = [];
220
+ for (const { file, module } of edges) {
221
+ const dedupe = `${file} -> ${module}`;
222
+ if (seenLeak.has(dedupe)) continue;
223
+ seenLeak.add(dedupe);
224
+ leaks.push({ module, chain: [...chainTo(file), module] });
225
+ }
226
+
227
+ results.push({ entry: rel, moduleCount: keys.length, leaks });
228
+ } catch (err) {
229
+ results.push({
230
+ entry: rel,
231
+ moduleCount: 0,
232
+ leaks: [],
233
+ bundleError: err instanceof Error ? err.message.split('\n')[0] : String(err),
234
+ });
235
+ }
236
+ }
237
+ return results;
238
+ }
239
+
240
+ /** Format a failing report into an actionable error message. */
241
+ export function formatBoundaryFailure(results: BoundaryResult[]): string {
242
+ const lines: string[] = ['Client/native modules leaked into a server bundle:\n'];
243
+ for (const r of results) {
244
+ if (r.bundleError) {
245
+ lines.push(` ❌ ${r.entry}: bundle error — ${r.bundleError}`);
246
+ continue;
247
+ }
248
+ if (!r.leaks.length) continue;
249
+ const mods = [...new Set(r.leaks.map((l) => l.module))];
250
+ lines.push(` ❌ ${r.entry}: ${mods.length} forbidden module(s) — ${mods.join(', ')}`);
251
+ // One representative chain to make the fix obvious.
252
+ const sample = r.leaks[0];
253
+ lines.push(` e.g. ${sample.chain.join('\n -> ')}`);
254
+ }
255
+ lines.push('\nFix at the source: import the server-safe entry (e.g. /rpc, /config),');
256
+ lines.push('not the React barrel. See docs/bundle-size.md.');
257
+ return lines.join('\n');
258
+ }
259
+
260
+ /**
261
+ * Assert every server entry bundles free of client/native modules.
262
+ * Throws an aggregated, actionable error if any entry leaks or fails to build.
263
+ */
264
+ export async function assertServerBundlesClean(opts: BoundaryOptions): Promise<BoundaryResult[]> {
265
+ const results = await analyzeServerBundles(opts);
266
+ const failed = results.filter((r) => r.bundleError || r.leaks.length > 0);
267
+ if (failed.length) {
268
+ throw new Error(formatBoundaryFailure(results));
269
+ }
270
+ return results;
271
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @everystack/server/testing — test-time guards for the runtime boundary.
3
+ */
4
+ export {
5
+ assertServerBundlesClean,
6
+ analyzeServerBundles,
7
+ formatBoundaryFailure,
8
+ CLIENT_ONLY_MODULES,
9
+ CLIENT_ONLY_PREFIXES,
10
+ DEFAULT_INFRA_EXTERNAL,
11
+ type BoundaryOptions,
12
+ type BoundaryResult,
13
+ type BoundaryLeak,
14
+ } from './bundle-boundary';
15
+
16
+ export {
17
+ assertWebBundleClean,
18
+ analyzeWebBundle,
19
+ formatWebReport,
20
+ barePackageFromSource,
21
+ type WebBundleOptions,
22
+ type WebBundleAnalysis,
23
+ type PackageContribution,
24
+ } from './web-bundle';
@@ -0,0 +1,266 @@
1
+ /**
2
+ * @everystack/server/testing — web bundle guard.
3
+ *
4
+ * The client-side analog of assertServerBundlesClean, with a deliberate design
5
+ * choice: it maintains NO list of "native-only" packages. everystack can't know
6
+ * any given app's dependencies' web-capability, and such a list goes stale the
7
+ * moment a package adds web support. Instead the gate is list-free:
8
+ *
9
+ * - SIZE BUDGET — total + largest-chunk uncompressed bytes (the parse-cost
10
+ * metric). Pure numbers, never stale. This is the durable CI gate against
11
+ * re-bloat.
12
+ * - COMPOSITION REPORT — per-package attributed bytes, so a human can triage
13
+ * "why is <package> on web?" The bundle size names the suspects, not a
14
+ * registry.
15
+ * - REGRESSION (app-local) — `expectAbsent`: packages THIS app declares it
16
+ * keeps out of web (its shim keys + lazy-gated natives). The guard asserts
17
+ * they stay absent. The only list is the app's, owned by the app.
18
+ *
19
+ * It reads the real Metro web output (`expo export --source-maps external
20
+ * --platform web`) + source maps and attributes generated bytes to source
21
+ * packages (a self-contained source-map-explorer). Attribution is an estimate
22
+ * (maps track UTF-16 columns, not bytes — drifts by ~multibyte chars); the size
23
+ * budget and `expectAbsent` checks are exact (file size + the map's `sources`).
24
+ *
25
+ * Usage (in an app's test suite, after an export):
26
+ *
27
+ * import { assertWebBundleClean } from '@everystack/server/testing';
28
+ * await assertWebBundleClean({
29
+ * bundleDir: 'dist/client/_expo/static/js/web',
30
+ * maxBytes: 6_500_000, // the durable gate
31
+ * expectAbsent: ['react-native-view-shot', '@rnmapbox/maps'], // THIS app's list
32
+ * });
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+
38
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
39
+ const B64_INDEX: Record<string, number> = {};
40
+ for (let i = 0; i < B64.length; i++) B64_INDEX[B64[i]] = i;
41
+
42
+ /** Decode a base64-VLQ-encoded source-map segment into its integer fields. */
43
+ function decodeVlqSegment(segment: string): number[] {
44
+ const result: number[] = [];
45
+ let value = 0;
46
+ let shift = 0;
47
+ for (const ch of segment) {
48
+ const digit = B64_INDEX[ch];
49
+ if (digit === undefined) return result;
50
+ const continuation = digit & 0x20;
51
+ value += (digit & 0x1f) << shift;
52
+ if (continuation) {
53
+ shift += 5;
54
+ } else {
55
+ const negate = value & 1;
56
+ value >>= 1;
57
+ result.push(negate ? -value : value);
58
+ value = 0;
59
+ shift = 0;
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+
65
+ /** Reduce a source path to its bare package name (or '(app)' for non-node_modules). */
66
+ export function barePackageFromSource(source: string): string {
67
+ const m = source.match(/node_modules\/((@[^/]+\/[^/]+)|[^/]+)/);
68
+ if (m) return m[1];
69
+ return '(app)';
70
+ }
71
+
72
+ export interface PackageContribution {
73
+ pkg: string;
74
+ bytes: number;
75
+ sourceFiles: number;
76
+ }
77
+
78
+ export interface WebBundleAnalysis {
79
+ totalBytes: number;
80
+ chunks: { file: string; bytes: number }[];
81
+ byPackage: PackageContribution[];
82
+ unmappedBytes: number;
83
+ /** Packages from `expectAbsent` that were found in the bundle (regressions). */
84
+ unexpected: { pkg: string; chunk: string; sourceFile: string }[];
85
+ }
86
+
87
+ export interface WebBundleOptions {
88
+ /** Directory containing the web JS chunks + their .map files. */
89
+ bundleDir: string;
90
+ /**
91
+ * Packages this app expects to be ABSENT from the web bundle — its own shim
92
+ * keys + lazy-gated natives. The guard flags any that appear. This is the
93
+ * app's regression list; everystack maintains none.
94
+ */
95
+ expectAbsent?: string[];
96
+ /** Fail if total JS exceeds this (uncompressed bytes — the parse-cost metric). */
97
+ maxBytes?: number;
98
+ /** Fail if any single chunk exceeds this. */
99
+ maxChunkBytes?: number;
100
+ }
101
+
102
+ /**
103
+ * Attribute generated bytes in one chunk to its source packages via the map.
104
+ * Mirrors source-map-explorer: for each generated line, each mapping segment
105
+ * owns the generated columns from its start to the next segment's start.
106
+ */
107
+ function attributeChunk(
108
+ jsPath: string,
109
+ mapPath: string,
110
+ ): { byPackage: Map<string, { bytes: number; files: Set<string> }>; unmapped: number; total: number } {
111
+ const generated = fs.readFileSync(jsPath, 'utf8');
112
+ const map = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
113
+ const sources: string[] = map.sources || [];
114
+ const lines = generated.split('\n');
115
+
116
+ const byPackage = new Map<string, { bytes: number; files: Set<string> }>();
117
+ let unmapped = 0;
118
+ let total = 0;
119
+
120
+ // Absolute fields carried across the whole mappings string (per spec).
121
+ let srcIdx = 0;
122
+ const mappingLines = (map.mappings || '').split(';');
123
+
124
+ for (let lineNo = 0; lineNo < lines.length; lineNo++) {
125
+ const content = lines[lineNo].length;
126
+ const hasNewline = lineNo < lines.length - 1; // split() drops N-1 separators
127
+ total += content + (hasNewline ? 1 : 0);
128
+ if (hasNewline) unmapped += 1; // the separator newline is never mapped
129
+ const segs = (mappingLines[lineNo] || '').split(',').filter(Boolean);
130
+ if (!segs.length) {
131
+ unmapped += content;
132
+ continue;
133
+ }
134
+ // Decode this line's segments into [genCol, srcIdxForSeg].
135
+ let genCol = 0;
136
+ const decoded: { genCol: number; src: number | null }[] = [];
137
+ for (const seg of segs) {
138
+ const fields = decodeVlqSegment(seg);
139
+ if (!fields.length) continue;
140
+ genCol += fields[0];
141
+ if (fields.length >= 4) {
142
+ srcIdx += fields[1];
143
+ decoded.push({ genCol, src: srcIdx });
144
+ } else {
145
+ decoded.push({ genCol, src: null });
146
+ }
147
+ }
148
+ if (!decoded.length) {
149
+ unmapped += content;
150
+ continue;
151
+ }
152
+ // Bytes before the first segment are unmapped.
153
+ unmapped += decoded[0].genCol;
154
+ for (let i = 0; i < decoded.length; i++) {
155
+ const start = decoded[i].genCol;
156
+ const end = i + 1 < decoded.length ? decoded[i + 1].genCol : content;
157
+ const span = Math.max(0, end - start);
158
+ const src = decoded[i].src;
159
+ if (src === null || src < 0 || src >= sources.length) {
160
+ unmapped += span;
161
+ continue;
162
+ }
163
+ const pkg = barePackageFromSource(sources[src]);
164
+ let entry = byPackage.get(pkg);
165
+ if (!entry) {
166
+ entry = { bytes: 0, files: new Set() };
167
+ byPackage.set(pkg, entry);
168
+ }
169
+ entry.bytes += span;
170
+ entry.files.add(sources[src]);
171
+ }
172
+ }
173
+ return { byPackage, unmapped, total };
174
+ }
175
+
176
+ /** Analyze every chunk in bundleDir and roll up per-package contributions. */
177
+ export function analyzeWebBundle(opts: WebBundleOptions): WebBundleAnalysis {
178
+ const dir = opts.bundleDir;
179
+ const expectAbsent = new Set(opts.expectAbsent ?? []);
180
+
181
+ const jsFiles = fs
182
+ .readdirSync(dir)
183
+ .filter((f) => f.endsWith('.js') && fs.existsSync(path.join(dir, f + '.map')));
184
+
185
+ const rollup = new Map<string, { bytes: number; files: Set<string> }>();
186
+ const chunks: { file: string; bytes: number }[] = [];
187
+ const unexpected: { pkg: string; chunk: string; sourceFile: string }[] = [];
188
+ let totalBytes = 0;
189
+ let unmappedBytes = 0;
190
+
191
+ for (const f of jsFiles) {
192
+ const jsPath = path.join(dir, f);
193
+ const bytes = fs.statSync(jsPath).size;
194
+ chunks.push({ file: f, bytes });
195
+ totalBytes += bytes;
196
+ const { byPackage, unmapped } = attributeChunk(jsPath, path.join(dir, f + '.map'));
197
+ unmappedBytes += unmapped;
198
+ for (const [pkg, v] of byPackage) {
199
+ const r = rollup.get(pkg) ?? { bytes: 0, files: new Set<string>() };
200
+ r.bytes += v.bytes;
201
+ for (const sf of v.files) r.files.add(sf);
202
+ rollup.set(pkg, r);
203
+ if (expectAbsent.has(pkg)) {
204
+ unexpected.push({ pkg, chunk: f, sourceFile: [...v.files][0] });
205
+ }
206
+ }
207
+ }
208
+
209
+ const byPackage = [...rollup.entries()]
210
+ .map(([pkg, v]) => ({ pkg, bytes: v.bytes, sourceFiles: v.files.size }))
211
+ .sort((a, b) => b.bytes - a.bytes);
212
+
213
+ // Dedupe to one row per package.
214
+ const seen = new Set<string>();
215
+ const dedup = unexpected.filter((l) => (seen.has(l.pkg) ? false : seen.add(l.pkg)));
216
+
217
+ return { totalBytes, chunks: chunks.sort((a, b) => b.bytes - a.bytes), byPackage, unmappedBytes, unexpected: dedup };
218
+ }
219
+
220
+ const kb = (n: number) => (n / 1024).toFixed(1) + ' KB';
221
+ const mb = (n: number) => (n / 1048576).toFixed(2) + ' MB';
222
+
223
+ /** Human-readable composition + verdict report. */
224
+ export function formatWebReport(a: WebBundleAnalysis, top = 15): string {
225
+ const out: string[] = [];
226
+ out.push(`Web bundle: ${mb(a.totalBytes)} across ${a.chunks.length} chunk(s)`);
227
+ for (const c of a.chunks.slice(0, 4)) out.push(` ${mb(c.bytes).padStart(9)} ${c.file}`);
228
+ out.push(`\nTop ${top} packages by attributed bytes:`);
229
+ for (const p of a.byPackage.slice(0, top)) {
230
+ out.push(` ${kb(p.bytes).padStart(11)} ${p.pkg} (${p.sourceFiles} files)`);
231
+ }
232
+ if (a.unexpected.length) {
233
+ out.push(`\n❌ EXPECTED-ABSENT packages found in web (${a.unexpected.length}):`);
234
+ for (const l of a.unexpected) {
235
+ out.push(` ${l.pkg} → its shim/lazy-gate isn't working (${l.sourceFile})`);
236
+ }
237
+ }
238
+ return out.join('\n');
239
+ }
240
+
241
+ /**
242
+ * Assert the web bundle is under budget and free of regressions.
243
+ * Throws an actionable report (incl. the composition) on failure.
244
+ */
245
+ export function assertWebBundleClean(opts: WebBundleOptions): WebBundleAnalysis {
246
+ const a = analyzeWebBundle(opts);
247
+ const problems: string[] = [];
248
+ if (a.unexpected.length) {
249
+ problems.push(
250
+ `${a.unexpected.length} expected-absent package(s) reappeared in web: ` +
251
+ a.unexpected.map((l) => l.pkg).join(', ') +
252
+ " — a shim or lazy-gate regressed.",
253
+ );
254
+ }
255
+ if (opts.maxBytes && a.totalBytes > opts.maxBytes) {
256
+ problems.push(`web bundle ${mb(a.totalBytes)} exceeds budget ${mb(opts.maxBytes)}.`);
257
+ }
258
+ if (opts.maxChunkBytes) {
259
+ const over = a.chunks.find((c) => c.bytes > opts.maxChunkBytes!);
260
+ if (over) problems.push(`chunk ${over.file} ${mb(over.bytes)} exceeds per-chunk budget ${mb(opts.maxChunkBytes)}.`);
261
+ }
262
+ if (problems.length) {
263
+ throw new Error('Web bundle guard failed:\n - ' + problems.join('\n - ') + '\n\n' + formatWebReport(a));
264
+ }
265
+ return a;
266
+ }