From fc09401596b38a68b29da902e94b65cb43cc467a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 03:34:36 +0900 Subject: [PATCH 01/34] chore(campaign): claim unplugin and playground fixes From d404d5f80574c899e423826f872f334057917419 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 03:43:21 +0900 Subject: [PATCH 02/34] fix(unplugin): track linked compiler inputs Close #975: fix(unplugin): track compiler inputs reached through filesystem links --- packages/metro/README.md | 2 +- packages/unplugin/README.md | 2 +- packages/unplugin/src/core/transform.ts | 36 ++++++++--- ...ds_linked_inputs_in_the_worker_snapshot.ts | 17 ++++++ tests/test-metro/src/internal/metro-cache.ts | 39 ++++++++++++ ...oject_cache_through_a_linked_graph_edge.ts | 19 ++++++ .../src/internal/transform-external.ts | 60 +++++++++++++++++++ website/src/content/docs/setup/metro.mdx | 2 +- website/src/content/docs/setup/unplugin.mdx | 2 +- 9 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 tests/test-metro/src/features/cache/test_transformer_records_linked_inputs_in_the_worker_snapshot.ts create mode 100644 tests/test-unplugin/src/features/transform/test_transformttsc_invalidates_project_cache_through_a_linked_graph_edge.ts diff --git a/packages/metro/README.md b/packages/metro/README.md index f78c01ee4..220025fb8 100644 --- a/packages/metro/README.md +++ b/packages/metro/README.md @@ -76,7 +76,7 @@ The plugin contract, `tsconfig` discovery, and per-build cache are identical to ## Cache invalidation -Metro keys its transform cache on each file's own content plus one static transformer key, and its babel-transformer contract has no per-file dependency registration. A `ttsc` transform can depend on a _type_ in another file, so `@ttsc/metro` folds a project fingerprint into that static key: every input file under the project root, plus the reference-graph inputs outside it (`node_modules` declarations, monorepo sibling sources, out-of-root tsconfig `extends` ancestry) recorded under `node_modules/.cache/ttsc-metro`. Editing any of them re-keys the next run, so `metro bundle` and dev-server starts pick up cross-file type changes without `--reset-cache`. +Metro keys its transform cache on each file's own content plus one static transformer key, and its babel-transformer contract has no per-file dependency registration. A `ttsc` transform can depend on a _type_ in another file, so `@ttsc/metro` folds a project fingerprint into that static key: every regular file reached by the non-following project walk, plus reference-graph inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root tsconfig `extends` ancestry) recorded under `node_modules/.cache/ttsc-metro`. Editing any of them re-keys the next run, so `metro bundle` and dev-server starts pick up cross-file type changes without `--reset-cache`. The granularity is project-level by necessity: Metro evaluates the transformer key once per run, so any fingerprinted change re-transforms every file on the next run. What remains outside the mechanism's reach: diff --git a/packages/unplugin/README.md b/packages/unplugin/README.md index 8334060cf..758c69e9a 100644 --- a/packages/unplugin/README.md +++ b/packages/unplugin/README.md @@ -310,7 +310,7 @@ The transform host reports the program's reference graph (the transform envelope Transform plugins may additionally report, per file, the source files they consulted (the envelope's `dependencies` field); the adapter registers those as watch files too, union semantics. A plugin that declares such a list [complete](https://ttsc.dev/docs/development/concepts/protocol#dependency-completeness) for a file narrows the registration instead: only its own list plus the tsconfig chain, so files the transform never consulted stop invalidating it. Files a plugin declares `volatile` (output depending on non-file inputs such as environment or time) bypass the adapter's transform cache and are marked uncacheable where the bundler exposes that control. -The transform cache itself validates against the same input set: every file under the project root plus the graph-reported inputs outside it (`node_modules` declarations, monorepo sibling sources, out-of-root `extends` ancestry). Hosts that keep one cache for the process lifetime instead of per build (Metro workers, the Turbopack loader, Bun) therefore recompile when any of those inputs changes, not only in-project ones. +The transform cache itself validates against the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). Hosts that keep one cache for the process lifetime instead of per build (Metro workers, the Turbopack loader, Bun) therefore recompile when any of those inputs changes, not only ordinary in-project files. ## Sponsors diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index 3bce9cca4..ac7dc0706 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -876,11 +876,12 @@ function listProjectInputFiles(root: string): string[] { /** * Report whether an absolute `file` belongs to the project walk universe of - * `root`: it lies under `root` and no segment of the relative path (including - * the basename) is an ignored directory name. The predicate mirrors - * {@link listProjectInputFiles} exactly, so "walk-visible" here means "hashed by - * {@link collectProjectInputHashes}". Anything else is an out-of-walk input that - * only the reference graph can prove relevant. + * `root`: it lies under `root`, every component exists without traversing a + * symbolic link, the leaf is a regular file, and no segment of the relative + * path is ignored. The predicate mirrors {@link listProjectInputFiles} exactly, + * so "walk-visible" here means "hashed by {@link collectProjectInputHashes}". + * Missing paths and files reached through symlinks or Windows junctions are + * out-of-walk inputs that only the reference graph can prove relevant. */ export function isProjectWalkPath(root: string, file: string): boolean { const relative = path.relative(pathIdentityKey(root), pathIdentityKey(file)); @@ -892,9 +893,28 @@ export function isProjectWalkPath(root: string, file: string): boolean { ) { return false; } - return relative - .split(path.sep) - .every((segment) => !isIgnoredProjectDirectory(segment)); + const segments = relative.split(path.sep); + if (segments.some(isIgnoredProjectDirectory)) { + return false; + } + let current = path.resolve(root); + for (let index = 0; index < segments.length; ++index) { + current = path.join(current, segments[index]!); + let stats: fs.Stats; + try { + stats = fs.lstatSync(current); + } catch { + return false; + } + if (stats.isSymbolicLink()) { + return false; + } + const leaf = index === segments.length - 1; + if ((leaf && !stats.isFile()) || (!leaf && !stats.isDirectory())) { + return false; + } + } + return true; } /** diff --git a/tests/test-metro/src/features/cache/test_transformer_records_linked_inputs_in_the_worker_snapshot.ts b/tests/test-metro/src/features/cache/test_transformer_records_linked_inputs_in_the_worker_snapshot.ts new file mode 100644 index 000000000..5593788eb --- /dev/null +++ b/tests/test-metro/src/features/cache/test_transformer_records_linked_inputs_in_the_worker_snapshot.ts @@ -0,0 +1,17 @@ +import { assertTransformerRecordsLinkedInput } from "../../internal/metro-cache"; + +/** + * Verifies Metro records linked graph inputs in the worker snapshot. + * + * Metro's project fingerprint shares the Unplugin walk predicate. A path below + * the project root is not actually fingerprinted when a symbolic link or + * Windows junction leads to it, so the graph snapshot must retain that path. + * + * 1. Link an in-project directory to an external declaration. + * 2. Transform with a plugin-reported dependency through the linked spelling. + * 3. Assert the worker snapshot records that spelling as an external input. + */ +export const test_transformer_records_linked_inputs_in_the_worker_snapshot = + async () => { + await assertTransformerRecordsLinkedInput(); + }; diff --git a/tests/test-metro/src/internal/metro-cache.ts b/tests/test-metro/src/internal/metro-cache.ts index d4087629a..f256a4783 100644 --- a/tests/test-metro/src/internal/metro-cache.ts +++ b/tests/test-metro/src/internal/metro-cache.ts @@ -542,6 +542,45 @@ export async function assertTransformerRecordsOnlyExternalInputs(): Promise { + const shared = TestProject.tmpdir("ttsc-metro-linked-"); + const target = path.join(shared, "types.d.ts"); + fs.writeFileSync(target, "declare const marker: string;\n", "utf8"); + + const root = TestUnpluginProject.createProject({ plugins: [] }); + const linkedDirectory = path.join(root, "linked"); + fs.symlinkSync( + shared, + linkedDirectory, + process.platform === "win32" ? "junction" : "dir", + ); + const linked = path.join(linkedDirectory, "types.d.ts"); + await prepareSnapshot(root); + await TestMetroRuntime.runTransform({ + options: { + upstreamTransformer: TestMetroRuntime.fakeUpstreamPathOnDisk(), + plugins: [ + { + transform: "./plugin.cjs", + name: "reporter", + operation: "emit-dependencies", + dependencies: ["linked/types.d.ts"], + }, + ], + }, + params: { + src: TestUnpluginProject.mainSource(root), + filename: "src/main.ts", + options: { projectRoot: root }, + }, + }); + assert.deepEqual(workerSnapshotFiles(root), [linked]); +} + /** * Asserts a plugin-declared volatile transform marks this worker's snapshot * volatile, feeding the nonce degradation checked by the volatile key case. diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_invalidates_project_cache_through_a_linked_graph_edge.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_invalidates_project_cache_through_a_linked_graph_edge.ts new file mode 100644 index 000000000..a480e39e3 --- /dev/null +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_invalidates_project_cache_through_a_linked_graph_edge.ts @@ -0,0 +1,19 @@ +import { assertCacheInvalidatesThroughLinkedGraphEdge } from "../../internal/transform-external"; + +/** + * Verifies a graph input reached through a filesystem link invalidates cache. + * + * The project walk deliberately does not follow symbolic links or Windows + * junctions. Classifying the linked spelling as walk-covered would therefore + * omit it from both the project and external snapshots and replay stale + * output. + * + * 1. Link an in-project directory to an external declaration and emit a graph edge + * through the linked spelling. + * 2. Transform once and edit only the link target. + * 3. Transform again and assert a new project generation replaced the cache. + */ +export const test_transformttsc_invalidates_project_cache_through_a_linked_graph_edge = + async () => { + await assertCacheInvalidatesThroughLinkedGraphEdge(); + }; diff --git a/tests/test-unplugin/src/internal/transform-external.ts b/tests/test-unplugin/src/internal/transform-external.ts index 333f3515f..9484ba37b 100644 --- a/tests/test-unplugin/src/internal/transform-external.ts +++ b/tests/test-unplugin/src/internal/transform-external.ts @@ -184,6 +184,66 @@ export async function assertCacheInvalidatesThroughExternalGraphEdge(): Promise< assert.notStrictEqual(cacheEntry(cache), generation); } +/** + * Asserts an in-root filesystem link remains outside the project-walk hash + * universe and its target content invalidates a cached generation. + */ +export async function assertCacheInvalidatesThroughLinkedGraphEdge(): Promise { + const { + isProjectWalkPath, + resolveOptions, + transformTtsc, + createTtscTransformCache, + } = await TestUnpluginRuntime.loadUnpluginApi(); + const shared = TestProject.tmpdir("ttsc-unplugin-linked-"); + const target = path.join(shared, "types.d.ts"); + fs.writeFileSync(target, "declare const first: string;\n", "utf8"); + const root = TestUnpluginProject.createProject({ plugins: [] }); + const linkedDirectory = path.join(root, "linked"); + fs.symlinkSync( + shared, + linkedDirectory, + process.platform === "win32" ? "junction" : "dir", + ); + const linked = path.join(linkedDirectory, "types.d.ts"); + assert.equal(isProjectWalkPath(root, linked), false); + assert.equal( + isProjectWalkPath(root, path.join(root, "src", "missing.d.ts")), + false, + ); + assert.equal( + isProjectWalkPath(root, TestUnpluginProject.mainFile(root)), + true, + ); + + const options = resolveOptions({ + plugins: emitGraphPlugins({ + edges: { "src/main.ts": ["linked/types.d.ts"] }, + }), + }); + const cache = createTtscTransformCache(); + const before = await transformTtsc( + TestUnpluginProject.mainFile(root), + TestUnpluginProject.mainSource(root), + options, + undefined, + cache, + ); + assert.ok(before); + const generation = cacheEntry(cache); + + fs.writeFileSync(target, "declare const second: string;\n", "utf8"); + const after = await transformTtsc( + TestUnpluginProject.mainFile(root), + TestUnpluginProject.mainSource(root), + options, + undefined, + cache, + ); + assert.ok(after); + assert.notStrictEqual(cacheEntry(cache), generation); +} + /** * Asserts invalidation covers the in-root ignored-directory class: a * `node_modules` declaration lives under the project root yet the walk skips diff --git a/website/src/content/docs/setup/metro.mdx b/website/src/content/docs/setup/metro.mdx index 027522406..1d1c6e376 100644 --- a/website/src/content/docs/setup/metro.mdx +++ b/website/src/content/docs/setup/metro.mdx @@ -79,7 +79,7 @@ Options travel from the Metro config process to its worker processes through an A module-resolution candidate that would outrank a selected target is recorded as a fingerprint input. A missing candidate remains recorded even when it lies under the project root, so creating it changes the next Metro cache key even though the first project walk could not hash a path that did not yet exist. Existing unsuccessful probes are already part of the ordinary project walk or the recorded out-of-walk set. -Metro keys its transform cache on each file's own content plus one static transformer key evaluated once per run, and its babel-transformer contract offers no per-file dependency registration. A `ttsc` transform can depend on a type declared in another file, so `@ttsc/metro` folds a project fingerprint into the static key: every input file under the project root, plus the [reference-graph](/docs/development/reference/driver-api#reference-graph) inputs outside it (`node_modules` declarations, monorepo sibling sources, out-of-root tsconfig `extends` ancestry), which the transformer records under `node_modules/.cache/ttsc-metro` as it runs. Editing any fingerprinted input re-keys the next run, so `metro bundle` and dev-server starts pick up cross-file type changes, `tsconfig` edits, and plugin configuration changes without `--reset-cache`. +Metro keys its transform cache on each file's own content plus one static transformer key evaluated once per run, and its babel-transformer contract offers no per-file dependency registration. A `ttsc` transform can depend on a type declared in another file, so `@ttsc/metro` folds a project fingerprint into the static key: every regular file reached by the non-following project walk, plus [reference-graph](/docs/development/reference/driver-api#reference-graph) inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root tsconfig `extends` ancestry), which the transformer records under `node_modules/.cache/ttsc-metro` as it runs. Editing any fingerprinted input re-keys the next run, so `metro bundle` and dev-server starts pick up cross-file type changes, `tsconfig` edits, and plugin configuration changes without `--reset-cache`. The recorded out-of-walk set follows what the transform core derives per file, so a plugin that declares its reported dependency list [complete](/docs/development/concepts/protocol#dependency-completeness) for a file narrows what that file contributes to the fingerprint. Files under the project root are walked regardless of any declaration. diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index ee941e361..e451916aa 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -203,7 +203,7 @@ This works for every adapter automatically when the transform host emits the env A plugin that knows exactly which declarations it consulted can go the other way and declare its reported list [complete](/docs/development/concepts/protocol#dependency-completeness) for a file. The adapter then registers only that list plus the tsconfig chain for it, so a change to a file the transform never read stops re-running the loader. Nothing is required of you: the field is per file and opt-in per plugin, and a plugin that never declares it keeps the sound default above. -The transform cache validates against the same input set: every file under the project root plus the graph-reported inputs outside it. Hosts that keep one cache for the process lifetime instead of per build (Metro workers, the Turbopack loader, Bun) therefore recompile when an out-of-project input such as a `node_modules` declaration or a monorepo sibling source changes, not only on in-project edits. +The transform cache validates against the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. Hosts that keep one cache for the process lifetime instead of per build (Metro workers, the Turbopack loader, Bun) therefore recompile when an input such as a `node_modules` declaration, a monorepo sibling source, or a file reached through a symlink or Windows junction changes, not only on ordinary in-project edits. This section is about the **transform** cache (which files a rebuild depends on), not the **source-plugin binary** cache (the compiled Go plugin ttsc builds on first use). They are independent: persisting a bundler cache such as Next.js `.next/cache` does not carry the plugin binary across builds, and a fresh CI or container build recompiles the plugin (the `ttsc: building source plugin ...` line) even with a warm bundler cache. To keep that first-build compile from recurring on every build, persist the binary cache as described in [Compile → Plugin cache](/docs/ttsc/compile#plugin-cache). In a monorepo, run `ttsc prepare` from the same project directory the bundler builds, so the warmed binary matches the cache key the bundler build looks up. From 55124be5ba11d325f4e5fc99357b3803ca8eed50 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 03:53:00 +0900 Subject: [PATCH 03/34] fix(unplugin): avoid repeated project validation Close #252: validate a cached generation once per module lifecycle instead of rehashing the project before every first delivery. Close #969: remove the per-module full-project validation cost behind the reported Bun startup regression. Close #978: preserve and retry a newer cache generation when stale input validation finishes late. --- packages/unplugin/README.md | 2 +- packages/unplugin/src/core/transform.ts | 187 ++++++++++-------- ..._project_for_each_first_module_delivery.ts | 17 ++ ...generation_after_a_stale_input_mismatch.ts | 17 ++ .../src/internal/transform-project-cache.ts | 98 +++++++++ website/src/content/docs/setup/unplugin.mdx | 2 +- 6 files changed, 238 insertions(+), 85 deletions(-) create mode 100644 tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts create mode 100644 tests/test-unplugin/src/features/transform/test_transformttsc_retries_a_newer_generation_after_a_stale_input_mismatch.ts diff --git a/packages/unplugin/README.md b/packages/unplugin/README.md index 758c69e9a..9ea5000bf 100644 --- a/packages/unplugin/README.md +++ b/packages/unplugin/README.md @@ -310,7 +310,7 @@ The transform host reports the program's reference graph (the transform envelope Transform plugins may additionally report, per file, the source files they consulted (the envelope's `dependencies` field); the adapter registers those as watch files too, union semantics. A plugin that declares such a list [complete](https://ttsc.dev/docs/development/concepts/protocol#dependency-completeness) for a file narrows the registration instead: only its own list plus the tsconfig chain, so files the transform never consulted stop invalidating it. Files a plugin declares `volatile` (output depending on non-file inputs such as environment or time) bypass the adapter's transform cache and are marked uncacheable where the bundler exposes that control. -The transform cache itself validates against the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). Hosts that keep one cache for the process lifetime instead of per build (Metro workers, the Turbopack loader, Bun) therefore recompile when any of those inputs changes, not only ordinary in-project files. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). One whole-project compile already contains every module's output, so the first request for each module checks its supplied source against that generation without re-reading the project. A repeated request for a module validates the complete project and external-input snapshots; adapters with a build lifecycle clear the generation at `buildStart`. This avoids one complete project walk per module on an initial build while long-lived hosts (Metro workers, the Turbopack loader, Bun) still recompile after an input changes. ## Sponsors diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index ac7dc0706..a6aac4a8b 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -43,12 +43,11 @@ export interface TtscTransformAlias { * A single entry in the per-build transform cache. * * Stores the full compiler result together with SHA-256 hashes of every project - * input file. On subsequent transforms the cached entry is validated by - * re-hashing the project and comparing against {@link inputHashes}; a mismatch - * triggers a full re-transform. Both sides hash the same set of files (the - * project directory walk), so the comparison is meaningful; keying the - * compiler's out-of-walk output paths on only one side is what made the cache - * miss on every module. + * input file. The first delivery of each compiled module compares its supplied + * source with the generation snapshot in constant time; a repeated delivery + * re-hashes the complete input set to validate a long-lived cache. This avoids + * an O(modules x project files) first build while retaining cross-build + * invalidation for hosts that do not expose a build boundary. */ export interface TtscCachedProjectTransform { /** @@ -80,6 +79,13 @@ export interface TtscCachedProjectTransform { projectRoot: string; /** Raw compiler output returned by {@link TtscCompiler.transform}. */ result: ITtscCompilerTransformation; + /** + * Files already delivered from this generation, keyed by filesystem identity. + * A repeated request is the observable boundary between builds for hosts that + * cannot clear the cache on `buildStart`, so it performs complete project and + * external-input validation. + */ + servedFiles?: Set; /** * Absolute path of the generated temp-dir tsconfig this compile ran against, * when an alias/compiler-options overlay required one. The compiler reports @@ -192,70 +198,77 @@ export async function transformTtsc( tsconfig, }); - let transformed = cache?.get(key); - if (transformed !== undefined) { - // A rejected in-flight generation must not stay cached: evict it (only if - // it is still the current entry) so a later call re-runs the transform. - const cached = await awaitOrEvict(cache, key, transformed); - if ( - // A file the plugin declared volatile must never be served from the - // cache: its output depends on non-file inputs, so the input-hash - // snapshot cannot prove freshness. Fall through to a fresh transform. - !isVolatileFile({ - file, - projectRoot: cached.projectRoot, - result: cached.result, - }) && - matchesCachedSource(cached, file, source) - ) { - reportSuccessDiagnostics(cached.result); - // A resolved `"exception"` / `"failure"` envelope makes this throw; that - // is a failed generation too, so evict before surfacing it. - const code = selectOrEvict(cache, key, transformed, { - file, - projectRoot: cached.projectRoot, - result: cached.result, - }); - notifyWatchInputs(hooks, { - file, - projectRoot: cached.projectRoot, - result: cached.result, - temporaryTsconfig: cached.temporaryTsconfig, + for (;;) { + let transformed = cache?.get(key); + if (transformed !== undefined) { + // A rejected in-flight generation must not stay cached: evict it (only if + // it is still the current entry) so a later call re-runs the transform. + const cached = await awaitOrEvict(cache, key, transformed); + if ( + // A file the plugin declared volatile must never be served from the + // cache: its output depends on non-file inputs, so the input-hash + // snapshot cannot prove freshness. Fall through to a fresh transform. + !isVolatileFile({ + file, + projectRoot: cached.projectRoot, + result: cached.result, + }) && + matchesCachedSource(cached, file, source) + ) { + reportSuccessDiagnostics(cached.result); + // A resolved `"exception"` / `"failure"` envelope makes this throw; + // that is a failed generation too, so evict before surfacing it. + const code = selectOrEvict(cache, key, transformed, { + file, + projectRoot: cached.projectRoot, + result: cached.result, + }); + notifyWatchInputs(hooks, { + file, + projectRoot: cached.projectRoot, + result: cached.result, + temporaryTsconfig: cached.temporaryTsconfig, + }); + markCachedSourceServed(cached, file); + return createTransformResult(source, code); + } + evictGeneration(cache, key, transformed); + // Another caller may have replaced the generation while this caller was + // awaiting or validating the old one. Retry that authoritative entry + // instead of deleting it or starting a redundant third compilation. + if (cache?.get(key) !== undefined) { + continue; + } + transformed = undefined; + } + + if (transformed === undefined) { + transformed = transformProject({ + aliasPaths, + compilerOptions: options.compilerOptions, + currentFile: file, + currentSource: source, + plugins: options.plugins, + tsconfig, }); - return createTransformResult(source, code); + cache?.set(key, transformed); } - cache?.delete(key); - transformed = undefined; - } - - if (transformed === undefined) { - transformed = transformProject({ - aliasPaths, - compilerOptions: options.compilerOptions, - currentFile: file, - currentSource: source, - plugins: options.plugins, - tsconfig, + const generation = transformed; + const cached = await awaitOrEvict(cache, key, generation); + const { projectRoot, result, temporaryTsconfig } = cached; + reportSuccessDiagnostics(result); + const code = selectOrEvict(cache, key, generation, { + file, + projectRoot, + result, }); - cache?.set(key, transformed); - } - const generation = transformed; - const { projectRoot, result, temporaryTsconfig } = await awaitOrEvict( - cache, - key, - generation, - ); - reportSuccessDiagnostics(result); - const code = selectOrEvict(cache, key, generation, { - file, - projectRoot, - result, - }); - notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig }); - if (isVolatileFile({ file, projectRoot, result })) { - hooks?.markVolatile?.(); + notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig }); + markCachedSourceServed(cached, file); + if (isVolatileFile({ file, projectRoot, result })) { + hooks?.markVolatile?.(); + } + return createTransformResult(source, code); } - return createTransformResult(source, code); } /** @@ -745,24 +758,17 @@ export function createTransformResult( * Validate a cached project transform against the current on-disk project * state. * - * Re-hashes every file under the project root and overlays the current module's - * in-memory source, then compares the snapshot against the one captured when - * the result was produced. Any input under the project root changing (the - * module itself or a sibling the plugin reads) invalidates the entry and forces - * a re-transform. Out-of-walk inputs the compiler reported (`node_modules` - * declarations, sibling-package sources, out-of-root config ancestry) are - * validated through {@link TtscCachedProjectTransform.externalInputHashes}; - * adapters additionally register them as derived watch inputs (the host-owned - * `graph` union the reported `dependencies`) → `addWatchFile` → the bundler's - * next `buildStart` cache clear. + * Always compares the current module's in-memory source with the generation + * snapshot. The first request for each module can then use the already compiled + * project result without re-reading every project file. Once that module has + * been served, a repeated request marks a possible new build in a long-lived + * host and re-hashes every project and out-of-walk input. Any mismatch forces a + * complete re-transform. Adapters with a real build boundary clear the cache on + * `buildStart`; adapters also register derived watch inputs with their hosts. * - * Both this snapshot and {@link collectInputHashes} draw their keys from the - * exact same {@link collectProjectInputHashes} walk, so the two always agree on - * the key universe. The earlier implementation overlaid the compiler's output - * keys here on only one side; those keys include out-of-walk program inputs - * (`node_modules` declarations, sibling-package sources), so the snapshots - * never matched and the cache missed on every module; re-transforming the whole - * project once per file on any project that imports a typed dependency. + * The complete validation snapshot and {@link collectInputHashes} draw their + * keys from the exact same {@link collectProjectInputHashes} walk, so the two + * agree on the key universe. */ function matchesCachedSource( cached: TtscCachedProjectTransform, @@ -770,6 +776,12 @@ function matchesCachedSource( source: string, ): boolean { const currentKey = toProjectKey(cached.projectRoot, file); + if (cached.inputHashes[currentKey] !== hashText(source)) { + return false; + } + if (!cached.servedFiles?.has(pathIdentityKey(file))) { + return true; + } const currentHashes = collectProjectInputHashes(cached.projectRoot); currentHashes[currentKey] = hashText(source); if (!sameHashes(cached.inputHashes, currentHashes)) { @@ -792,6 +804,14 @@ function matchesCachedSource( ); } +/** Record a successfully selected module as delivered by this generation. */ +function markCachedSourceServed( + cached: TtscCachedProjectTransform, + file: string, +): void { + (cached.servedFiles ??= new Set()).add(pathIdentityKey(file)); +} + /** * Build the input-hash snapshot stored alongside a fresh compiler result. * @@ -1112,6 +1132,7 @@ async function transformProject(props: { }), projectRoot, result, + servedFiles: new Set(), // Remember the generated temp-dir tsconfig (disposed below) so watch // derivation can drop it from the envelope's config chain; a registered // but deleted file would invalidate every persistent-cache snapshot. diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts new file mode 100644 index 000000000..ecb0de4cf --- /dev/null +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts @@ -0,0 +1,17 @@ +import { assertFirstModuleDeliveriesDoNotRehashProject } from "../../internal/transform-project-cache"; + +/** + * Verifies a cached whole-project transform selects first-use modules in O(1). + * + * The compiler already produced every module and the generation snapshot. + * Re-reading all project files before each module selection turns an N-file + * initial build into N complete project walks. + * + * 1. Compile a 24-module project and cache its whole-project result. + * 2. Count project reads while requesting every remaining module once. + * 3. Assert no project file was re-read and the native transform ran once. + */ +export const test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery = + async () => { + await assertFirstModuleDeliveriesDoNotRehashProject(); + }; diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_retries_a_newer_generation_after_a_stale_input_mismatch.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_retries_a_newer_generation_after_a_stale_input_mismatch.ts new file mode 100644 index 000000000..d25ca70d9 --- /dev/null +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_retries_a_newer_generation_after_a_stale_input_mismatch.ts @@ -0,0 +1,17 @@ +import { assertStaleMismatchUsesNewerGeneration } from "../../internal/transform-project-cache"; + +/** + * Verifies stale input validation cannot delete a newer cache generation. + * + * An old generation can finish after another caller has replaced its cache + * slot. If the old snapshot then mismatches, unconditional deletion removes the + * replacement and starts an unnecessary third compile. + * + * 1. Begin awaiting a deferred stale generation. + * 2. Install a valid newer generation, then resolve the old one with a mismatch. + * 3. Assert the request retries and retains the newer generation. + */ +export const test_transformttsc_retries_a_newer_generation_after_a_stale_input_mismatch = + async () => { + await assertStaleMismatchUsesNewerGeneration(); + }; diff --git a/tests/test-unplugin/src/internal/transform-project-cache.ts b/tests/test-unplugin/src/internal/transform-project-cache.ts index 99cbfa501..2d24ef82a 100644 --- a/tests/test-unplugin/src/internal/transform-project-cache.ts +++ b/tests/test-unplugin/src/internal/transform-project-cache.ts @@ -292,6 +292,102 @@ async function assertConcurrentTransformsCompileOnce(): Promise { assert.equal(pluginRuns, 1, "concurrent callers must share one compile"); } +/** + * Asserts the first delivery of each module does not re-read the entire + * project. + * + * A project transform already returns output and an input snapshot for every + * module. Re-hashing all P project files before selecting each of N outputs + * makes the first build O(N x P), even though no generation has crossed a build + * boundary. The cache can compare each supplied module source with its snapshot + * entry and reserve complete validation for a repeated module request. + */ +async function assertFirstModuleDeliveriesDoNotRehashProject(): Promise { + const { createTtscTransformCache, resolveOptions, transformTtsc } = + await TestUnpluginRuntime.loadUnpluginApi(); + const project = createCacheProject({ fileCount: 24 }); + const modules = projectModules(project.root); + const sources = new Map( + modules.map((file) => [file, fs.readFileSync(file, "utf8")]), + ); + const cache = createTtscTransformCache(); + const options = resolveOptions(); + + const first = modules[0]!; + assert.ok( + await transformTtsc(first, sources.get(first)!, options, undefined, cache), + ); + + const originalReadFileSync = fs.readFileSync; + let projectReads = 0; + fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if ( + typeof file === "string" && + path.resolve(file).startsWith(`${path.resolve(project.root)}${path.sep}`) + ) { + ++projectReads; + } + return (originalReadFileSync as (...params: unknown[]) => unknown)( + file, + ...args, + ); + }) as typeof fs.readFileSync; + try { + for (const file of modules.slice(1)) { + assert.ok( + await transformTtsc( + file, + sources.get(file)!, + options, + undefined, + cache, + ), + ); + } + } finally { + fs.readFileSync = originalReadFileSync; + } + + assert.equal( + projectReads, + 0, + "first module deliveries must not re-hash the project snapshot", + ); + assert.equal(fs.readFileSync(project.runLog, "utf8").length, 1); +} + +/** + * Asserts a stale input-mismatch cleanup neither deletes nor bypasses a newer + * generation installed while the stale Promise was pending. + */ +async function assertStaleMismatchUsesNewerGeneration(): Promise { + const { api, cache, key, good, file, source, options } = + await primeSuccessfulTransform(); + + let resolveStale!: (value: unknown) => void; + const stale = new Promise((resolve) => { + resolveStale = resolve; + }); + cache.set(key, stale); + const pending = api.transformTtsc(file, source, options, undefined, cache); + + const newer = Promise.resolve(good); + cache.set(key, newer); + resolveStale({ + ...(good as Record), + inputHashes: {}, + }); + + const result = await pending; + assert.ok(result); + TestUnpluginProject.assertTransformedToPlugin(result.code); + assert.equal( + cache.get(key), + newer, + "a stale mismatch must retry the authoritative newer generation", + ); +} + /** Absolute, sorted list of the project's `src/*.ts` modules. */ function projectModules(root: string): string[] { const srcDir = path.join(root, "src"); @@ -491,7 +587,9 @@ export { assertCacheHitsDespiteOutOfWalkOutputKey, assertCacheTransformsMultiFileProjectOnce, assertConcurrentTransformsCompileOnce, + assertFirstModuleDeliveriesDoNotRehashProject, assertHostExceptionTransformIsEvictedAndRecovers, assertRejectedTransformIsEvictedAndRecovers, assertStaleEvictionKeepsNewerGeneration, + assertStaleMismatchUsesNewerGeneration, }; diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index e451916aa..4f46bb9e9 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -203,7 +203,7 @@ This works for every adapter automatically when the transform host emits the env A plugin that knows exactly which declarations it consulted can go the other way and declare its reported list [complete](/docs/development/concepts/protocol#dependency-completeness) for a file. The adapter then registers only that list plus the tsconfig chain for it, so a change to a file the transform never read stops re-running the loader. Nothing is required of you: the field is per file and opt-in per plugin, and a plugin that never declares it keeps the sound default above. -The transform cache validates against the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. Hosts that keep one cache for the process lifetime instead of per build (Metro workers, the Turbopack loader, Bun) therefore recompile when an input such as a `node_modules` declaration, a monorepo sibling source, or a file reached through a symlink or Windows junction changes, not only on ordinary in-project edits. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output, so the first request for each module checks its supplied source against that generation without re-reading the project. A repeated request for a module validates the complete project and external-input snapshots; adapters with a build lifecycle clear the generation at `buildStart`. This avoids one complete project walk per module on an initial build while long-lived hosts (Metro workers, the Turbopack loader, Bun) still recompile after an input such as a `node_modules` declaration, a monorepo sibling source, or a file reached through a symlink or Windows junction changes. This section is about the **transform** cache (which files a rebuild depends on), not the **source-plugin binary** cache (the compiled Go plugin ttsc builds on first use). They are independent: persisting a bundler cache such as Next.js `.next/cache` does not carry the plugin binary across builds, and a fresh CI or container build recompiles the plugin (the `ttsc: building source plugin ...` line) even with a warm bundler cache. To keep that first-build compile from recurring on every build, persist the binary cache as described in [Compile → Plugin cache](/docs/ttsc/compile#plugin-cache). In a monorepo, run `ttsc prepare` from the same project directory the bundler builds, so the warmed binary matches the cache key the bundler build looks up. From 90a374971a49349d55a3628b28fe21aecdddbe4a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:04:33 +0900 Subject: [PATCH 04/34] fix(unplugin): honor host cache lifecycles Restrict first-delivery cache shortcuts to hosts that signal a build start, and retry any generation superseded while a caller was awaiting it. Close #976: let Bun loaders fall through on excluded or unchanged modules and clear bundler generations through onStart. --- packages/metro/src/transformer.ts | 10 +- packages/unplugin/README.md | 4 +- packages/unplugin/src/bun.ts | 90 +++++++------- packages/unplugin/src/core/index.ts | 6 +- packages/unplugin/src/core/transform.ts | 66 +++++++--- packages/unplugin/src/turbopack.ts | 6 +- ...ough_for_excluded_and_unchanged_modules.ts | 17 +++ ...un_adapter_forwards_bundler_build_start.ts | 16 +++ ..._serve_a_superseded_matching_generation.ts | 17 +++ ...tes_inputs_before_first_module_delivery.ts | 17 +++ .../test-unplugin/src/internal/adapter-bun.ts | 99 ++++++++++++++- .../src/internal/transform-project-cache.ts | 114 +++++++++++++++++- website/src/content/docs/setup/unplugin.mdx | 4 +- 13 files changed, 390 insertions(+), 76 deletions(-) create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_adapter_falls_through_for_excluded_and_unchanged_modules.ts create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_adapter_forwards_bundler_build_start.ts create mode 100644 tests/test-unplugin/src/features/transform/test_transformttsc_does_not_serve_a_superseded_matching_generation.ts create mode 100644 tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts diff --git a/packages/metro/src/transformer.ts b/packages/metro/src/transformer.ts index d616a4526..650fc706b 100644 --- a/packages/metro/src/transformer.ts +++ b/packages/metro/src/transformer.ts @@ -9,11 +9,11 @@ * (strips types, RN transforms) -> Babel AST (what Metro consumes) * * The ttsc pass reuses `@ttsc/unplugin`'s `transformTtsc`, so the plugin - * contract, tsconfig discovery, and per-build cache are identical to every - * other bundler integration. Cross-file cache invalidation rides the project - * fingerprint {@link getCacheKey} folds into Metro's static transformer key (see - * `core/fingerprint.ts`); the package README covers the v1 cost model and the - * remaining watch-session boundary. + * contract and tsconfig discovery are identical to the bundler integrations. + * Its per-worker cache has no build-start signal and therefore validates every + * generation hit. Cross-file invalidation also rides the project fingerprint + * {@link getCacheKey} folds into Metro's static transformer key (see + * `core/fingerprint.ts`). */ import { createTtscTransformCache, diff --git a/packages/unplugin/README.md b/packages/unplugin/README.md index 9ea5000bf..c82e03244 100644 --- a/packages/unplugin/README.md +++ b/packages/unplugin/README.md @@ -165,6 +165,8 @@ await Bun.build({ }); ``` +The adapter yields to the next Bun loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. `Bun.build` also clears the project generation through its `onStart` lifecycle on every build; the Bun runtime API has no corresponding hook, so its long-lived loader performs complete input validation on every generation hit. + ## Configuration By default, `@ttsc/unplugin` finds the nearest `tsconfig.json` from the file being transformed and uses that project's plugin settings, including directly installed plugin packages. @@ -310,7 +312,7 @@ The transform host reports the program's reference graph (the transform envelope Transform plugins may additionally report, per file, the source files they consulted (the envelope's `dependencies` field); the adapter registers those as watch files too, union semantics. A plugin that declares such a list [complete](https://ttsc.dev/docs/development/concepts/protocol#dependency-completeness) for a file narrows the registration instead: only its own list plus the tsconfig chain, so files the transform never consulted stop invalidating it. Files a plugin declares `volatile` (output depending on non-file inputs such as environment or time) bypass the adapter's transform cache and are marked uncacheable where the bundler exposes that control. -The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). One whole-project compile already contains every module's output, so the first request for each module checks its supplied source against that generation without re-reading the project. A repeated request for a module validates the complete project and external-input snapshots; adapters with a build lifecycle clear the generation at `buildStart`. This avoids one complete project walk per module on an initial build while long-lived hosts (Metro workers, the Turbopack loader, Bun) still recompile after an input changes. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). One whole-project compile already contains every module's output. Adapters with a guaranteed `buildStart` boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Long-lived hosts without that boundary (Metro workers, the Turbopack loader, and the Bun runtime loader) validate the complete project and external-input snapshots on every generation hit. ## Sponsors diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index 9b52af69d..379e0f5cb 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -1,8 +1,13 @@ import fs from "node:fs/promises"; -import type { UnpluginContextMeta } from "unplugin"; -import { unplugin } from "./api"; -import { sourceFilePattern } from "./core/index"; +import { + beginTtscTransformBuild, + createTtscTransformCache, + isTransformTarget, + resolveOptions, + sourceFilePattern, + transformTtsc, +} from "./core/index"; import type { TtscUnpluginOptions } from "./core/options"; /** @@ -38,7 +43,7 @@ export type TtscBunOptions = | (() => TtscUnpluginOptions | undefined); /** - * Transform context handed to the raw unplugin transform under Bun. + * Transform hooks handed to the shared transform under Bun. * * The shared transform calls `addWatchFile` once per plugin-reported dependency * so type-only inputs can enter a bundler's watch graph. Bun's bundler and @@ -49,7 +54,7 @@ export type TtscBunOptions = * `this.addWatchFile` `undefined`, so any plugin reporting dependencies threw * `TypeError: this.addWatchFile is not a function`. */ -const bunTransformContext = { +const bunTransformHooks = { addWatchFile(): void {}, }; @@ -63,10 +68,17 @@ function resolveBunOptions( /** * Minimal subset of the Bun `BuildConfig` plugin build object. * - * Only the `onLoad` hook is used; other hooks are not needed for a - * source-to-source transform. + * `onLoad` drives the source transform. Bun's bundler also exposes `onStart`, + * which is used when available to forward the shared plugin's build lifecycle + * and clear its per-build cache. The runtime plugin API omits that hook. */ export interface BunLikeBuild { + /** + * Register a callback for the start of a bundler build. + * + * Optional because `Bun.plugin()` runtime builders do not expose this hook. + */ + onStart?(callback: () => void | Promise): void; /** * Register a loader callback for files matching `filter`. * @@ -80,18 +92,18 @@ export interface BunLikeBuild { options: { filter: RegExp }, loader: (args: { path: string; - }) => Promise<{ contents: string; loader: BunLoader }>, + }) => Promise<{ contents: string; loader: BunLoader } | undefined>, ): void; } /** * Create a ttsc plugin for Bun's bundler AND runtime. * - * Bun does not implement the unplugin protocol, so this adapter instantiates - * the raw unplugin transform and wires it to Bun's `onLoad` hook manually. The - * adapter reads each matching file from disk and forwards the content to the - * ttsc transform; if the transform returns no changes the original source is - * passed through unchanged. + * Bun does not implement the unplugin protocol, so this adapter wires the + * shared ttsc transform core to Bun's `onLoad` hook directly. It reads each + * included file from disk and forwards the content to the transform. Excluded + * files and no-op transforms return `undefined` so Bun's next loader or + * built-in TypeScript loader retains ownership. * * The same object works for `Bun.build({ plugins: [ttsc()] })` (bundler) and * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see @@ -103,41 +115,33 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { return { name: "ttsc-unplugin", setup(build) { - // Build the raw transform lazily on first load rather than in `setup`. - // Bun runs `setup` synchronously when the plugin is registered, so a - // runtime registration (bun-register) that resolves its effective options - // through a provider must defer that resolution until after any explicit - // `register(options)` call in the same tick. Deferring also keeps a single - // transform (and its project cache) shared across every loaded module. - let raw: ReturnType | undefined; + // Resolve options lazily on first load. Runtime registration may call + // register(options) immediately after the import-time default + // registration; the provider form must observe that last synchronous + // update without installing a second shadowing loader. + let resolved: ReturnType | undefined; + const getOptions = () => + (resolved ??= resolveOptions(resolveBunOptions(options))); + const cache = createTtscTransformCache(); + build.onStart?.(() => beginTtscTransformBuild(cache)); build.onLoad({ filter: sourceFilePattern }, async (args) => { - raw ??= unplugin.raw( - resolveBunOptions(options), - {} as UnpluginContextMeta, - ); + if (!isTransformTarget(args.path)) { + return undefined; + } const loader = bunLoaderFor(args.path); const source = await fs.readFile(args.path, "utf8"); - const result = - typeof raw.transform === "function" - ? await raw.transform.call( - bunTransformContext as never, - source, - args.path, - ) - : undefined; - // Unpack both shorthand string and object result shapes. - if (typeof result === "string") { - return { contents: result, loader }; - } - if ( - typeof result === "object" && - result !== null && - "code" in result && - typeof result.code === "string" - ) { + const result = await transformTtsc( + args.path, + source, + getOptions(), + undefined, + cache, + bunTransformHooks, + ); + if (result !== undefined) { return { contents: result.code, loader }; } - return { contents: source, loader }; + return undefined; }); }, }; diff --git a/packages/unplugin/src/core/index.ts b/packages/unplugin/src/core/index.ts index a8bfc87d2..51eee4fd5 100644 --- a/packages/unplugin/src/core/index.ts +++ b/packages/unplugin/src/core/index.ts @@ -6,6 +6,7 @@ import { createUnplugin } from "unplugin"; import type { TtscUnpluginOptions } from "./options"; import { resolveOptions } from "./options"; import { + beginTtscTransformBuild, collectExternalInputHashes, collectProjectInputHashes, createTtscTransformCache, @@ -81,7 +82,7 @@ const unpluginFactory: UnpluginFactory< }, buildStart() { - transformCache.clear(); + beginTtscTransformBuild(transformCache); }, transformInclude(id) { @@ -142,6 +143,7 @@ export type { } from "./options"; export type { TtscTransformHooks } from "./transform"; export { + beginTtscTransformBuild, collectExternalInputHashes, collectProjectInputHashes, createTtscTransformCache, @@ -160,7 +162,7 @@ export default unplugin; * Excluded ids: virtual modules (NUL prefix), `.d.ts` declaration files, and * anything inside `node_modules`. */ -function isTransformTarget(id: string): boolean { +export function isTransformTarget(id: string): boolean { return ( sourceFilePattern.test(id) && !virtualModulePattern.test(id) && diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index a6aac4a8b..844e4c23f 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -40,14 +40,14 @@ export interface TtscTransformAlias { } /** - * A single entry in the per-build transform cache. + * A single entry in the project transform cache. * * Stores the full compiler result together with SHA-256 hashes of every project - * input file. The first delivery of each compiled module compares its supplied - * source with the generation snapshot in constant time; a repeated delivery - * re-hashes the complete input set to validate a long-lived cache. This avoids - * an O(modules x project files) first build while retaining cross-build - * invalidation for hosts that do not expose a build boundary. + * input file. In a cache with an explicit build lifecycle, the first delivery + * of each compiled module compares its supplied source with the generation + * snapshot in constant time; a repeated delivery re-hashes the complete input + * set. Persistent caches without that boundary perform complete validation on + * every hit. */ export interface TtscCachedProjectTransform { /** @@ -81,9 +81,8 @@ export interface TtscCachedProjectTransform { result: ITtscCompilerTransformation; /** * Files already delivered from this generation, keyed by filesystem identity. - * A repeated request is the observable boundary between builds for hosts that - * cannot clear the cache on `buildStart`, so it performs complete project and - * external-input validation. + * Build-scoped caches use this to skip complete validation only for a + * module's first delivery inside the current build. */ servedFiles?: Set; /** @@ -108,11 +107,29 @@ export type TtscTransformCache = Map< Promise >; -/** Create an empty transform cache for a single build session. */ +/** + * Caches whose owner has declared a real per-build lifecycle by calling + * {@link beginTtscTransformBuild} before transforms begin. + */ +const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet(); + +/** Create an empty persistent transform cache. */ export function createTtscTransformCache(): TtscTransformCache { return new Map(); } +/** + * Start a host build, clearing its prior generation and enabling constant-time + * first delivery for modules compiled during this build. + * + * Hosts without a guaranteed build-start callback must not call this function; + * their persistent caches validate the complete input snapshot on every hit. + */ +export function beginTtscTransformBuild(cache: TtscTransformCache): void { + cache.clear(); + BUILD_SCOPED_TRANSFORM_CACHES.add(cache); +} + /** Cached case-insensitivity probes for existing macOS filesystem locations. */ const CASE_INSENSITIVE_FILESYSTEMS = new Map(); @@ -204,6 +221,11 @@ export async function transformTtsc( // A rejected in-flight generation must not stay cached: evict it (only if // it is still the current entry) so a later call re-runs the transform. const cached = await awaitOrEvict(cache, key, transformed); + // While this caller awaited the old Promise, another caller may have + // invalidated it and installed a newer authoritative generation. + if (cache?.get(key) !== transformed) { + continue; + } if ( // A file the plugin declared volatile must never be served from the // cache: its output depends on non-file inputs, so the input-hash @@ -213,7 +235,12 @@ export async function transformTtsc( projectRoot: cached.projectRoot, result: cached.result, }) && - matchesCachedSource(cached, file, source) + matchesCachedSource( + cached, + file, + source, + cache !== undefined && BUILD_SCOPED_TRANSFORM_CACHES.has(cache), + ) ) { reportSuccessDiagnostics(cached.result); // A resolved `"exception"` / `"failure"` envelope makes this throw; @@ -255,6 +282,9 @@ export async function transformTtsc( } const generation = transformed; const cached = await awaitOrEvict(cache, key, generation); + if (cache !== undefined && cache.get(key) !== generation) { + continue; + } const { projectRoot, result, temporaryTsconfig } = cached; reportSuccessDiagnostics(result); const code = selectOrEvict(cache, key, generation, { @@ -759,12 +789,11 @@ export function createTransformResult( * state. * * Always compares the current module's in-memory source with the generation - * snapshot. The first request for each module can then use the already compiled - * project result without re-reading every project file. Once that module has - * been served, a repeated request marks a possible new build in a long-lived - * host and re-hashes every project and out-of-walk input. Any mismatch forces a - * complete re-transform. Adapters with a real build boundary clear the cache on - * `buildStart`; adapters also register derived watch inputs with their hosts. + * snapshot. A cache whose owner called {@link beginTtscTransformBuild} can use + * that comparison alone for the module's first delivery in the current build; + * repeated requests re-hash every project and out-of-walk input. Persistent + * caches with no guaranteed build boundary perform complete validation on every + * hit. Any mismatch forces a complete re-transform. * * The complete validation snapshot and {@link collectInputHashes} draw their * keys from the exact same {@link collectProjectInputHashes} walk, so the two @@ -774,12 +803,13 @@ function matchesCachedSource( cached: TtscCachedProjectTransform, file: string, source: string, + buildScoped: boolean, ): boolean { const currentKey = toProjectKey(cached.projectRoot, file); if (cached.inputHashes[currentKey] !== hashText(source)) { return false; } - if (!cached.servedFiles?.has(pathIdentityKey(file))) { + if (buildScoped && !cached.servedFiles?.has(pathIdentityKey(file))) { return true; } const currentHashes = collectProjectInputHashes(cached.projectRoot); diff --git a/packages/unplugin/src/turbopack.ts b/packages/unplugin/src/turbopack.ts index a14ae72ce..b9fbaa0b3 100644 --- a/packages/unplugin/src/turbopack.ts +++ b/packages/unplugin/src/turbopack.ts @@ -45,9 +45,9 @@ const nodeModulesPattern = /(?:^|[/\\])node_modules(?:[/\\]|$)/; /** * Per-process transform cache. Turbopack runs loaders in a worker pool and * never signals build boundaries to a loader, so the cache lives for the - * worker's lifetime; entries self-invalidate by re-hashing the project's input - * files on every request (see `transformTtsc`), which is the same freshness - * rule the bundler-plugin adapters rely on between watch rebuilds. + * worker's lifetime. Because no build-start boundary exists, every cache hit + * validates all project and graph inputs before selecting output (see + * `transformTtsc`). */ const transformCache = createTtscTransformCache(); diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_falls_through_for_excluded_and_unchanged_modules.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_falls_through_for_excluded_and_unchanged_modules.ts new file mode 100644 index 000000000..de8334121 --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_falls_through_for_excluded_and_unchanged_modules.ts @@ -0,0 +1,17 @@ +import { assertBunAdapterFallsThroughWhenItDoesNotTransform } from "../../internal/adapter-bun"; + +/** + * Verifies the Bun adapter does not claim modules it did not transform. + * + * Bun stops its loader chain at the first `onLoad` callback that returns a + * value. Returning the original source for an excluded or unchanged file + * shadows later plugins and Bun's built-in loader. + * + * 1. Request a missing TypeScript path inside `node_modules`. + * 2. Request a project source with ttsc plugins disabled. + * 3. Assert both return `undefined`, and the excluded path is never read. + */ +export const test_bun_adapter_falls_through_for_excluded_and_unchanged_modules = + async () => { + await assertBunAdapterFallsThroughWhenItDoesNotTransform(); + }; diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_forwards_bundler_build_start.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_forwards_bundler_build_start.ts new file mode 100644 index 000000000..5839d40e1 --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_forwards_bundler_build_start.ts @@ -0,0 +1,16 @@ +import { assertBunAdapterClearsCacheOnBuildStart } from "../../internal/adapter-bun"; + +/** + * Verifies Bun bundler builds clear the shared transform generation. + * + * Bun's bundler exposes `onStart`, while its runtime plugin builder does not. + * Ignoring the bundler hook lets first-use modules in a previous generation + * cross a rebuild boundary without complete input validation. + * + * 1. Compile a project whose generation includes an unrequested second module. + * 2. Corrupt another project input and invoke the captured build-start hook. + * 3. Request the second module and assert a fresh compile observes the error. + */ +export const test_bun_adapter_forwards_bundler_build_start = async () => { + await assertBunAdapterClearsCacheOnBuildStart(); +}; diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_does_not_serve_a_superseded_matching_generation.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_does_not_serve_a_superseded_matching_generation.ts new file mode 100644 index 000000000..5182d3521 --- /dev/null +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_does_not_serve_a_superseded_matching_generation.ts @@ -0,0 +1,17 @@ +import { assertSupersededMatchingGenerationIsNotServed } from "../../internal/transform-project-cache"; + +/** + * Verifies a matching waiter cannot return a superseded cache generation. + * + * Two callers can resume from one old Promise. One observes a source mismatch + * and installs a new generation; the other source still matches the old + * snapshot. The latter must re-check cache identity before selecting output. + * + * 1. Start mismatching and matching callers on a deferred stale generation. + * 2. Resolve it with an observable stale output marker. + * 3. Assert neither caller returns that superseded output. + */ +export const test_transformttsc_does_not_serve_a_superseded_matching_generation = + async () => { + await assertSupersededMatchingGenerationIsNotServed(); + }; diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts new file mode 100644 index 000000000..e70cf3207 --- /dev/null +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts @@ -0,0 +1,17 @@ +import { assertPersistentCacheValidatesAnUnservedModule } from "../../internal/transform-project-cache"; + +/** + * Verifies lifecycle-less caches validate before serving an unrequested module. + * + * A Metro, Turbopack, or Bun-runtime cache can outlive a build. A project + * generation may contain a lazy module that was never requested during the old + * build, so "first delivery" alone does not prove its other inputs are fresh. + * + * 1. Compile a generation containing an unrequested lazy module. + * 2. Change another project input without signaling a build start. + * 3. Request the lazy module and assert the generation is replaced. + */ +export const test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery = + async () => { + await assertPersistentCacheValidatesAnUnservedModule(); + }; diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index d3e2c58f9..54710da50 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -1,5 +1,6 @@ import { TestUnpluginProject, TestUnpluginRuntime } from "@ttsc/testing"; import assert from "node:assert/strict"; +import fs from "node:fs"; import path from "node:path"; /** Minimal shape of the options object passed to `onLoad` in the Bun plugin API. */ @@ -11,7 +12,7 @@ type BunLoadOptions = { filter: RegExp }; */ type BunLoader = (args: { path: string; -}) => Promise<{ contents: string; loader: string }>; +}) => Promise<{ contents: string; loader: string } | undefined>; /** * Register the Bun adapter against a capturing `setup` stub and return the @@ -57,6 +58,7 @@ async function assertBunAdapterTransformsSource() { throw new Error("Bun adapter did not register a TypeScript source filter"); } const result = await loader({ path: TestUnpluginProject.mainFile(root) }); + assert.ok(result); TestUnpluginProject.assertTransformedToPlugin(result.contents); // The loader field is what lets the same adapter drive Bun's runtime // (`Bun.plugin` / bunfig preload): Bun must be told the emitted contents are @@ -103,17 +105,112 @@ async function assertBunAdapterSurvivesPluginReportedDependencies() { // Fresh transform: the reported dependencies reach the watch hook. The old // empty-receiver context threw here instead of returning source. const first = await loader({ path: TestUnpluginProject.mainFile(root) }); + assert.ok(first); TestUnpluginProject.assertTransformedToPlugin(first.contents); assert.equal(first.loader, "ts"); // Cache hit: the shared transform replays the dependency notification, so the // no-op context must stay valid on the second load too. const second = await loader({ path: TestUnpluginProject.mainFile(root) }); + assert.ok(second); TestUnpluginProject.assertTransformedToPlugin(second.contents); assert.equal(second.loader, "ts"); } +/** + * Asserts excluded files and no-op transforms fall through to Bun's next + * loader. + * + * Bun stops at the first `onLoad` callback that returns a value. The adapter's + * broad TypeScript filter therefore must consult the shared `transformInclude` + * predicate before reading and return `undefined` when the path is excluded or + * the transform produced no code. + */ +async function assertBunAdapterFallsThroughWhenItDoesNotTransform(): Promise { + const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); + const { loader } = await captureBunLoader( + unpluginBun({ + plugins: [], + }), + ); + + assert.equal( + await loader({ + path: path.join( + TestUnpluginProject.createProject(), + "node_modules", + "missing", + "index.ts", + ), + }), + undefined, + "an excluded path must not be read or claim the loader chain", + ); + + const root = TestUnpluginProject.createProject({ plugins: [] }); + assert.equal( + await loader({ path: TestUnpluginProject.mainFile(root) }), + undefined, + "a no-op transform must fall through to Bun's built-in TypeScript loader", + ); +} + +/** + * Asserts Bun bundler `onStart` forwards the shared transform build lifecycle. + * + * The first compile emits a second module but only serves `main.ts`. After + * corrupting `main.ts`, the unchanged second module would still be a valid + * first-use cache hit unless the next build's `onStart` clears the generation. + */ +async function assertBunAdapterClearsCacheOnBuildStart(): Promise { + const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); + const root = TestUnpluginProject.createProject({ + plugins: [ + { + transform: "./plugin.cjs", + name: "fixture", + operation: "echo-file", + path: "src/secondary.ts", + }, + ], + }); + const secondary = path.join(root, "src", "secondary.ts"); + fs.writeFileSync(secondary, "export const secondary = 1;\n", "utf8"); + + let start: (() => void | Promise) | undefined; + const loaders: BunLoader[] = []; + unpluginBun().setup({ + onStart(callback: () => void | Promise) { + start = callback; + }, + onLoad(_options: BunLoadOptions, loader: BunLoader) { + loaders.push(loader); + }, + }); + assert.ok(start, "Bun bundler setup must register onStart when available"); + const loader = loaders[0]; + assert.ok(loader); + + const first = await loader({ path: TestUnpluginProject.mainFile(root) }); + assert.ok(first); + TestUnpluginProject.assertTransformedToPlugin(first.contents); + + fs.writeFileSync( + TestUnpluginProject.mainFile(root), + "export const broken = true;\n", + "utf8", + ); + await start(); + await assert.rejects( + () => loader({ path: secondary }), + /expected export const value/, + "the next build must compile again instead of serving the old generation", + ); +} + export { + assertBunAdapterClearsCacheOnBuildStart, + assertBunAdapterFallsThroughWhenItDoesNotTransform, assertBunAdapterSurvivesPluginReportedDependencies, assertBunAdapterTransformsSource, }; diff --git a/tests/test-unplugin/src/internal/transform-project-cache.ts b/tests/test-unplugin/src/internal/transform-project-cache.ts index 2d24ef82a..5a6fee737 100644 --- a/tests/test-unplugin/src/internal/transform-project-cache.ts +++ b/tests/test-unplugin/src/internal/transform-project-cache.ts @@ -303,14 +303,19 @@ async function assertConcurrentTransformsCompileOnce(): Promise { * entry and reserve complete validation for a repeated module request. */ async function assertFirstModuleDeliveriesDoNotRehashProject(): Promise { - const { createTtscTransformCache, resolveOptions, transformTtsc } = - await TestUnpluginRuntime.loadUnpluginApi(); + const { + beginTtscTransformBuild, + createTtscTransformCache, + resolveOptions, + transformTtsc, + } = await TestUnpluginRuntime.loadUnpluginApi(); const project = createCacheProject({ fileCount: 24 }); const modules = projectModules(project.root); const sources = new Map( modules.map((file) => [file, fs.readFileSync(file, "utf8")]), ); const cache = createTtscTransformCache(); + beginTtscTransformBuild(cache); const options = resolveOptions(); const first = modules[0]!; @@ -356,6 +361,57 @@ async function assertFirstModuleDeliveriesDoNotRehashProject(): Promise { assert.equal(fs.readFileSync(project.runLog, "utf8").length, 1); } +/** + * Asserts a cache with no build-start lifecycle validates every generation hit, + * including a module that generation has not served before. + */ +async function assertPersistentCacheValidatesAnUnservedModule(): Promise { + const { createTtscTransformCache, resolveOptions, transformTtsc } = + await TestUnpluginRuntime.loadUnpluginApi(); + const root = TestUnpluginProject.createProject({ + plugins: [ + { + transform: "./plugin.cjs", + name: "fixture", + operation: "echo-file", + path: "src/lazy.ts", + }, + ], + }); + const lazy = path.join(root, "src", "lazy.ts"); + fs.writeFileSync(lazy, "export const lazy = 1;\n", "utf8"); + const cache = createTtscTransformCache(); + const options = resolveOptions(); + + assert.ok( + await transformTtsc( + TestUnpluginProject.mainFile(root), + TestUnpluginProject.mainSource(root), + options, + undefined, + cache, + ), + ); + const oldGeneration = [...cache.values()][0]; + assert.ok(oldGeneration); + + fs.appendFileSync(path.join(root, "plugin.cjs"), "\n// changed input\n"); + const result = await transformTtsc( + lazy, + fs.readFileSync(lazy, "utf8"), + options, + undefined, + cache, + ); + assert.ok(result); + assert.match(result.code, /ttsc-fixture/); + assert.notEqual( + [...cache.values()][0], + oldGeneration, + "a persistent cache must validate unrelated inputs before first delivery", + ); +} + /** * Asserts a stale input-mismatch cleanup neither deletes nor bypasses a newer * generation installed while the stale Promise was pending. @@ -388,6 +444,58 @@ async function assertStaleMismatchUsesNewerGeneration(): Promise { ); } +/** + * Asserts a caller awaiting an old but otherwise matching generation retries + * when a sibling caller replaces that generation. + */ +async function assertSupersededMatchingGenerationIsNotServed(): Promise { + const { api, cache, key, good, file, source, options } = + await primeSuccessfulTransform(); + const goodRecord = good as { + result: { + typescript: Record; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + const outputKey = Object.keys(goodRecord.result.typescript)[0]!; + const staleValue = { + ...goodRecord, + result: { + ...goodRecord.result, + typescript: { + ...goodRecord.result.typescript, + [outputKey]: "export const marker = 'STALE';\n", + }, + }, + }; + + let resolveStale!: (value: unknown) => void; + const stale = new Promise((resolve) => { + resolveStale = resolve; + }); + cache.set(key, stale); + const mismatching = api.transformTtsc( + file, + `${source}\n// mismatching caller\n`, + options, + undefined, + cache, + ); + const matching = api.transformTtsc(file, source, options, undefined, cache); + resolveStale(staleValue); + + const matchingResult = await matching; + assert.ok(matchingResult); + assert.doesNotMatch( + matchingResult.code, + /STALE/, + "a matching waiter must not return a generation another caller superseded", + ); + assert.ok(await mismatching); + assert.notEqual(cache.get(key), stale); +} + /** Absolute, sorted list of the project's `src/*.ts` modules. */ function projectModules(root: string): string[] { const srcDir = path.join(root, "src"); @@ -589,7 +697,9 @@ export { assertConcurrentTransformsCompileOnce, assertFirstModuleDeliveriesDoNotRehashProject, assertHostExceptionTransformIsEvictedAndRecovers, + assertPersistentCacheValidatesAnUnservedModule, assertRejectedTransformIsEvictedAndRecovers, assertStaleEvictionKeepsNewerGeneration, assertStaleMismatchUsesNewerGeneration, + assertSupersededMatchingGenerationIsNotServed, }; diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index 4f46bb9e9..413f516fd 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -161,6 +161,8 @@ await Bun.build({ }); ``` +The adapter yields to the next Bun loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged, so its broad TypeScript hook does not shadow another plugin or Bun's built-in loader. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. + For the Bun **runtime**, `bun run`, `bun test`, or any `bun .ts` with no bundling step, register the transform on Bun's module loader with `@ttsc/unplugin/bun-register`. Add a `bunfig.toml` preload once: ```toml @@ -203,7 +205,7 @@ This works for every adapter automatically when the transform host emits the env A plugin that knows exactly which declarations it consulted can go the other way and declare its reported list [complete](/docs/development/concepts/protocol#dependency-completeness) for a file. The adapter then registers only that list plus the tsconfig chain for it, so a change to a file the transform never read stops re-running the loader. Nothing is required of you: the field is per file and opt-in per plugin, and a plugin that never declares it keeps the sound default above. -The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output, so the first request for each module checks its supplied source against that generation without re-reading the project. A repeated request for a module validates the complete project and external-input snapshots; adapters with a build lifecycle clear the generation at `buildStart`. This avoids one complete project walk per module on an initial build while long-lived hosts (Metro workers, the Turbopack loader, Bun) still recompile after an input such as a `node_modules` declaration, a monorepo sibling source, or a file reached through a symlink or Windows junction changes. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output. Adapters with a guaranteed `buildStart` boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Long-lived hosts without that boundary (Metro workers, the Turbopack loader, and the Bun runtime loader) validate the complete project and external-input snapshots on every generation hit. This section is about the **transform** cache (which files a rebuild depends on), not the **source-plugin binary** cache (the compiled Go plugin ttsc builds on first use). They are independent: persisting a bundler cache such as Next.js `.next/cache` does not carry the plugin binary across builds, and a fresh CI or container build recompiles the plugin (the `ttsc: building source plugin ...` line) even with a warm bundler cache. To keep that first-build compile from recurring on every build, persist the binary cache as described in [Compile → Plugin cache](/docs/ttsc/compile#plugin-cache). In a monorepo, run `ttsc prepare` from the same project directory the bundler builds, so the warmed binary matches the cache key the bundler build looks up. From 66a0a4b4b434d6921db1133f275d629e0d44dc2c Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:11:33 +0900 Subject: [PATCH 05/34] fix(playground): align sandbox package exports Close #977: preserve Node export-target selection, validate package boundaries, and resolve npm-alias self-references by manifest identity. --- packages/playground/README.md | 2 + .../src/sandbox/createSandboxRequire.ts | 374 ++++++++++++------ ...re_matches_node_export_target_decisions.ts | 59 +++ ...x_require_preserves_fallback_resolution.ts | 5 +- ...x_require_rejects_export_target_escapes.ts | 56 +++ ...esolves_aliased_package_self_references.ts | 43 ++ website/src/content/docs/playground.mdx | 2 +- 7 files changed, 427 insertions(+), 114 deletions(-) create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts diff --git a/packages/playground/README.md b/packages/playground/README.md index 9e7f9ff48..4d3bea5d5 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -148,6 +148,8 @@ When a direct source import is removed, solve the complete current root list wit `PlaygroundShell` wires this automatically on every keystroke, debounced 900 ms, with an abort signal on source change. +The Execute lane's CommonJS resolver treats `package.json#exports` as the package boundary. It selects `require` and `default` conditions in manifest order, preserves Node's distinction between target selection and file loading, validates relative targets after wildcard substitution, and rejects mixed or numeric condition maps. A package mounted through an npm alias may still self-reference its real manifest `name` when it declares exports; packages without exports keep legacy direct-path resolution and do not gain self-reference behavior. + ## Tailwind setup The bundled React components use Tailwind 4 utility classes. The host site must load Tailwind for them to render correctly. diff --git a/packages/playground/src/sandbox/createSandboxRequire.ts b/packages/playground/src/sandbox/createSandboxRequire.ts index 076ff9254..46f067288 100644 --- a/packages/playground/src/sandbox/createSandboxRequire.ts +++ b/packages/playground/src/sandbox/createSandboxRequire.ts @@ -4,13 +4,12 @@ // // Resolution algorithm (minimal): // - Bare specifier `typia/lib/X`: -// try `typia/lib/X.js`, then `typia/lib/X/index.js`, then honor the -// package's `exports` / `main` (read from /package.json) before -// giving up. +// honor the package's `exports` boundary when declared; otherwise try +// `typia/lib/X.js`, then `typia/lib/X/index.js`. // - Relative `./Y` / `../Y` (encountered when one pack module requires a // sibling): resolved against the caller's pack key. -// - Bare `name` (no subpath): honors the package `main` field, falling back -// to `name/index.js`. +// - Bare `name` (no subpath): honors package `exports`, then (only when +// exports is absent) `main`, falling back to `name/index.js`. // // Every successful load is cached so cyclic graphs settle. Module evaluation // wraps the source text in the standard CJS wrapper: @@ -36,6 +35,14 @@ interface ISandboxRequireOptions { // neither Node's runtime nor an ESM evaluator. const ACTIVE_EXPORT_CONDITIONS = new Set(["require", "default"]); +type ExportTargetResolution = + | { type: "resolved"; key: string } + | { type: "blocked" } + | { type: "unresolved" }; + +class InvalidPackageTargetError extends Error {} +class InvalidPackageConfigError extends Error {} + /** * Build a sandboxed `require` function over a runtime pack. Resolves typia / * `@typia/*` / randexp specifiers from the pack; throws on anything else so the @@ -56,96 +63,82 @@ export function createSandboxRequire( return null; }; - const packageDeclaresExports = (pkg: string): boolean => { - const key = `${pkg}/package.json`; - if (!has(key)) return false; + const readPackageJson = (mount: string): IPackJson | null => { + const key = `${mount}/package.json`; + if (!has(key)) return null; try { - return Object.prototype.hasOwnProperty.call( - JSON.parse(pack[key]!) as IPackJson, - "exports", - ); + const parsed = JSON.parse(pack[key]!) as unknown; + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new Error("package.json must contain an object"); + } + return parsed as IPackJson; } catch { - return false; + throw new InvalidPackageConfigError( + `invalid package configuration in ${key}`, + ); } }; - const resolveExportTarget = ( - pkg: string, - target: unknown, - replacement = "", - ): string | null => { - const candidates = conditionalExportTargets(target); - if (!candidates) return null; - for (const candidate of candidates) { - const resolved = `${pkg}/${stripDotSlash( - candidate.split("*").join(replacement), - )}`; - if (has(resolved)) return resolved; - } - return null; + const packageDeclaresExports = (mount: string): boolean => { + const manifest = readPackageJson(mount); + return ( + manifest !== null && + Object.prototype.hasOwnProperty.call(manifest, "exports") + ); }; // Read package.json from pack and resolve via main/exports. const resolvePackageEntry = ( - pkg: string, + mount: string, subpath: string | null, ): string | null => { - const pjKey = `${pkg}/package.json`; - if (!has(pjKey)) return null; - let pj: IPackJson; - try { - pj = JSON.parse(pack[pjKey]!) as IPackJson; - } catch { - return null; + const pj = readPackageJson(mount); + if (pj === null) return null; + if (Object.prototype.hasOwnProperty.call(pj, "exports")) { + const resolution = resolvePackageExports(mount, pj.exports, subpath); + return resolution.type === "resolved" ? resolution.key : null; } if (subpath === null) { - // bare "name": honor the root `exports` entry (string, subpath table - // "." key, or bare condition map) → CJS target, else main, else index. - if (pj.exports !== undefined) - return resolveExportTarget(pkg, rootExportTarget(pj.exports)); if (typeof pj.main === "string") { return tryPaths( - `${pkg}/${stripDotSlash(pj.main)}`, - `${pkg}/${stripDotSlash(pj.main)}.js`, - `${pkg}/${stripDotSlash(pj.main)}.cjs`, - `${pkg}/${stripDotSlash(pj.main)}.mjs`, - `${pkg}/${stripDotSlash(pj.main)}.json`, - `${pkg}/${stripDotSlash(pj.main)}/index.js`, - `${pkg}/${stripDotSlash(pj.main)}/index.cjs`, + `${mount}/${stripDotSlash(pj.main)}`, + `${mount}/${stripDotSlash(pj.main)}.js`, + `${mount}/${stripDotSlash(pj.main)}.cjs`, + `${mount}/${stripDotSlash(pj.main)}.mjs`, + `${mount}/${stripDotSlash(pj.main)}.json`, + `${mount}/${stripDotSlash(pj.main)}/index.js`, + `${mount}/${stripDotSlash(pj.main)}/index.cjs`, ); } - return tryPaths(`${pkg}/index.js`, `${pkg}/index.cjs`); + return tryPaths(`${mount}/index.js`, `${mount}/index.cjs`); } - // Subpath: honor exact exports and general single-star patterns. - const exportsAny = pj.exports; - if ( - exportsAny !== undefined && - typeof exportsAny === "object" && - exportsAny !== null - ) { - const entries = exportsAny as Record; - // Exact match first. - const exactKey = `./${subpath}`; + return null; + }; + + /** + * Locate a package self-reference by walking from the calling module to its + * nearest manifest. Node enables self-reference only when that manifest has + * both the requested `name` and an `exports` field; the pack mount itself may + * be an npm alias whose key differs from `name`. + */ + const selfReferenceMount = ( + fromKey: string | null, + requestedPackage: string, + ): string | null => { + if (fromKey === null) return null; + const parts = dirname(fromKey).split("/").filter(Boolean); + for (let length = parts.length; length > 0; --length) { + const mount = parts.slice(0, length).join("/"); + const manifest = readPackageJson(mount); if ( - Object.prototype.hasOwnProperty.call(entries, exactKey) && - !exactKey.includes("*") && - !exactKey.endsWith("/") - ) - return resolveExportTarget(pkg, entries[exactKey]); - // Node resolves the most-specific wildcard, not insertion order. - const patterns = Object.entries(entries) - .map(([pattern, target]) => ({ - pattern, - replacement: exportPatternReplacement(pattern, exactKey), - target, - })) - .filter( - (entry): entry is typeof entry & { replacement: string } => - entry.replacement !== undefined, - ) - .sort((a, b) => compareExportPatternKeys(a.pattern, b.pattern)); - for (const { replacement, target } of patterns) { - return resolveExportTarget(pkg, target, replacement); + manifest?.name === requestedPackage && + Object.prototype.hasOwnProperty.call(manifest, "exports") + ) { + return mount; } } return null; @@ -173,6 +166,10 @@ export function createSandboxRequire( } // Bare specifier. Split into package name + subpath. const { pkg, subpath } = splitBareSpecifier(specifier); + const selfMount = selfReferenceMount(fromKey, pkg); + if (selfMount !== null) { + return resolvePackageEntry(selfMount, subpath); + } if (subpath === null) { return resolvePackageEntry(pkg, null); } @@ -208,7 +205,7 @@ export function createSandboxRequire( } const localRequire = (specifier: string): unknown => { const resolved = resolveSpecifier(specifier, key); - if (!resolved) { + if (!resolved || !has(resolved)) { throw new Error( `require("${specifier}") is not available in the playground sandbox (from ${key})`, ); @@ -257,7 +254,7 @@ export function createSandboxRequire( return (specifier: string): unknown => { const resolved = resolveSpecifier(specifier, null); - if (!resolved) { + if (!resolved || !has(resolved)) { throw new Error( `require("${specifier}") is not available in the playground sandbox`, ); @@ -302,48 +299,203 @@ function compareExportPatternKeys(left: string, right: string): number { } /** - * Reduce a `package.json` `exports` field to the value describing its ROOT - * (".") entry, ready for {@link conditionalExportTargets}. Node accepts three - * valid root shapes and they must all resolve consistently: - * - * - A bare string target — `"exports": "./index.cjs"`; - * - A subpath table keyed by "." — `{ ".": , "./sub": ... }`; - * - A bare condition map whose keys are all conditions, not subpaths — `{ - * "require": "./index.cjs", "default": "./index.cjs" }`. + * Resolve one package `exports` request without consulting pack existence. * - * Node forbids mixing subpath keys with condition keys, so the presence of any - * "."-prefixed key decides the interpretation: a subpath table exposes its root - * as `exports["."]`, while a condition map is itself the root target. Returns - * null when `exports` is absent, empty, or describes no root entry. + * Node chooses one target first and performs file loading afterward. Keeping + * those phases separate is essential: a valid first array target that names a + * missing file must fail at load time rather than fall through to a later + * target. */ -function rootExportTarget(exports: unknown): unknown { - if (typeof exports === "string") return exports; - if (!exports || typeof exports !== "object") return null; - const obj = exports as Record; - const keys = Object.keys(obj); - if (keys.length === 0) return null; - const hasSubpathKey = keys.some((k) => k === "." || k.startsWith("./")); - return hasSubpathKey ? obj["."] : obj; +function resolvePackageExports( + mount: string, + exportsField: unknown, + subpath: string | null, +): ExportTargetResolution { + let target: unknown; + if ( + exportsField !== null && + typeof exportsField === "object" && + !Array.isArray(exportsField) + ) { + const entries = exportsField as Record; + const kind = classifyExportsObject(mount, entries); + if (kind === "conditions") { + if (subpath !== null) return { type: "unresolved" }; + target = entries; + } else { + const request = subpath === null ? "." : `./${subpath}`; + if ( + Object.prototype.hasOwnProperty.call(entries, request) && + !request.includes("*") && + !request.endsWith("/") + ) { + target = entries[request]; + } else { + const patterns = Object.entries(entries) + .map(([pattern, candidate]) => ({ + pattern, + replacement: exportPatternReplacement(pattern, request), + target: candidate, + })) + .filter( + (entry): entry is typeof entry & { replacement: string } => + entry.replacement !== undefined, + ) + .sort((a, b) => compareExportPatternKeys(a.pattern, b.pattern)); + const selected = patterns[0]; + if (selected === undefined) return { type: "unresolved" }; + return resolvePackageTarget( + mount, + selected.target, + selected.replacement, + ); + } + } + } else { + if (subpath !== null) return { type: "unresolved" }; + target = exportsField; + } + return resolvePackageTarget(mount, target, ""); +} + +/** Classify and validate a top-level exports object. */ +function classifyExportsObject( + mount: string, + entries: Record, +): "subpaths" | "conditions" { + const keys = Object.keys(entries); + const subpathKeys = keys.filter((key) => key.startsWith(".")); + if (subpathKeys.length !== 0 && subpathKeys.length !== keys.length) { + throw new InvalidPackageConfigError( + `invalid package configuration for ${mount}: exports cannot mix subpath and condition keys`, + ); + } + if (subpathKeys.length !== 0) { + if (subpathKeys.some((key) => key !== "." && !key.startsWith("./"))) { + throw new InvalidPackageConfigError( + `invalid package configuration for ${mount}: invalid exports subpath key`, + ); + } + return "subpaths"; + } + validateConditionKeys(mount, keys); + return "conditions"; } -function conditionalExportTargets(value: unknown): string[] | null { - if (typeof value === "string") return [value]; - if (Array.isArray(value)) { - const candidates: string[] = []; - for (const target of value) { - const resolved = conditionalExportTargets(target); - if (resolved) candidates.push(...resolved); +/** Resolve a string, array, conditional object, or null exports target. */ +function resolvePackageTarget( + mount: string, + target: unknown, + replacement: string, +): ExportTargetResolution { + if (typeof target === "string") { + const substituted = target.split("*").join(replacement); + validatePackageTarget(mount, substituted); + return { + type: "resolved", + key: `${mount}/${stripDotSlash(substituted)}`, + }; + } + if (target === null) return { type: "blocked" }; + if (Array.isArray(target)) { + let lastInvalid: InvalidPackageTargetError | undefined; + for (const candidate of target) { + try { + const resolution = resolvePackageTarget(mount, candidate, replacement); + // Node skips null and unresolved array members. Once a valid target is + // resolved, file existence is a later phase and cannot trigger fallback. + if (resolution.type === "blocked" || resolution.type === "unresolved") { + continue; + } + return resolution; + } catch (error) { + if (!(error instanceof InvalidPackageTargetError)) throw error; + lastInvalid = error; + } } - return candidates.length === 0 ? null : candidates; + if (lastInvalid !== undefined) throw lastInvalid; + return { type: "unresolved" }; } - if (value && typeof value === "object") { - const obj = value as Record; - for (const [condition, target] of Object.entries(obj)) { + if (target !== null && typeof target === "object") { + const conditions = target as Record; + const keys = Object.keys(conditions); + if (keys.some((key) => key.startsWith("."))) { + throw new InvalidPackageConfigError( + `invalid package configuration for ${mount}: nested exports targets must use condition keys`, + ); + } + validateConditionKeys(mount, keys); + for (const [condition, candidate] of Object.entries(conditions)) { if (!ACTIVE_EXPORT_CONDITIONS.has(condition)) continue; - return conditionalExportTargets(target); + const resolution = resolvePackageTarget(mount, candidate, replacement); + if (resolution.type === "unresolved") continue; + return resolution; + } + return { type: "unresolved" }; + } + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: expected a relative ./ target`, + ); +} + +/** Reject integer-like condition keys, whose enumeration order is ambiguous. */ +function validateConditionKeys(mount: string, keys: string[]): void { + if (keys.some(isArrayIndexKey)) { + throw new InvalidPackageConfigError( + `invalid package configuration for ${mount}: numeric exports condition keys are not allowed`, + ); + } +} + +function isArrayIndexKey(key: string): boolean { + if (!/^(?:0|[1-9]\d*)$/.test(key)) return false; + const value = Number(key); + return value >= 0 && value < 0xffffffff && String(value) === key; +} + +/** + * Validate the substituted package target before turning it into a pack key. + * + * Targets are URL-like package-relative paths. Dot segments, `node_modules`, + * raw or encoded path separators, and their case/percent-encoded forms cannot + * be allowed to escape or reinterpret the mounted package boundary. + */ +function validatePackageTarget(mount: string, target: string): void { + if ( + !target.startsWith("./") || + target.includes("\\") || + /%(?:2f|5c)/i.test(target) + ) { + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); + } + for (const rawSegment of target.slice(2).split("/")) { + let decoded = rawSegment; + try { + for (let depth = 0; depth < 4; ++depth) { + const next = decodeURIComponent(decoded); + if (next === decoded) break; + decoded = next; + } + } catch { + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); + } + const normalized = decoded.toLowerCase(); + if ( + normalized === "." || + normalized === ".." || + normalized === "node_modules" || + decoded.includes("/") || + decoded.includes("\\") + ) { + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); } } - return null; } function stripDotSlash(p: string): string { diff --git a/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts b/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts new file mode 100644 index 000000000..5c6a9d968 --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies the sandbox follows Node's target-selection boundaries for exports. + * + * Selection and file loading are separate in Node. Arrays may skip invalid, + * null, and unresolved members, but a valid selected target does not fall + * through merely because its file is missing. Conditional objects continue + * after an active nested branch resolves to no target. + */ +export const test_create_sandbox_require_matches_node_export_target_decisions = + () => { + const require = createSandboxRequire( + { + "missing/package.json": JSON.stringify({ + exports: ["./missing.cjs", "./fallback.cjs"], + }), + "missing/fallback.cjs": "module.exports = 'wrong fallback';", + "invalid/package.json": JSON.stringify({ + exports: ["bare-target", "./fallback.cjs"], + }), + "invalid/fallback.cjs": "module.exports = 'valid fallback';", + "nested/package.json": JSON.stringify({ + exports: { + require: { browser: "./browser.cjs" }, + default: "./default.cjs", + }, + }), + "nested/default.cjs": "module.exports = 'outer default';", + "bare/package.json": JSON.stringify({ + exports: "bare-target", + main: "./main.cjs", + }), + "bare/main.cjs": "module.exports = 'private main';", + "mixed/package.json": JSON.stringify({ + exports: { ".": "./index.cjs", require: "./index.cjs" }, + }), + "mixed/index.cjs": "module.exports = true;", + "numeric/package.json": JSON.stringify({ + exports: { 0: "./zero.cjs", default: "./default.cjs" }, + }), + "numeric/default.cjs": "module.exports = true;", + }, + { console }, + ); + + assert.throws( + () => require("missing"), + /require\("missing"\) is not available/, + "a selected valid target must not fall through after file lookup fails", + ); + assert.equal(require("invalid"), "valid fallback"); + assert.equal(require("nested"), "outer default"); + assert.throws(() => require("bare"), /invalid package target/); + assert.throws(() => require("mixed"), /cannot mix subpath and condition/); + assert.throws(() => require("numeric"), /numeric exports condition/); + }; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts index 447db102c..771e9985c 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts @@ -35,9 +35,10 @@ export const test_create_sandbox_require_preserves_fallback_resolution = () => { }), "w/src/feat/x.js": "module.exports = { v: 'wild' };", "w/src/deep/x.js": "module.exports = { v: 'deep-wild' };", - // array fallback, null blocker, and inactive condition target + // array fallback from an invalid target, null blocker, and inactive + // condition target "a/package.json": JSON.stringify({ - exports: { "./entry": ["./missing.js", "./available.js"] }, + exports: { "./entry": ["invalid-target", "./available.js"] }, }), "a/available.js": "module.exports = { v: 'array' };", "blocked/package.json": JSON.stringify({ exports: { "./x": null } }), diff --git a/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts b/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts new file mode 100644 index 000000000..f14d67a10 --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies exports targets cannot escape or reinterpret their package mount. + * + * Validation runs after wildcard substitution and percent decoding. Request + * keys remain lookup keys, however: an exact key containing `..` is not itself + * a target escape when it maps to a safe file. + */ +export const test_create_sandbox_require_rejects_export_target_escapes = () => { + const require = createSandboxRequire( + { + "safe-key/package.json": JSON.stringify({ + exports: { "./a/../b": "./safe.cjs" }, + }), + "safe-key/safe.cjs": "module.exports = 42;", + "dot/package.json": JSON.stringify({ + exports: "./dist/../secret.cjs", + }), + "encoded-dot/package.json": JSON.stringify({ + exports: "./dist/%2e%2e/secret.cjs", + }), + "modules/package.json": JSON.stringify({ + exports: "./dist/NoDe_MoDuLeS/secret.cjs", + }), + "encoded-modules/package.json": JSON.stringify({ + exports: "./dist/%6eode_modules/secret.cjs", + }), + "encoded-slash/package.json": JSON.stringify({ + exports: "./dist/%2Fsecret.cjs", + }), + "pattern/package.json": JSON.stringify({ + exports: { "./*": "./dist/*" }, + }), + }, + { console }, + ); + + assert.equal(require("safe-key/a/../b"), 42); + for (const specifier of [ + "dot", + "encoded-dot", + "modules", + "encoded-modules", + "encoded-slash", + "pattern/escape/../secret", + ]) { + assert.throws( + () => require(specifier), + /invalid package target/, + specifier, + ); + } +}; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts b/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts new file mode 100644 index 000000000..0991e010d --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies package self-reference uses manifest identity rather than mount + * name. + * + * Npm aliases mount a package under the requested alias while preserving its + * real `package.json#name`. Node allows modules inside a package that declares + * exports to require that real name through the package's own exports map. + */ +export const test_create_sandbox_require_resolves_aliased_package_self_references = + () => { + const require = createSandboxRequire( + { + "alias/package.json": JSON.stringify({ + name: "actual", + exports: { + ".": "./index.cjs", + "./sub": "./sub.cjs", + }, + }), + "alias/index.cjs": "module.exports = { value: require('actual/sub') };", + "alias/sub.cjs": "module.exports = 42;", + "legacy-alias/package.json": JSON.stringify({ + name: "legacy-actual", + main: "./index.cjs", + }), + "legacy-alias/index.cjs": + "module.exports = require('legacy-actual/sub');", + "legacy-alias/sub.js": "module.exports = 'private';", + }, + { console }, + ); + + assert.deepEqual(require("alias"), { value: 42 }); + assert.throws( + () => require("legacy-alias"), + /require\("legacy-actual\/sub"\) is not available/, + "a package without exports must not gain self-reference semantics", + ); + }; diff --git a/website/src/content/docs/playground.mdx b/website/src/content/docs/playground.mdx index 729540832..feaefd8e9 100644 --- a/website/src/content/docs/playground.mdx +++ b/website/src/content/docs/playground.mdx @@ -14,7 +14,7 @@ Incremental additions retain each mounted package's registry target, exact versi Removing a direct import solves the complete remaining root set before the playground replaces its worker and compiler, Monaco, and Execute maps; aborted or failed replacement solves never publish a partial graph. -Node builtins, in either bare or `node:` form, are never requested from npm. The Execute sandbox treats a package `exports` map as its public boundary and evaluates the first applicable `require` or `default` condition in manifest order. It does not activate Node-only or ESM-only conditions. +Node builtins, in either bare or `node:` form, are never requested from npm. The Execute sandbox treats a package `exports` map as its public boundary and evaluates applicable `require` and `default` conditions in manifest order. It preserves Node's separation between selecting a target and loading its file, validates relative targets after wildcard substitution, and rejects invalid mixed or numeric condition maps. It does not activate Node-only or ESM-only conditions. A package mounted through an npm alias can self-reference its real manifest `name` when it declares exports; a package without exports keeps legacy direct-path resolution and does not gain self-reference behavior. The `typia` packages used by the default examples remain bundled because the wasm-side typia adapter must match the exact typia source version linked into the playground compiler. From 65c5f0e168955fb418905e2e4e7b18483d287b18 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:18:37 +0900 Subject: [PATCH 06/34] fix(unplugin): distinguish runtime cache sessions --- packages/unplugin/README.md | 4 +- packages/unplugin/src/bun.ts | 14 ++++- packages/unplugin/src/core/index.ts | 14 +++-- packages/unplugin/src/core/transform.ts | 12 ++++ ..._does_not_rehash_the_project_per_module.ts | 14 +++++ ...serve_validates_first_use_after_startup.ts | 12 ++++ .../test-unplugin/src/internal/adapter-bun.ts | 56 +++++++++++++++++ .../src/internal/adapter-vite.ts | 60 ++++++++++++++++++- 8 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts create mode 100644 tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts diff --git a/packages/unplugin/README.md b/packages/unplugin/README.md index c82e03244..25eef79a2 100644 --- a/packages/unplugin/README.md +++ b/packages/unplugin/README.md @@ -165,7 +165,7 @@ await Bun.build({ }); ``` -The adapter yields to the next Bun loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. `Bun.build` also clears the project generation through its `onStart` lifecycle on every build; the Bun runtime API has no corresponding hook, so its long-lived loader performs complete input validation on every generation hit. +The adapter yields to the next Bun loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. The Bun runtime API has no corresponding hook, so one `Bun.plugin()` setup is treated as one immutable module-loading session: restart the Bun process after changing source, tsconfig, or plugin inputs. ## Configuration @@ -312,7 +312,7 @@ The transform host reports the program's reference graph (the transform envelope Transform plugins may additionally report, per file, the source files they consulted (the envelope's `dependencies` field); the adapter registers those as watch files too, union semantics. A plugin that declares such a list [complete](https://ttsc.dev/docs/development/concepts/protocol#dependency-completeness) for a file narrows the registration instead: only its own list plus the tsconfig chain, so files the transform never consulted stop invalidating it. Files a plugin declares `volatile` (output depending on non-file inputs such as environment or time) bypass the adapter's transform cache and are marked uncacheable where the bundler exposes that control. -The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). One whole-project compile already contains every module's output. Adapters with a guaranteed `buildStart` boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Long-lived hosts without that boundary (Metro workers, the Turbopack loader, and the Bun runtime loader) validate the complete project and external-input snapshots on every generation hit. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk (`node_modules` declarations, monorepo sibling sources, files reached through symlinks or Windows junctions, and out-of-root `extends` ancestry). One whole-project compile already contains every module's output. Adapters with a guaranteed build boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Bun runtime setup defines one process-scoped loading session. Long-lived hosts without either boundary (Metro workers, the Turbopack loader, and Vite's development server, whose initial `buildStart` spans later HMR edits) validate the complete project and external-input snapshots on every generation hit. ## Sponsors diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index 379e0f5cb..1795a424d 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -70,7 +70,8 @@ function resolveBunOptions( * * `onLoad` drives the source transform. Bun's bundler also exposes `onStart`, * which is used when available to forward the shared plugin's build lifecycle - * and clear its per-build cache. The runtime plugin API omits that hook. + * and clear its per-build cache. The runtime plugin API omits that hook, so + * plugin setup itself starts its one process-scoped module-loading session. */ export interface BunLikeBuild { /** @@ -109,7 +110,9 @@ export interface BunLikeBuild { * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see * `bun-register`. Every result carries an explicit `loader` so Bun keeps * transpiling the emitted TypeScript at runtime; `sourceFilePattern` only - * matches TypeScript, so the loader is always `ts`/`tsx`. + * matches TypeScript, so the loader is always `ts`/`tsx`. A runtime plugin + * instance is one immutable load session, like Bun's own module cache; restart + * the process after changing compiler inputs. */ export default function bun(options?: TtscBunOptions): BunLikePlugin { return { @@ -123,6 +126,13 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { const getOptions = () => (resolved ??= resolveOptions(resolveBunOptions(options))); const cache = createTtscTransformCache(); + // Bun.plugin() has no onStart callback, but one setup invocation belongs + // to exactly one runtime process and module-loading session. Mark that + // session up front so first delivery of every emitted project module is + // constant-time instead of re-reading the whole project. Bun.build() + // immediately starts the same initial scope again through onStart and + // repeats it for subsequent builds. + beginTtscTransformBuild(cache); build.onStart?.(() => beginTtscTransformBuild(cache)); build.onLoad({ filter: sourceFilePattern }, async (args) => { if (!isTransformTarget(args.path)) { diff --git a/packages/unplugin/src/core/index.ts b/packages/unplugin/src/core/index.ts index 51eee4fd5..7ea499932 100644 --- a/packages/unplugin/src/core/index.ts +++ b/packages/unplugin/src/core/index.ts @@ -12,6 +12,7 @@ import { createTtscTransformCache, isDeclarationFile, isProjectWalkPath, + resetTtscTransformCache, stripQuery, transformTtsc, } from "./transform"; @@ -38,9 +39,9 @@ const virtualModulePattern = /\0/; * * The factory resolves raw options once, creates a per-build transform cache, * and captures Vite alias configuration via the `vite.configResolved` hook so - * that path aliases are forwarded to the generated tsconfig overlay. The cache - * is cleared on every `buildStart` to avoid stale results across watch-mode - * rebuilds. + * that path aliases are forwarded to the generated tsconfig overlay. Real build + * lifecycles use a per-build cache; Vite's development server keeps persistent + * validation because its one `buildStart` spans later HMR edits. */ const unpluginFactory: UnpluginFactory< TtscUnpluginOptions | undefined, @@ -82,7 +83,11 @@ const unpluginFactory: UnpluginFactory< }, buildStart() { - beginTtscTransformBuild(transformCache); + if (viteCommand === "serve") { + resetTtscTransformCache(transformCache); + } else { + beginTtscTransformBuild(transformCache); + } }, transformInclude(id) { @@ -148,6 +153,7 @@ export { collectProjectInputHashes, createTtscTransformCache, isProjectWalkPath, + resetTtscTransformCache, resolveOptions, transformTtsc, unplugin, diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index 844e4c23f..fe37fce0c 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -130,6 +130,18 @@ export function beginTtscTransformBuild(cache: TtscTransformCache): void { BUILD_SCOPED_TRANSFORM_CACHES.add(cache); } +/** + * Clear a cache and return it to persistent validation mode. + * + * This is distinct from {@link beginTtscTransformBuild}: hosts such as Vite's + * development server may invoke `buildStart` only once for a process that spans + * many edits, so that callback cannot authorize build-scoped shortcuts. + */ +export function resetTtscTransformCache(cache: TtscTransformCache): void { + cache.clear(); + BUILD_SCOPED_TRANSFORM_CACHES.delete(cache); +} + /** Cached case-insensitivity probes for existing macOS filesystem locations. */ const CASE_INSENSITIVE_FILESYSTEMS = new Map(); diff --git a/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts b/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts new file mode 100644 index 000000000..516fef206 --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts @@ -0,0 +1,14 @@ +import { assertBunRuntimeDoesNotRehashProjectPerModule } from "../../internal/adapter-bun"; + +/** + * Verifies a Bun runtime plugin setup is one cache lifecycle. + * + * The runtime builder has no `onStart`. Leaving that cache persistent makes + * each first module delivery hash every project input again, recreating the + * quadratic startup amplification reported in #969. The adapter must mark its + * process-scoped loading session during setup. + */ +export const test_bun_runtime_does_not_rehash_the_project_per_module = + async () => { + await assertBunRuntimeDoesNotRehashProjectPerModule(); + }; diff --git a/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts b/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts new file mode 100644 index 000000000..1dae12bc6 --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts @@ -0,0 +1,12 @@ +import { assertViteServeValidatesFirstUseAfterStartup } from "../../internal/adapter-vite"; + +/** + * Verifies Vite serve retains persistent cache validation after startup. + * + * Its `buildStart` callback is not replayed for every HMR edit. Treating that + * one callback as a complete build boundary lets a later first-use module + * escape validation against an earlier changed input. + */ +export const test_vite_serve_validates_first_use_after_startup = async () => { + await assertViteServeValidatesFirstUseAfterStartup(); +}; diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index 54710da50..f28295bdc 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -208,9 +208,65 @@ async function assertBunAdapterClearsCacheOnBuildStart(): Promise { ); } +/** + * Asserts Bun's runtime-only plugin shape does not re-read the whole project + * for every module's first delivery. + * + * `Bun.plugin()` exposes `onLoad` but no `onStart`. One setup invocation is one + * process-scoped module-loading session, so the adapter must start a build + * scope during setup rather than leave the shared cache in persistent mode. + */ +async function assertBunRuntimeDoesNotRehashProjectPerModule(): Promise { + const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); + const root = TestUnpluginProject.createProject({ + plugins: [ + { + transform: "./plugin.cjs", + name: "fixture", + operation: "echo-file", + path: "src/secondary.ts", + }, + ], + }); + const secondary = path.join(root, "src", "secondary.ts"); + fs.writeFileSync(secondary, "export const secondary = 1;\n", "utf8"); + const { loader } = await captureBunLoader(unpluginBun()); + + const first = await loader({ path: TestUnpluginProject.mainFile(root) }); + assert.ok(first); + + const originalReadFileSync = fs.readFileSync; + let projectReads = 0; + fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if ( + typeof file === "string" && + path.resolve(file).startsWith(`${path.resolve(root)}${path.sep}`) + ) { + ++projectReads; + } + return (originalReadFileSync as (...values: unknown[]) => unknown)( + file, + ...args, + ); + }) as typeof fs.readFileSync; + try { + const lazy = await loader({ path: secondary }); + assert.ok(lazy); + assert.match(lazy.contents, /secondary = 1/); + } finally { + fs.readFileSync = originalReadFileSync; + } + assert.equal( + projectReads, + 0, + "a first module delivery in one Bun runtime session must not walk and hash the project", + ); +} + export { assertBunAdapterClearsCacheOnBuildStart, assertBunAdapterFallsThroughWhenItDoesNotTransform, assertBunAdapterSurvivesPluginReportedDependencies, assertBunAdapterTransformsSource, + assertBunRuntimeDoesNotRehashProjectPerModule, }; diff --git a/tests/test-unplugin/src/internal/adapter-vite.ts b/tests/test-unplugin/src/internal/adapter-vite.ts index f9b4e95da..a25e33d58 100644 --- a/tests/test-unplugin/src/internal/adapter-vite.ts +++ b/tests/test-unplugin/src/internal/adapter-vite.ts @@ -1,7 +1,10 @@ import { TestUnpluginProject, TestUnpluginRuntime } from "@ttsc/testing"; +import assert from "node:assert/strict"; +import fs from "node:fs"; import path from "node:path"; -const { build: viteBuild } = TestUnpluginProject.REQUIRE_FROM_UNPLUGIN("vite"); +const { build: viteBuild, createServer: viteCreateServer } = + TestUnpluginProject.REQUIRE_FROM_UNPLUGIN("vite"); /** * Asserts that running a real Vite build with the unplugin vite adapter @@ -35,4 +38,57 @@ async function assertViteAdapterTransformsSource() { ); } -export { assertViteAdapterTransformsSource }; +/** + * Asserts Vite serve does not treat its one startup `buildStart` as an HMR + * generation boundary. + * + * Vite invokes that hook once when the development plugin container starts, not + * once per file edit. A cache marked build-scoped there could return an + * unserved module from the initial whole-project compilation after another + * project input changed. + */ +async function assertViteServeValidatesFirstUseAfterStartup(): Promise { + const unpluginVite = await TestUnpluginRuntime.loadUnpluginAdapter("vite"); + const root = TestUnpluginProject.createProject({ + plugins: [ + { + transform: "./plugin.cjs", + name: "fixture", + operation: "echo-file", + path: "src/lazy.ts", + }, + ], + }); + const lazy = path.join(root, "src", "lazy.ts"); + fs.writeFileSync(lazy, "export const lazy = 1;\n", "utf8"); + const server = await viteCreateServer({ + appType: "custom", + configFile: false, + logLevel: "silent", + optimizeDeps: { include: [], noDiscovery: true }, + plugins: [unpluginVite()], + root, + server: { hmr: false, middlewareMode: true, watch: null }, + }); + try { + const first = await server.transformRequest("/src/main.ts"); + assert.ok(first, "Vite serve must transform the entry module"); + fs.writeFileSync( + TestUnpluginProject.mainFile(root), + "export const broken = true;\n", + "utf8", + ); + await assert.rejects( + () => server.transformRequest("/src/lazy.ts"), + /expected export const value/, + "the first lazy request after an edit must validate the initial generation", + ); + } finally { + await server.close(); + } +} + +export { + assertViteAdapterTransformsSource, + assertViteServeValidatesFirstUseAfterStartup, +}; From d89ef2fb2ebbb1fdda17fefc77113c1c0c372649 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:30:40 +0900 Subject: [PATCH 07/34] fix(playground): complete package resolution boundaries --- .../src/sandbox/createSandboxRequire.ts | 125 +++++++++++------- ...equire_completes_legacy_index_fallbacks.ts | 36 +++++ ...x_require_normalizes_url_export_targets.ts | 42 ++++++ ...uire_preserves_array_blocking_semantics.ts | 59 +++++++++ ...ops_self_reference_at_the_nearest_scope.ts | 40 ++++++ 5 files changed, 254 insertions(+), 48 deletions(-) create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts create mode 100644 tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts diff --git a/packages/playground/src/sandbox/createSandboxRequire.ts b/packages/playground/src/sandbox/createSandboxRequire.ts index 46f067288..814a3d2d4 100644 --- a/packages/playground/src/sandbox/createSandboxRequire.ts +++ b/packages/playground/src/sandbox/createSandboxRequire.ts @@ -85,10 +85,7 @@ export function createSandboxRequire( const packageDeclaresExports = (mount: string): boolean => { const manifest = readPackageJson(mount); - return ( - manifest !== null && - Object.prototype.hasOwnProperty.call(manifest, "exports") - ); + return manifest?.exports !== null && manifest?.exports !== undefined; }; // Read package.json from pack and resolve via main/exports. @@ -98,23 +95,30 @@ export function createSandboxRequire( ): string | null => { const pj = readPackageJson(mount); if (pj === null) return null; - if (Object.prototype.hasOwnProperty.call(pj, "exports")) { + if (pj.exports !== null && pj.exports !== undefined) { const resolution = resolvePackageExports(mount, pj.exports, subpath); return resolution.type === "resolved" ? resolution.key : null; } if (subpath === null) { if (typeof pj.main === "string") { - return tryPaths( - `${mount}/${stripDotSlash(pj.main)}`, - `${mount}/${stripDotSlash(pj.main)}.js`, - `${mount}/${stripDotSlash(pj.main)}.cjs`, - `${mount}/${stripDotSlash(pj.main)}.mjs`, - `${mount}/${stripDotSlash(pj.main)}.json`, - `${mount}/${stripDotSlash(pj.main)}/index.js`, - `${mount}/${stripDotSlash(pj.main)}/index.cjs`, + const main = stripDotSlash(pj.main); + const resolvedMain = tryPaths( + `${mount}/${main}`, + `${mount}/${main}.js`, + `${mount}/${main}.cjs`, + `${mount}/${main}.mjs`, + `${mount}/${main}.json`, + `${mount}/${main}/index.js`, + `${mount}/${main}/index.cjs`, + `${mount}/${main}/index.json`, ); + if (resolvedMain !== null) return resolvedMain; } - return tryPaths(`${mount}/index.js`, `${mount}/index.cjs`); + return tryPaths( + `${mount}/index.js`, + `${mount}/index.cjs`, + `${mount}/index.json`, + ); } return null; }; @@ -134,12 +138,12 @@ export function createSandboxRequire( for (let length = parts.length; length > 0; --length) { const mount = parts.slice(0, length).join("/"); const manifest = readPackageJson(mount); - if ( - manifest?.name === requestedPackage && - Object.prototype.hasOwnProperty.call(manifest, "exports") - ) { - return mount; - } + if (manifest === null) continue; + return manifest.name === requestedPackage && + manifest.exports !== null && + manifest.exports !== undefined + ? mount + : null; } return null; }; @@ -185,6 +189,7 @@ export function createSandboxRequire( `${pkg}/${subpath}.json`, `${pkg}/${subpath}/index.js`, `${pkg}/${subpath}/index.cjs`, + `${pkg}/${subpath}/index.json`, ); if (direct) return direct; // Fall back to package.json exports map. @@ -390,40 +395,39 @@ function resolvePackageTarget( ): ExportTargetResolution { if (typeof target === "string") { const substituted = target.split("*").join(replacement); - validatePackageTarget(mount, substituted); return { type: "resolved", - key: `${mount}/${stripDotSlash(substituted)}`, + key: resolvePackageTargetKey(mount, substituted), }; } if (target === null) return { type: "blocked" }; if (Array.isArray(target)) { + if (target.length === 0) return { type: "blocked" }; let lastInvalid: InvalidPackageTargetError | undefined; + let lastBlocked = false; for (const candidate of target) { try { const resolution = resolvePackageTarget(mount, candidate, replacement); - // Node skips null and unresolved array members. Once a valid target is - // resolved, file existence is a later phase and cannot trigger fallback. - if (resolution.type === "blocked" || resolution.type === "unresolved") { - continue; + if (resolution.type === "blocked") { + lastBlocked = true; + lastInvalid = undefined; + } else if (resolution.type === "resolved") { + // File existence is a later phase and cannot trigger fallback. + return resolution; } - return resolution; } catch (error) { if (!(error instanceof InvalidPackageTargetError)) throw error; lastInvalid = error; + lastBlocked = false; } } if (lastInvalid !== undefined) throw lastInvalid; + if (lastBlocked) return { type: "blocked" }; return { type: "unresolved" }; } if (target !== null && typeof target === "object") { const conditions = target as Record; const keys = Object.keys(conditions); - if (keys.some((key) => key.startsWith("."))) { - throw new InvalidPackageConfigError( - `invalid package configuration for ${mount}: nested exports targets must use condition keys`, - ); - } validateConditionKeys(mount, keys); for (const [condition, candidate] of Object.entries(conditions)) { if (!ACTIVE_EXPORT_CONDITIONS.has(condition)) continue; @@ -454,30 +458,29 @@ function isArrayIndexKey(key: string): boolean { } /** - * Validate the substituted package target before turning it into a pack key. + * Resolve one URL-like package target into a normalized pack key. * * Targets are URL-like package-relative paths. Dot segments, `node_modules`, - * raw or encoded path separators, and their case/percent-encoded forms cannot - * be allowed to escape or reinterpret the mounted package boundary. + * and encoded path separators cannot escape or reinterpret the mount. URL + * query/hash components do not participate in filesystem lookup, and pathname + * percent escapes are decoded exactly once. */ -function validatePackageTarget(mount: string, target: string): void { - if ( - !target.startsWith("./") || - target.includes("\\") || - /%(?:2f|5c)/i.test(target) - ) { +function resolvePackageTargetKey(mount: string, target: string): string { + if (!target.startsWith("./")) { + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); + } + const pathnameTarget = target.split(/[?#]/, 1)[0]!; + if (/%(?:2f|5c)/i.test(pathnameTarget)) { throw new InvalidPackageTargetError( `invalid package target for ${mount}: ${JSON.stringify(target)}`, ); } - for (const rawSegment of target.slice(2).split("/")) { - let decoded = rawSegment; + for (const rawSegment of pathnameTarget.slice(2).split(/[\\/]/)) { + let decoded: string; try { - for (let depth = 0; depth < 4; ++depth) { - const next = decodeURIComponent(decoded); - if (next === decoded) break; - decoded = next; - } + decoded = decodeURIComponent(rawSegment); } catch { throw new InvalidPackageTargetError( `invalid package target for ${mount}: ${JSON.stringify(target)}`, @@ -496,6 +499,32 @@ function validatePackageTarget(mount: string, target: string): void { ); } } + try { + const base = new URL(`https://sandbox.invalid/${mount}/`); + const resolved = new URL(target, base); + const basePath = decodeURIComponent(base.pathname); + const resolvedPath = decodeURIComponent(resolved.pathname).replace( + /\/+/g, + "/", + ); + if (!resolvedPath.startsWith(basePath)) { + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); + } + const relative = resolvedPath.slice(basePath.length); + if (relative.length === 0) { + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); + } + return `${mount}/${relative}`; + } catch (error) { + if (error instanceof InvalidPackageTargetError) throw error; + throw new InvalidPackageTargetError( + `invalid package target for ${mount}: ${JSON.stringify(target)}`, + ); + } } function stripDotSlash(p: string): string { diff --git a/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts b/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts new file mode 100644 index 000000000..f639b98ab --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies packages without exports retain Node-style legacy fallbacks. + * + * 1. Provide a missing main with a root index, a JSON-only root index, and a main + * directory containing only `index.json`. + * 2. Assert failed main lookup continues to root fallback and JSON index files are + * available at both directory levels. + */ +export const test_create_sandbox_require_completes_legacy_index_fallbacks = + () => { + const require = createSandboxRequire( + { + "missing-main/package.json": JSON.stringify({ + main: "./missing.cjs", + }), + "missing-main/index.js": "module.exports = 'root-index';", + "json-root/package.json": JSON.stringify({}), + "json-root/index.json": JSON.stringify({ value: "json-root" }), + "json-directory/package.json": JSON.stringify({ main: "./lib" }), + "json-directory/lib/index.json": JSON.stringify({ + value: "json-directory", + }), + }, + { console }, + ); + + assert.equal(require("missing-main"), "root-index"); + assert.deepEqual(require("json-root"), { value: "json-root" }); + assert.deepEqual(require("json-directory"), { + value: "json-directory", + }); + }; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts b/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts new file mode 100644 index 000000000..ca3ca8f49 --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies exports targets use URL pathname semantics before pack lookup. + * + * 1. Declare encoded characters, double encoding, query/hash suffixes, + * backslashes, and repeated slashes that Node resolves inside the package. + * 2. Assert each resolves to the normalized pack key without weakening the + * existing encoded-separator escape rejection. + */ +export const test_create_sandbox_require_normalizes_url_export_targets = () => { + const require = createSandboxRequire( + { + "urls/package.json": JSON.stringify({ + exports: { + "./encoded": "./%66oo.cjs", + "./double": "./%252e%252e/file.cjs", + "./query": "./file.cjs?x", + "./hash": "./file.cjs#x", + "./backslash": "./dist\\file.cjs", + "./slashes": "./dist//file.cjs", + "./escape": "./%2foutside.cjs", + }, + }), + "urls/foo.cjs": "module.exports = 'encoded';", + "urls/%2e%2e/file.cjs": "module.exports = 'double';", + "urls/file.cjs": "module.exports = 'suffix';", + "urls/dist/file.cjs": "module.exports = 'normalized';", + }, + { console }, + ); + + assert.equal(require("urls/encoded"), "encoded"); + assert.equal(require("urls/double"), "double"); + assert.equal(require("urls/query"), "suffix"); + assert.equal(require("urls/hash"), "suffix"); + assert.equal(require("urls/backslash"), "normalized"); + assert.equal(require("urls/slashes"), "normalized"); + assert.throws(() => require("urls/escape"), /invalid package target/); +}; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts new file mode 100644 index 000000000..2ad73b70c --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies exports arrays preserve Node's final null/invalid decision. + * + * 1. Put empty, null-only, invalid-then-null, and null-then-valid arrays under the + * active `require` condition with an outer default. + * 2. Assert blocked arrays cannot reach the default while the later valid target + * remains selectable. + */ +export const test_create_sandbox_require_preserves_array_blocking_semantics = + () => { + const require = createSandboxRequire( + { + "empty/package.json": JSON.stringify({ + exports: { require: [], default: "./default.cjs" }, + }), + "empty/default.cjs": "module.exports = 'wrong';", + "null/package.json": JSON.stringify({ + exports: { require: [null], default: "./default.cjs" }, + }), + "null/default.cjs": "module.exports = 'wrong';", + "invalid-null/package.json": JSON.stringify({ + exports: { + require: ["invalid", null], + default: "./default.cjs", + }, + }), + "invalid-null/default.cjs": "module.exports = 'wrong';", + "null-valid/package.json": JSON.stringify({ + exports: { + require: [null, "./valid.cjs"], + default: "./default.cjs", + }, + }), + "null-valid/valid.cjs": "module.exports = 'valid';", + "dot-condition/package.json": JSON.stringify({ + exports: { + require: { ".": "./wrong.cjs" }, + default: "./default.cjs", + }, + }), + "dot-condition/default.cjs": "module.exports = 'default';", + }, + { console }, + ); + + for (const name of ["empty", "null", "invalid-null"]) { + assert.throws(() => require(name), /is not available/); + } + assert.equal(require("null-valid"), "valid"); + assert.equal( + require("dot-condition"), + "default", + "an inactive nested dot key leaves the branch unresolved", + ); + }; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts b/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts new file mode 100644 index 000000000..9c86e13a5 --- /dev/null +++ b/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; + +import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; + +/** + * Verifies alias self-reference cannot cross a nearer package scope. + * + * 1. Nest a differently named package inside an aliased package that declares + * exports, and require the outer real name from the nested entry. + * 2. Assert the nearest manifest blocks the outer self-reference; also verify + * `exports: null` retains legacy main without gaining self-reference. + */ +export const test_create_sandbox_require_stops_self_reference_at_the_nearest_scope = + () => { + const require = createSandboxRequire( + { + "alias/package.json": JSON.stringify({ + name: "actual", + exports: { + ".": "./index.cjs", + "./sub": "./sub.cjs", + }, + }), + "alias/index.cjs": "module.exports = require('./nested/entry.cjs');", + "alias/sub.cjs": "module.exports = 42;", + "alias/nested/package.json": JSON.stringify({ name: "nested" }), + "alias/nested/entry.cjs": "module.exports = require('actual/sub');", + "nullable/package.json": JSON.stringify({ + exports: null, + main: "./main.cjs", + name: "nullable-actual", + }), + "nullable/main.cjs": "module.exports = 'legacy-main';", + }, + { console }, + ); + + assert.throws(() => require("alias"), /actual\/sub.*not available/); + assert.equal(require("nullable"), "legacy-main"); + }; From 24b064e19b5651a06181786329f28deaf404dd82 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:31:02 +0900 Subject: [PATCH 08/34] test(unplugin): pin runtime cache contracts --- packages/unplugin/src/core/transform.ts | 7 ++-- ..._does_not_rehash_the_project_per_module.ts | 4 ++ ...serve_validates_first_use_after_startup.ts | 4 ++ .../test-unplugin/src/internal/adapter-bun.ts | 40 ++++++------------- website/src/content/docs/setup/unplugin.mdx | 2 +- 5 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index fe37fce0c..31d9c3add 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -58,7 +58,7 @@ export interface TtscCachedProjectTransform { * directories (`node_modules` declarations, monorepo sibling sources, * out-of-root tsconfig `extends` ancestry), yet the host-owned reference * graph proves they are transform inputs. Long-lived hosts that never clear - * the cache between builds (Metro workers, the Turbopack loader, Bun) would + * the cache between builds (Metro workers and the Turbopack loader) would * otherwise replay a project transform computed against a stale out-of-walk * input for the whole process lifetime; per-build hosts clear the cache on * `buildStart` and never replay across edits. @@ -122,8 +122,9 @@ export function createTtscTransformCache(): TtscTransformCache { * Start a host build, clearing its prior generation and enabling constant-time * first delivery for modules compiled during this build. * - * Hosts without a guaranteed build-start callback must not call this function; - * their persistent caches validate the complete input snapshot on every hit. + * Hosts without a guaranteed build-start callback use persistent validation + * unless they have another immutable lifecycle. Bun runtime setup, for example, + * defines one process-scoped module-loading session. */ export function beginTtscTransformBuild(cache: TtscTransformCache): void { cache.clear(); diff --git a/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts b/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts index 516fef206..b6412cf92 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_runtime_does_not_rehash_the_project_per_module.ts @@ -7,6 +7,10 @@ import { assertBunRuntimeDoesNotRehashProjectPerModule } from "../../internal/ad * each first module delivery hash every project input again, recreating the * quadratic startup amplification reported in #969. The adapter must mark its * process-scoped loading session during setup. + * + * 1. Compile an entry whose generation also emits an unrequested lazy module. + * 2. Change another project input inside the same runtime session. + * 3. Assert the first lazy delivery still uses the session generation. */ export const test_bun_runtime_does_not_rehash_the_project_per_module = async () => { diff --git a/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts b/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts index 1dae12bc6..dfcfe4a2c 100644 --- a/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts +++ b/tests/test-unplugin/src/features/adapters/test_vite_serve_validates_first_use_after_startup.ts @@ -6,6 +6,10 @@ import { assertViteServeValidatesFirstUseAfterStartup } from "../../internal/ada * Its `buildStart` callback is not replayed for every HMR edit. Treating that * one callback as a complete build boundary lets a later first-use module * escape validation against an earlier changed input. + * + * 1. Start Vite serve and compile an entry that also emits a lazy module. + * 2. Corrupt the entry after startup, then request the lazy module first. + * 3. Assert persistent validation rejects the stale initial generation. */ export const test_vite_serve_validates_first_use_after_startup = async () => { await assertViteServeValidatesFirstUseAfterStartup(); diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index f28295bdc..6f6fee788 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -209,8 +209,8 @@ async function assertBunAdapterClearsCacheOnBuildStart(): Promise { } /** - * Asserts Bun's runtime-only plugin shape does not re-read the whole project - * for every module's first delivery. + * Asserts Bun's runtime-only plugin shape keeps one immutable generation for + * the process-scoped module-loading session. * * `Bun.plugin()` exposes `onLoad` but no `onStart`. One setup invocation is one * process-scoped module-loading session, so the adapter must start a build @@ -235,32 +235,18 @@ async function assertBunRuntimeDoesNotRehashProjectPerModule(): Promise { const first = await loader({ path: TestUnpluginProject.mainFile(root) }); assert.ok(first); - const originalReadFileSync = fs.readFileSync; - let projectReads = 0; - fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if ( - typeof file === "string" && - path.resolve(file).startsWith(`${path.resolve(root)}${path.sep}`) - ) { - ++projectReads; - } - return (originalReadFileSync as (...values: unknown[]) => unknown)( - file, - ...args, - ); - }) as typeof fs.readFileSync; - try { - const lazy = await loader({ path: secondary }); - assert.ok(lazy); - assert.match(lazy.contents, /secondary = 1/); - } finally { - fs.readFileSync = originalReadFileSync; - } - assert.equal( - projectReads, - 0, - "a first module delivery in one Bun runtime session must not walk and hash the project", + // A persistent-validation cache would observe this unrelated input change + // and reject the next lazy module. Runtime setup deliberately fences one + // immutable module-loading session, so the already compiled lazy output + // remains deliverable without a project-wide validation pass. + fs.writeFileSync( + TestUnpluginProject.mainFile(root), + "export const broken = true;\n", + "utf8", ); + const lazy = await loader({ path: secondary }); + assert.ok(lazy); + assert.match(lazy.contents, /secondary = 1/); } export { diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index 413f516fd..6765d4cb4 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -205,7 +205,7 @@ This works for every adapter automatically when the transform host emits the env A plugin that knows exactly which declarations it consulted can go the other way and declare its reported list [complete](/docs/development/concepts/protocol#dependency-completeness) for a file. The adapter then registers only that list plus the tsconfig chain for it, so a change to a file the transform never read stops re-running the loader. Nothing is required of you: the field is per file and opt-in per plugin, and a plugin that never declares it keeps the sound default above. -The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output. Adapters with a guaranteed `buildStart` boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Long-lived hosts without that boundary (Metro workers, the Turbopack loader, and the Bun runtime loader) validate the complete project and external-input snapshots on every generation hit. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output. Adapters with a guaranteed build boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Bun runtime setup defines one process-scoped module-loading session; restart the Bun process after changing source, tsconfig, or plugin inputs. Long-lived hosts without either boundary (Metro workers, the Turbopack loader, and Vite's development server, whose initial `buildStart` spans later HMR edits) validate the complete project and external-input snapshots on every generation hit. This section is about the **transform** cache (which files a rebuild depends on), not the **source-plugin binary** cache (the compiled Go plugin ttsc builds on first use). They are independent: persisting a bundler cache such as Next.js `.next/cache` does not carry the plugin binary across builds, and a fresh CI or container build recompiles the plugin (the `ttsc: building source plugin ...` line) even with a warm bundler cache. To keep that first-build compile from recurring on every build, persist the binary cache as described in [Compile → Plugin cache](/docs/ttsc/compile#plugin-cache). In a monorepo, run `ttsc prepare` from the same project directory the bundler builds, so the warmed binary matches the cache key the bundler build looks up. From 3afc77ae7a3c4219d4f03f34936d550b7134aa29 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:31:58 +0900 Subject: [PATCH 09/34] fix(playground): secure npm archive installs --- packages/playground/README.md | 6 + .../src/npm/installPlaygroundDependencies.ts | 37 ++- .../src/npm/internal/npmRegistry.ts | 278 ++++++++++++++++-- .../IPlaygroundDependencyInstallOptions.ts | 13 + ...rchive_pipeline_honors_abort_boundaries.ts | 47 +++ ...bounds_compressed_and_expanded_archives.ts | 43 +++ ...does_not_skip_optional_archive_failures.ts | 59 ++++ ...sum_and_allows_legacy_unsigned_metadata.ts | 38 +++ ...verifies_the_strongest_integrity_digest.ts | 66 +++++ ..._rejects_paths_outside_the_package_root.ts | 58 ++++ ...jects_truncated_and_overflowing_entries.ts | 47 +++ .../src/internal/npmFixture.ts | 70 +++++ website/src/content/docs/playground.mdx | 4 + 13 files changed, 736 insertions(+), 30 deletions(-) create mode 100644 tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts create mode 100644 tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts create mode 100644 tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts create mode 100644 tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts create mode 100644 tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts create mode 100644 tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts create mode 100644 tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts create mode 100644 tests/test-playground/src/internal/npmFixture.ts diff --git a/packages/playground/README.md b/packages/playground/README.md index 4d3bea5d5..5e4463f8c 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -137,6 +137,8 @@ import { const names = collectExternalPackageNames(userSource); const installed = await installPlaygroundDependencies(names, { + maxTarballBytes: 16 * 1024 * 1024, + maxUnpackedBytes: 64 * 1024 * 1024, onProgress: (p) => console.log(p.phase, p.packageName), }); // mount installed.compilerFiles into the wasm MemFS via service.installDependencies @@ -148,6 +150,10 @@ When a direct source import is removed, solve the complete current root list wit `PlaygroundShell` wires this automatically on every keystroke, debounced 900 ms, with an abort signal on source change. +The installer verifies the strongest supported `dist.integrity` digest before decompression, or `dist.shasum` when integrity is absent. Historical and private registry metadata with neither field remains compatible but unauthenticated. Each tarball is streamed through independent compressed and expanded byte limits (16 MiB and 64 MiB by default), and every file must remain below the archive's canonical `package/` root. Override the limits with `maxTarballBytes` and `maxUnpackedBytes` when a known package requires more. + +Integrity proves that archive bytes match registry metadata; it does not establish that package code is safe. `createSandboxRequire` is a CommonJS resolver and evaluator, not an origin, process, or capability sandbox. The byte limits protect installation availability, not runtime behavior such as loops, timers, network access, or globals exposed by the host. Sites accepting untrusted code or packages must provide an isolated `executeBundle` policy appropriate to their environment. + The Execute lane's CommonJS resolver treats `package.json#exports` as the package boundary. It selects `require` and `default` conditions in manifest order, preserves Node's distinction between target selection and file loading, validates relative targets after wildcard substitution, and rejects mixed or numeric condition maps. A package mounted through an npm alias may still self-reference its real manifest `name` when it declares exports; packages without exports keep legacy direct-path resolution and do not gain self-reference behavior. ## Tailwind setup diff --git a/packages/playground/src/npm/installPlaygroundDependencies.ts b/packages/playground/src/npm/installPlaygroundDependencies.ts index 3e6fa03a8..9d3b62fea 100644 --- a/packages/playground/src/npm/installPlaygroundDependencies.ts +++ b/packages/playground/src/npm/installPlaygroundDependencies.ts @@ -15,9 +15,12 @@ import { throwIfAborted, toTypesPackageName, unpackNpmTarball, + verifyTarball, } from "./internal/npmRegistry"; const DEFAULT_MAX_PACKAGES = 48; +const DEFAULT_MAX_TARBALL_BYTES = 16 * 1024 * 1024; +const DEFAULT_MAX_UNPACKED_BYTES = 64 * 1024 * 1024; /** * Resolve, download, and unpack a set of npm packages directly inside the @@ -72,6 +75,9 @@ export async function installPlaygroundDependencies( }); } const maxPackages = options.maxPackages ?? DEFAULT_MAX_PACKAGES; + const maxTarballBytes = options.maxTarballBytes ?? DEFAULT_MAX_TARBALL_BYTES; + const maxUnpackedBytes = + options.maxUnpackedBytes ?? DEFAULT_MAX_UNPACKED_BYTES; const queue: IQueueItem[] = []; const queued = new Map(); const done = new Map(); @@ -250,19 +256,30 @@ export async function installPlaygroundDependencies( const versionMetadata = metadata.versions[version]; const tarball = versionMetadata?.dist?.tarball; if (!versionMetadata || !tarball) { - if (item.optional) { - done.set(item.name, ""); - report("skip", item, `Skipped optional ${item.name}`); - continue; - } throw new Error(`No tarball found for ${item.name}@${version}.`); } - report("download", item, `Downloading ${item.name}@${version}`, version); - const tgz = await downloadTarball(fetchImpl, tarball, options.signal); - throwIfAborted(options.signal); - report("extract", item, `Extracting ${item.name}@${version}`, version); - const unpacked = await unpackNpmTarball(tgz, options.signal); + let unpacked: Awaited>; + try { + report("download", item, `Downloading ${item.name}@${version}`, version); + const tgz = await downloadTarball( + fetchImpl, + tarball, + options.signal, + maxTarballBytes, + ); + throwIfAborted(options.signal); + await verifyTarball(tgz, versionMetadata.dist ?? {}, options.signal); + throwIfAborted(options.signal); + report("extract", item, `Extracting ${item.name}@${version}`, version); + unpacked = await unpackNpmTarball(tgz, options.signal, maxUnpackedBytes); + } catch (error) { + if (options.signal?.aborted) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to install ${item.name}@${version}: ${message}`, { + cause: error, + }); + } throwIfAborted(options.signal); const packageJson = { ...versionMetadata, diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 5e41581b4..9b4211c66 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -22,6 +22,8 @@ export interface INpmVersionMetadata { peerDependencies?: Record; peerDependenciesMeta?: Record; dist?: { + integrity?: string; + shasum?: string; tarball?: string; }; } @@ -135,20 +137,73 @@ export async function downloadTarball( fetchImpl: FetchLike, tarball: string, signal: AbortSignal | undefined, + maxBytes = 16 * 1024 * 1024, ): Promise { const response = await fetchImpl(tarball, { signal }); if (!response.ok) { throw new Error(`tarball download failed with HTTP ${response.status}.`); } - return response.arrayBuffer(); + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null) { + const parsed = Number(declaredLength); + if (Number.isFinite(parsed) && parsed >= 0 && parsed > maxBytes) { + throw new Error( + `tarball exceeds the ${formatByteLimit(maxBytes)} compressed byte limit.`, + ); + } + } + return collectBoundedStream( + response.body, + maxBytes, + "compressed", + signal, + () => response.arrayBuffer(), + ); +} + +/** Verify registry authentication metadata against the compressed bytes. */ +export async function verifyTarball( + tgz: ArrayBuffer, + dist: { integrity?: string; shasum?: string }, + signal: AbortSignal | undefined, +): Promise { + throwIfAborted(signal); + if (dist.integrity !== undefined) { + const candidates = parseIntegrity(dist.integrity); + const strength = Math.max(...candidates.map(({ rank }) => rank)); + const strongest = candidates.filter( + (candidate) => candidate.rank === strength, + ); + const actual = new Uint8Array( + await crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz), + ); + throwIfAborted(signal); + if (!strongest.some(({ digest }) => equalBytes(actual, digest))) { + throw new Error( + `tarball integrity mismatch (${strongest[0]!.algorithm}).`, + ); + } + return; + } + if (dist.shasum !== undefined) { + if (!/^[a-fA-F0-9]{40}$/.test(dist.shasum)) { + throw new Error("tarball shasum is not a valid SHA-1 digest."); + } + const actual = new Uint8Array(await crypto.subtle.digest("SHA-1", tgz)); + throwIfAborted(signal); + if (!equalBytes(actual, decodeHex(dist.shasum))) { + throw new Error("tarball shasum mismatch (sha1)."); + } + } } export async function unpackNpmTarball( tgz: ArrayBuffer, signal: AbortSignal | undefined, + maxBytes = 64 * 1024 * 1024, ): Promise { throwIfAborted(signal); - const tar = await gunzip(tgz); + const tar = await gunzip(tgz, maxBytes, signal); throwIfAborted(signal); const decoder = new TextDecoder(); const files: Record = {}; @@ -156,24 +211,40 @@ export async function unpackNpmTarball( let offset = 0; let longPath: string | null = null; let paxPath: string | null = null; + let terminated = false; - while (offset + 512 <= tar.length) { + while (offset < tar.length) { throwIfAborted(signal); + if (offset + 512 > tar.length) { + throw new Error("Truncated tar header."); + } const header = tar.subarray(offset, offset + 512); offset += 512; - if (header.every((value) => value === 0)) break; + if (header.every((value) => value === 0)) { + terminated = true; + break; + } const type = String.fromCharCode(header[156] ?? 0); const size = parseOctal(header.subarray(124, 136)); + if (size > tar.length - offset) { + throw new Error("Tar entry body extends beyond the archive."); + } const body = tar.subarray(offset, offset + size); - offset += Math.ceil(size / 512) * 512; + const paddedSize = Math.ceil(size / 512) * 512; + if (!Number.isSafeInteger(paddedSize) || paddedSize > tar.length - offset) { + throw new Error("Tar entry padding extends beyond the archive."); + } + offset += paddedSize; if (type === "L") { longPath = trimNull(decoder.decode(body)); + confineTarPath(longPath); continue; } if (type === "x") { paxPath = parsePaxPath(body); + if (paxPath !== null) confineTarPath(paxPath); continue; } if (type !== "0" && type !== "\0") { @@ -182,12 +253,11 @@ export async function unpackNpmTarball( continue; } - const rawPath = - paxPath ?? longPath ?? readTarString(header.subarray(0, 100), decoder); + const rawPath = paxPath ?? longPath ?? readTarHeaderPath(header, decoder); longPath = null; paxPath = null; - const rel = stripTarRoot(rawPath); - if (!rel || !TEXT_FILE_REGEXP.test(rel)) continue; + const rel = confineTarPath(rawPath); + if (!TEXT_FILE_REGEXP.test(rel)) continue; const text = decoder.decode(body); files[rel] = text; @@ -199,11 +269,16 @@ export async function unpackNpmTarball( } } } + if (!terminated) throw new Error("Tar archive has no end marker."); return { files, packageJson }; } -async function gunzip(input: ArrayBuffer): Promise { +async function gunzip( + input: ArrayBuffer, + maxBytes: number, + signal: AbortSignal | undefined, +): Promise { if (!("DecompressionStream" in globalThis)) { throw new Error( "This browser cannot unpack npm tgz files because DecompressionStream is unavailable.", @@ -212,7 +287,11 @@ async function gunzip(input: ArrayBuffer): Promise { const stream = new Blob([input]) .stream() .pipeThrough(new DecompressionStream("gzip")); - return new Uint8Array(await new Response(stream).arrayBuffer()); + return new Uint8Array( + await collectBoundedStream(stream, maxBytes, "expanded", signal, async () => + new Response(stream).arrayBuffer(), + ), + ); } export interface IMountedFiles { @@ -307,12 +386,18 @@ function readTarString(bytes: Uint8Array, decoder: TextDecoder): string { function trimNull(text: string): string { const index = text.indexOf("\0"); - return (index < 0 ? text : text.slice(0, index)).trim(); + return index < 0 ? text : text.slice(0, index); } function parseOctal(bytes: Uint8Array): number { const text = trimNull(new TextDecoder().decode(bytes)).trim(); - return text ? Number.parseInt(text, 8) : 0; + if (text.length === 0) return 0; + if (!/^[0-7]+$/.test(text)) throw new Error("Invalid tar entry size."); + const value = Number.parseInt(text, 8); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("Tar entry size is outside the safe integer range."); + } + return value; } function parsePaxPath(bytes: Uint8Array): string | null { @@ -343,11 +428,164 @@ function parsePaxPath(bytes: Uint8Array): string | null { return path; } -function stripTarRoot(path: string): string { - const normalized = path.replace(/\\/g, "/").replace(/^\/+/, ""); - if (!normalized) return ""; - if (normalized.startsWith("package/")) - return normalized.slice("package/".length); - const slash = normalized.indexOf("/"); - return slash < 0 ? normalized : normalized.slice(slash + 1); +function readTarHeaderPath(header: Uint8Array, decoder: TextDecoder): string { + const name = readTarString(header.subarray(0, 100), decoder); + const prefix = readTarString(header.subarray(345, 500), decoder); + return prefix ? `${prefix}/${name}` : name; +} + +/** Require a regular npm archive path below its canonical `package/` root. */ +function confineTarPath(rawPath: string): string { + if ( + rawPath.length === 0 || + rawPath.includes("\\") || + rawPath.startsWith("/") || + /^[a-zA-Z]:/.test(rawPath) + ) { + throw new Error(`Invalid npm tar entry path ${JSON.stringify(rawPath)}.`); + } + const segments = rawPath.split("/"); + if ( + segments[0] !== "package" || + segments.length < 2 || + segments.some( + (segment, index) => + segment.length === 0 || + segment === "." || + segment === ".." || + (index > 0 && /^[a-zA-Z]:/.test(segment)), + ) + ) { + throw new Error( + `npm tar entry is outside the canonical package root: ${JSON.stringify(rawPath)}.`, + ); + } + return segments.slice(1).join("/"); +} + +interface IIntegrityCandidate { + algorithm: string; + digest: Uint8Array; + rank: number; + webAlgorithm: AlgorithmIdentifier; +} + +function parseIntegrity(integrity: string): IIntegrityCandidate[] { + const tokens = integrity.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) throw new Error("tarball integrity is empty."); + const candidates: IIntegrityCandidate[] = []; + for (const token of tokens) { + const match = /^([A-Za-z0-9]+)-([A-Za-z0-9+/]+={0,2})(?:\?[!-~]+)?$/i.exec( + token, + ); + if (!match) { + throw new Error("tarball integrity contains malformed metadata."); + } + const algorithm = match[1]!.toLowerCase(); + if (!["sha1", "sha256", "sha384", "sha512"].includes(algorithm)) { + decodeBase64(match[2]!); + continue; + } + const expectedLength = + algorithm === "sha512" + ? 64 + : algorithm === "sha384" + ? 48 + : algorithm === "sha256" + ? 32 + : 20; + const digest = decodeBase64(match[2]!); + if (digest.length !== expectedLength) { + throw new Error("tarball integrity contains a malformed digest."); + } + candidates.push({ + algorithm, + digest, + rank: expectedLength, + webAlgorithm: algorithm.replace("sha", "SHA-") as AlgorithmIdentifier, + }); + } + if (candidates.length === 0) { + throw new Error("tarball integrity has no supported digest algorithm."); + } + return candidates; +} + +function decodeBase64(value: string): Uint8Array { + try { + const decoded = atob(value); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)); + } catch { + throw new Error("tarball integrity contains malformed base64."); + } +} + +function decodeHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/../g) ?? [], (byte) => + Number.parseInt(byte, 16), + ); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; ++index) { + difference |= left[index]! ^ right[index]!; + } + return difference === 0; +} + +async function collectBoundedStream( + stream: ReadableStream | null, + maxBytes: number, + kind: "compressed" | "expanded", + signal: AbortSignal | undefined, + fallback: () => Promise, +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new Error(`${kind} byte limit must be a positive safe integer.`); + } + if (stream === null) { + const bytes = await fallback(); + throwIfAborted(signal); + if (bytes.byteLength > maxBytes) { + throw new Error( + `tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`, + ); + } + return bytes; + } + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + throwIfAborted(signal); + const next = await reader.read(); + throwIfAborted(signal); + if (next.done) break; + if (next.value.byteLength > maxBytes - length) { + await reader.cancel(); + throw new Error( + `tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`, + ); + } + chunks.push(next.value); + length += next.value.byteLength; + } + } catch (error) { + await reader.cancel(error).catch(() => undefined); + throw error; + } + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output.buffer; +} + +function formatByteLimit(bytes: number): string { + return `${bytes.toLocaleString("en-US")}-byte`; } diff --git a/packages/playground/src/structures/IPlaygroundDependencyInstallOptions.ts b/packages/playground/src/structures/IPlaygroundDependencyInstallOptions.ts index 63dcae190..04e050a6f 100644 --- a/packages/playground/src/structures/IPlaygroundDependencyInstallOptions.ts +++ b/packages/playground/src/structures/IPlaygroundDependencyInstallOptions.ts @@ -22,6 +22,19 @@ export interface IPlaygroundDependencyInstallOptions { ignoredPackages?: Iterable; /** Safety cap: error out after installing this many packages. */ maxPackages?: number; + /** + * Maximum compressed bytes accepted for one npm tarball. + * + * Defaults to 16 MiB and is enforced while streaming, independent of the + * response's `Content-Length`. + */ + maxTarballBytes?: number; + /** + * Maximum expanded tar bytes accepted for one npm package. + * + * Defaults to 64 MiB and is enforced while gzip output is streamed. + */ + maxUnpackedBytes?: number; /** Aborts the install when triggered. */ signal?: AbortSignal; /** Fires for each phase transition during the install. */ diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts new file mode 100644 index 000000000..7bad513b6 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; + +import { + downloadTarball, + unpackNpmTarball, + verifyTarball, +} from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; +import { createNpmFixtureTarball } from "../internal/npmFixture"; + +/** + * Verifies cancellation is observed across the archive handoff. + * + * 1. Abort while a streamed download produces its first chunk and assert the byte + * collector stops. + * 2. Pass an already aborted signal to verification and decompression and assert + * neither stage continues. + */ +export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { + const tarball = createNpmFixtureTarball(); + const downloadController = new AbortController(); + const reason = new DOMException("fixture aborted", "AbortError"); + await assert.rejects( + downloadTarball( + async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(tarball.slice(0, 8))); + downloadController.abort(reason); + }, + }), + ), + "https://tar.invalid/fixture.tgz", + downloadController.signal, + ), + { name: "AbortError" }, + ); + + const stopped = new AbortController(); + stopped.abort(reason); + await assert.rejects(verifyTarball(tarball, {}, stopped.signal), { + name: "AbortError", + }); + await assert.rejects(unpackNpmTarball(tarball, stopped.signal), { + name: "AbortError", + }); +}; diff --git a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts new file mode 100644 index 000000000..e0d8bcc95 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { gunzipSync } from "node:zlib"; + +import { + createNpmFixtureTarball, + installNpmFixture, +} from "../internal/npmFixture"; + +/** + * Verifies both network and decompression budgets are enforced by byte count. + * + * A missing or falsely low Content-Length cannot bypass the compressed stream + * counter, and a small gzip cannot expand beyond the independent tar budget. + */ +export const test_npm_registry_bounds_compressed_and_expanded_archives = + async () => { + const tarball = createNpmFixtureTarball(); + const compressedLimit = tarball.byteLength - 1; + await assert.rejects( + installNpmFixture({ + options: { maxTarballBytes: compressedLimit }, + tarball, + }), + /compressed byte limit/, + ); + await assert.rejects( + installNpmFixture({ + options: { maxTarballBytes: compressedLimit }, + responseHeaders: { "content-length": "1" }, + tarball, + }), + /compressed byte limit/, + ); + + const expandedLength = gunzipSync(new Uint8Array(tarball)).byteLength; + await assert.rejects( + installNpmFixture({ + options: { maxUnpackedBytes: expandedLength - 1 }, + tarball, + }), + /expanded byte limit/, + ); + }; diff --git a/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts b/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts new file mode 100644 index 000000000..2cffaa29a --- /dev/null +++ b/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; + +import { installPlaygroundDependencies } from "../../../../packages/playground/lib/src/index.js"; +import { createNpmFixtureTarball } from "../internal/npmFixture"; + +/** + * Verifies optional semantics stop at registry absence. + * + * Once optional package metadata exists, an integrity failure is a corrupt + * archive and must abort the install rather than silently omit the dependency. + */ +export const test_npm_registry_does_not_skip_optional_archive_failures = + async () => { + const root = createNpmFixtureTarball({ + name: "root", + optionalDependencies: { optional: "*" }, + version: "1.0.0", + }); + const optional = createNpmFixtureTarball({ + name: "optional", + version: "1.0.0", + }); + const install = (optionalDist: { integrity?: string; tarball?: string }) => + installPlaygroundDependencies(["root"], { + fetch: async (url) => { + const name = url.includes("/optional") ? "optional" : "root"; + if (url.startsWith("https://registry.npmjs.org/")) { + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name, + version: "1.0.0", + dist: + name === "optional" + ? optionalDist + : { tarball: "https://tar.invalid/root.tgz" }, + }, + }, + }); + } + return new Response(name === "optional" ? optional : root); + }, + }); + + await assert.rejects( + install({ + integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + tarball: "https://tar.invalid/optional.tgz", + }), + /Failed to install optional@1\.0\.0: tarball integrity mismatch/, + ); + await assert.rejects( + install({}), + /No tarball found for optional@1\.0\.0/, + "optional metadata without an archive is not a registry-absence skip", + ); + }; diff --git a/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts b/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts new file mode 100644 index 000000000..890bd8713 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +import { + createNpmFixtureTarball, + installNpmFixture, +} from "../internal/npmFixture"; + +/** + * Verifies the explicit compatibility order for older registry metadata. + * + * SHA-1 `shasum` is used only when SRI is absent. Metadata carrying neither + * field remains installable for private and historical registries. + */ +export const test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata = + async () => { + const tarball = createNpmFixtureTarball(); + const shasum = crypto + .createHash("sha1") + .update(new Uint8Array(tarball)) + .digest("hex"); + assert.equal( + (await installNpmFixture({ dist: { shasum }, tarball })).packages.length, + 1, + ); + await assert.rejects( + installNpmFixture({ + dist: { shasum: "0".repeat(40) }, + tarball, + }), + /tarball shasum mismatch/, + ); + assert.equal( + (await installNpmFixture({ tarball })).packages.length, + 1, + "missing authentication metadata follows the documented legacy policy", + ); + }; diff --git a/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts b/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts new file mode 100644 index 000000000..22e3d10fa --- /dev/null +++ b/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +import { + createNpmFixtureTarball, + installNpmFixture, +} from "../internal/npmFixture"; + +/** + * Verifies npm SRI is checked before an archive is decompressed. + * + * The strongest supported algorithm controls the decision even when a weaker + * digest matches. Changed bytes and malformed metadata must fail with package + * context, while a correct SHA-512 digest installs normally. + */ +export const test_npm_registry_verifies_the_strongest_integrity_digest = + async () => { + const tarball = createNpmFixtureTarball(); + const sha512 = digest(tarball, "sha512"); + const sha256 = digest(tarball, "sha256"); + const installed = await installNpmFixture({ + dist: { + integrity: `futurehash-${wrongDigest(20)} sha256-${wrongDigest(32)} sha512-${sha512}`, + }, + tarball, + }); + assert.equal(installed.packages[0]?.name, "fixture"); + + await assert.rejects( + installNpmFixture({ + dist: { + integrity: `sha256-${sha256} sha512-${wrongDigest(64)}`, + }, + tarball, + }), + /Failed to install fixture@1\.0\.0: tarball integrity mismatch \(sha512\)/, + ); + + const changed = tarball.slice(0); + new Uint8Array(changed)[10] ^= 1; + await assert.rejects( + installNpmFixture({ + dist: { integrity: `sha512-${sha512}` }, + tarball: changed, + }), + /tarball integrity mismatch/, + ); + await assert.rejects( + installNpmFixture({ + dist: { integrity: "sha512-not base64" }, + tarball, + }), + /tarball integrity contains (?:malformed metadata|a malformed digest)/, + ); + }; + +function digest(bytes: ArrayBuffer, algorithm: "sha256" | "sha512"): string { + return crypto + .createHash(algorithm) + .update(new Uint8Array(bytes)) + .digest("base64"); +} + +function wrongDigest(bytes: number): string { + return Buffer.alloc(bytes, 0xa5).toString("base64"); +} diff --git a/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts b/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts new file mode 100644 index 000000000..0b5f79b84 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; + +import { unpackNpmTarball } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; +import { createPaxRecord, createTarball } from "../internal/tarball"; + +/** + * Verifies every tar path source is confined below the canonical package root. + * + * Direct headers, PAX path overrides, and GNU long-name records must reject + * parent, absolute, drive, and backslash spellings before any file map + * returns. + */ +export const test_npm_tarball_rejects_paths_outside_the_package_root = + async () => { + for (const path of [ + "package/../../outside.js", + "/package/outside.js", + "C:/package/outside.js", + "package\\outside.js", + "other/index.js", + "package/./index.js", + ]) { + await assert.rejects( + unpackNpmTarball(createTarball([{ body: "bad", path }]), undefined), + /tar entry|canonical package root/, + `header path ${JSON.stringify(path)} must be rejected`, + ); + } + + await assert.rejects( + unpackNpmTarball( + createTarball([ + { + body: createPaxRecord("path", "package/../../pax.js"), + path: "PaxHeader", + type: "x", + }, + { body: "bad", path: "package/safe.js" }, + ]), + undefined, + ), + /canonical package root/, + ); + await assert.rejects( + unpackNpmTarball( + createTarball([ + { + body: new TextEncoder().encode("package/../../long.js\0"), + path: "././@LongLink", + type: "L", + }, + { body: "bad", path: "package/safe.js" }, + ]), + undefined, + ), + /canonical package root/, + ); + }; diff --git a/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts b/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts new file mode 100644 index 000000000..cd11e9282 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { gunzipSync, gzipSync } from "node:zlib"; + +import { unpackNpmTarball } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; +import { createNpmFixtureTarball } from "../internal/npmFixture"; + +/** + * Verifies tar record bounds are checked before body slicing. + * + * Missing end markers, bodies that extend past the archive, and malformed + * numeric fields must fail instead of producing partial maps. + */ +export const test_npm_tarball_rejects_truncated_and_overflowing_entries = + async () => { + const valid = new Uint8Array(gunzipSync(createNpmFixtureTarball())); + await assert.rejects( + unpackNpmTarball(gzip(valid.subarray(0, valid.length - 1024)), undefined), + /no end marker/, + ); + + const oversized = valid.slice(); + writeSize(oversized, "77777777777"); + await assert.rejects( + unpackNpmTarball(gzip(oversized), undefined), + /extends beyond the archive/, + ); + + const malformed = valid.slice(); + writeSize(malformed, "999"); + await assert.rejects( + unpackNpmTarball(gzip(malformed), undefined), + /Invalid tar entry size/, + ); + }; + +function gzip(bytes: Uint8Array): ArrayBuffer { + const compressed = gzipSync(bytes); + return compressed.buffer.slice( + compressed.byteOffset, + compressed.byteOffset + compressed.byteLength, + ) as ArrayBuffer; +} + +function writeSize(tar: Uint8Array, value: string): void { + tar.fill(0, 124, 136); + tar.set(new TextEncoder().encode(value), 124); +} diff --git a/tests/test-playground/src/internal/npmFixture.ts b/tests/test-playground/src/internal/npmFixture.ts new file mode 100644 index 000000000..36037cb01 --- /dev/null +++ b/tests/test-playground/src/internal/npmFixture.ts @@ -0,0 +1,70 @@ +import { installPlaygroundDependencies } from "../../../../packages/playground/lib/src/index.js"; +import type { IPlaygroundDependencyInstallOptions } from "../../../../packages/playground/lib/src/index.js"; +import { createTarball } from "./tarball"; + +/** Minimal fixture package archive accepted by the browser npm installer. */ +export function createNpmFixtureTarball( + packageJson: Record = { + name: "fixture", + version: "1.0.0", + }, +): ArrayBuffer { + return createTarball([ + { + body: JSON.stringify(packageJson), + path: "package/package.json", + }, + { + body: "module.exports = true;\n", + path: "package/index.js", + }, + { + body: "export declare const value: true;\n", + path: "package/index.d.ts", + }, + ]); +} + +/** Install one package through controlled registry metadata and tarball bytes. */ +export function installNpmFixture(input: { + dist?: { + integrity?: string; + shasum?: string; + tarball?: string; + }; + options?: Omit; + packageName?: string; + responseHeaders?: HeadersInit; + tarball: ArrayBuffer; +}): ReturnType { + const packageName = input.packageName ?? "fixture"; + const tarballUrl = + input.dist?.tarball ?? `https://tar.invalid/${packageName}.tgz`; + return installPlaygroundDependencies([packageName], { + ...input.options, + fetch: async (url) => { + if (url.startsWith("https://registry.npmjs.org/")) { + return Response.json({ + name: packageName, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: packageName, + version: "1.0.0", + dist: { + ...input.dist, + tarball: tarballUrl, + }, + }, + }, + }); + } + if (url === tarballUrl) { + return new Response(input.tarball, { + headers: input.responseHeaders, + }); + } + return new Response(null, { status: 404 }); + }, + }); +} diff --git a/website/src/content/docs/playground.mdx b/website/src/content/docs/playground.mdx index feaefd8e9..2fd4693d5 100644 --- a/website/src/content/docs/playground.mdx +++ b/website/src/content/docs/playground.mdx @@ -21,3 +21,7 @@ The `typia` packages used by the default examples remain bundled because the was ## Embedding the playground Everything behind the hosted playground ships as the [`@ttsc/playground`](https://github.com/samchon/ttsc/tree/master/packages/playground) package: the worker compiler (`createWorkerCompiler`), the browser-side npm dependency loader (`installPlaygroundDependencies`), and the Monaco-based React UI (`PlaygroundShell`). The website itself consumes the package, so embedding the same playground in another site starts from the package README. + +The npm loader authenticates archives with registry SRI (or legacy shasum), confines files below the npm `package/` root, and enforces independent compressed and expanded byte limits. Metadata without either digest remains compatible but unauthenticated. These checks protect the install handoff and browser-tab availability; they do not make downloaded code trustworthy. + +`createSandboxRequire` is a CommonJS resolver and evaluator, not a browser security boundary. An embedder that accepts untrusted code or packages must provide an `executeBundle` policy with the origin, process, and capability isolation its site requires. Install byte limits do not constrain runtime loops, timers, network access, or globals exposed by that executor. From 7146bd08e86d89e47cc76a9e4c052caf85285b20 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:45:56 +0900 Subject: [PATCH 10/34] fix(playground): separate package selection from loading --- .../src/sandbox/createSandboxRequire.ts | 99 +++++++++---------- ...equire_completes_legacy_index_fallbacks.ts | 15 +++ ...x_require_normalizes_url_export_targets.ts | 2 +- ...uire_preserves_array_blocking_semantics.ts | 19 ++++ ...x_require_rejects_export_target_escapes.ts | 6 +- 5 files changed, 87 insertions(+), 54 deletions(-) diff --git a/packages/playground/src/sandbox/createSandboxRequire.ts b/packages/playground/src/sandbox/createSandboxRequire.ts index 814a3d2d4..85935a93c 100644 --- a/packages/playground/src/sandbox/createSandboxRequire.ts +++ b/packages/playground/src/sandbox/createSandboxRequire.ts @@ -41,6 +41,7 @@ type ExportTargetResolution = | { type: "unresolved" }; class InvalidPackageTargetError extends Error {} +class InvalidPackageTargetLoadError extends Error {} class InvalidPackageConfigError extends Error {} /** @@ -88,37 +89,49 @@ export function createSandboxRequire( return manifest?.exports !== null && manifest?.exports !== undefined; }; + /** Resolve one legacy CommonJS file-or-directory candidate. */ + const resolveLegacyPath = ( + candidate: string, + seen = new Set(), + ): string | null => { + const file = tryPaths( + candidate, + `${candidate}.js`, + `${candidate}.cjs`, + `${candidate}.mjs`, + `${candidate}.json`, + ); + if (file !== null) return file; + if (seen.has(candidate)) return null; + seen.add(candidate); + + const manifest = readPackageJson(candidate); + if (typeof manifest?.main === "string" && manifest.main.length !== 0) { + const main = posixJoin(candidate, manifest.main); + if (main !== candidate) { + const resolvedMain = resolveLegacyPath(main, seen); + if (resolvedMain !== null) return resolvedMain; + } + } + return tryPaths( + `${candidate}/index.js`, + `${candidate}/index.cjs`, + `${candidate}/index.json`, + ); + }; + // Read package.json from pack and resolve via main/exports. const resolvePackageEntry = ( mount: string, subpath: string | null, ): string | null => { const pj = readPackageJson(mount); - if (pj === null) return null; - if (pj.exports !== null && pj.exports !== undefined) { + if (pj?.exports !== null && pj?.exports !== undefined) { const resolution = resolvePackageExports(mount, pj.exports, subpath); return resolution.type === "resolved" ? resolution.key : null; } if (subpath === null) { - if (typeof pj.main === "string") { - const main = stripDotSlash(pj.main); - const resolvedMain = tryPaths( - `${mount}/${main}`, - `${mount}/${main}.js`, - `${mount}/${main}.cjs`, - `${mount}/${main}.mjs`, - `${mount}/${main}.json`, - `${mount}/${main}/index.js`, - `${mount}/${main}/index.cjs`, - `${mount}/${main}/index.json`, - ); - if (resolvedMain !== null) return resolvedMain; - } - return tryPaths( - `${mount}/index.js`, - `${mount}/index.cjs`, - `${mount}/index.json`, - ); + return resolveLegacyPath(mount); } return null; }; @@ -158,15 +171,7 @@ export function createSandboxRequire( if (!fromKey) return null; const baseDir = dirname(fromKey); const joined = posixJoin(baseDir, specifier); - return tryPaths( - joined, - `${joined}.js`, - `${joined}.cjs`, - `${joined}.mjs`, - `${joined}.json`, - `${joined}/index.js`, - `${joined}/index.cjs`, - ); + return resolveLegacyPath(joined); } // Bare specifier. Split into package name + subpath. const { pkg, subpath } = splitBareSpecifier(specifier); @@ -181,16 +186,7 @@ export function createSandboxRequire( // retain the historical packed-file fallback. if (packageDeclaresExports(pkg)) return resolvePackageEntry(pkg, subpath); // First try direct paths (covers packages that do not declare exports). - const direct = tryPaths( - `${pkg}/${subpath}`, - `${pkg}/${subpath}.js`, - `${pkg}/${subpath}.cjs`, - `${pkg}/${subpath}.mjs`, - `${pkg}/${subpath}.json`, - `${pkg}/${subpath}/index.js`, - `${pkg}/${subpath}/index.cjs`, - `${pkg}/${subpath}/index.json`, - ); + const direct = resolveLegacyPath(`${pkg}/${subpath}`); if (direct) return direct; // Fall back to package.json exports map. return resolvePackageEntry(pkg, subpath); @@ -473,8 +469,8 @@ function resolvePackageTargetKey(mount: string, target: string): string { } const pathnameTarget = target.split(/[?#]/, 1)[0]!; if (/%(?:2f|5c)/i.test(pathnameTarget)) { - throw new InvalidPackageTargetError( - `invalid package target for ${mount}: ${JSON.stringify(target)}`, + throw new InvalidPackageTargetLoadError( + `invalid module specifier for ${mount}: ${JSON.stringify(target)}`, ); } for (const rawSegment of pathnameTarget.slice(2).split(/[\\/]/)) { @@ -482,8 +478,8 @@ function resolvePackageTargetKey(mount: string, target: string): string { try { decoded = decodeURIComponent(rawSegment); } catch { - throw new InvalidPackageTargetError( - `invalid package target for ${mount}: ${JSON.stringify(target)}`, + throw new InvalidPackageTargetLoadError( + `invalid module specifier for ${mount}: ${JSON.stringify(target)}`, ); } const normalized = decoded.toLowerCase(); @@ -513,16 +509,15 @@ function resolvePackageTargetKey(mount: string, target: string): string { ); } const relative = resolvedPath.slice(basePath.length); - if (relative.length === 0) { - throw new InvalidPackageTargetError( - `invalid package target for ${mount}: ${JSON.stringify(target)}`, - ); - } return `${mount}/${relative}`; } catch (error) { - if (error instanceof InvalidPackageTargetError) throw error; - throw new InvalidPackageTargetError( - `invalid package target for ${mount}: ${JSON.stringify(target)}`, + if ( + error instanceof InvalidPackageTargetError || + error instanceof InvalidPackageTargetLoadError + ) + throw error; + throw new InvalidPackageTargetLoadError( + `invalid module specifier for ${mount}: ${JSON.stringify(target)}`, ); } } diff --git a/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts b/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts index f639b98ab..27edd5280 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts @@ -24,6 +24,16 @@ export const test_create_sandbox_require_completes_legacy_index_fallbacks = "json-directory/lib/index.json": JSON.stringify({ value: "json-directory", }), + "nested/package.json": JSON.stringify({ main: "./entry.cjs" }), + "nested/entry.cjs": + "module.exports = [require('./jsondir'), require('./subdir')];", + "nested/jsondir/index.json": JSON.stringify({ value: "relative-json" }), + "nested/subdir/package.json": JSON.stringify({ main: "./main.cjs" }), + "nested/subdir/main.cjs": "module.exports = 'relative-main';", + "bare-sub/package.json": JSON.stringify({ main: "./index.cjs" }), + "bare-sub/index.cjs": "module.exports = require('bare-sub/sub');", + "bare-sub/sub/package.json": JSON.stringify({ main: "./main.cjs" }), + "bare-sub/sub/main.cjs": "module.exports = 'bare-sub-main';", }, { console }, ); @@ -33,4 +43,9 @@ export const test_create_sandbox_require_completes_legacy_index_fallbacks = assert.deepEqual(require("json-directory"), { value: "json-directory", }); + assert.deepEqual(require("nested"), [ + { value: "relative-json" }, + "relative-main", + ]); + assert.equal(require("bare-sub"), "bare-sub-main"); }; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts b/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts index ca3ca8f49..aeb567131 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts @@ -38,5 +38,5 @@ export const test_create_sandbox_require_normalizes_url_export_targets = () => { assert.equal(require("urls/hash"), "suffix"); assert.equal(require("urls/backslash"), "normalized"); assert.equal(require("urls/slashes"), "normalized"); - assert.throws(() => require("urls/escape"), /invalid package target/); + assert.throws(() => require("urls/escape"), /invalid module specifier/); }; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts index 2ad73b70c..b0e1bb737 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts @@ -43,6 +43,18 @@ export const test_create_sandbox_require_preserves_array_blocking_semantics = }, }), "dot-condition/default.cjs": "module.exports = 'default';", + "load-error/package.json": JSON.stringify({ + exports: ["./%2foutside.cjs", "./fallback.cjs"], + }), + "load-error/fallback.cjs": "module.exports = 'wrong';", + "directory/package.json": JSON.stringify({ + exports: ["./", "./fallback.cjs"], + }), + "directory/fallback.cjs": "module.exports = 'wrong';", + "malformed/package.json": JSON.stringify({ + exports: ["./bad%escape.cjs", "./fallback.cjs"], + }), + "malformed/fallback.cjs": "module.exports = 'wrong';", }, { console }, ); @@ -56,4 +68,11 @@ export const test_create_sandbox_require_preserves_array_blocking_semantics = "default", "an inactive nested dot key leaves the branch unresolved", ); + assert.throws(() => require("load-error"), /invalid module specifier/); + assert.throws( + () => require("directory"), + /is not available/, + "a selected package-directory target must not reach array fallback", + ); + assert.throws(() => require("malformed"), /invalid module specifier/); }; diff --git a/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts b/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts index f14d67a10..64b6404c9 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts @@ -44,7 +44,6 @@ export const test_create_sandbox_require_rejects_export_target_escapes = () => { "encoded-dot", "modules", "encoded-modules", - "encoded-slash", "pattern/escape/../secret", ]) { assert.throws( @@ -53,4 +52,9 @@ export const test_create_sandbox_require_rejects_export_target_escapes = () => { specifier, ); } + assert.throws( + () => require("encoded-slash"), + /invalid module specifier/, + "an encoded separator is selected but cannot be converted to a pack path", + ); }; From 9b83bf6aeb210dafc52827f0509dd9f442cef3cd Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:46:24 +0900 Subject: [PATCH 11/34] fix(playground): preserve bounded archive compatibility --- packages/playground/README.md | 2 +- .../src/npm/internal/npmRegistry.ts | 99 ++++++++++++++++--- ...rchive_pipeline_honors_abort_boundaries.ts | 46 +++++---- ...bounds_compressed_and_expanded_archives.ts | 25 +++++ ...does_not_skip_optional_archive_failures.ts | 4 + ...sum_and_allows_legacy_unsigned_metadata.ts | 4 + ...verifies_the_strongest_integrity_digest.ts | 4 + ...rball_accepts_one_safe_nonstandard_root.ts | 48 +++++++++ ..._rejects_paths_outside_the_package_root.ts | 25 ++++- ...jects_truncated_and_overflowing_entries.ts | 4 + website/src/content/docs/playground.mdx | 2 +- 11 files changed, 224 insertions(+), 39 deletions(-) create mode 100644 tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts diff --git a/packages/playground/README.md b/packages/playground/README.md index 5e4463f8c..2e822c543 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -150,7 +150,7 @@ When a direct source import is removed, solve the complete current root list wit `PlaygroundShell` wires this automatically on every keystroke, debounced 900 ms, with an abort signal on source change. -The installer verifies the strongest supported `dist.integrity` digest before decompression, or `dist.shasum` when integrity is absent. Historical and private registry metadata with neither field remains compatible but unauthenticated. Each tarball is streamed through independent compressed and expanded byte limits (16 MiB and 64 MiB by default), and every file must remain below the archive's canonical `package/` root. Override the limits with `maxTarballBytes` and `maxUnpackedBytes` when a known package requires more. +The installer verifies the strongest supported `dist.integrity` digest before decompression, or `dist.shasum` when integrity is absent. Historical and private registry metadata with neither field remains compatible but unauthenticated. Each tarball is streamed through independent compressed and expanded byte limits (16 MiB and 64 MiB by default), and every file must remain below one safe, consistent archive root (`package/` normally; DefinitelyTyped uses roots such as `node/`). Override the limits with `maxTarballBytes` and `maxUnpackedBytes` when a known package requires more. Integrity proves that archive bytes match registry metadata; it does not establish that package code is safe. `createSandboxRequire` is a CommonJS resolver and evaluator, not an origin, process, or capability sandbox. The byte limits protect installation availability, not runtime behavior such as loops, timers, network access, or globals exposed by the host. Sites accepting untrusted code or packages must provide an isolated `executeBundle` policy appropriate to their environment. diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 9b4211c66..71e2f1ddf 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -147,6 +147,7 @@ export async function downloadTarball( if (declaredLength !== null) { const parsed = Number(declaredLength); if (Number.isFinite(parsed) && parsed >= 0 && parsed > maxBytes) { + void response.body?.cancel().catch(() => undefined); throw new Error( `tarball exceeds the ${formatByteLimit(maxBytes)} compressed byte limit.`, ); @@ -175,7 +176,10 @@ export async function verifyTarball( (candidate) => candidate.rank === strength, ); const actual = new Uint8Array( - await crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz), + await abortable( + crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz), + signal, + ), ); throwIfAborted(signal); if (!strongest.some(({ digest }) => equalBytes(actual, digest))) { @@ -189,7 +193,9 @@ export async function verifyTarball( if (!/^[a-fA-F0-9]{40}$/.test(dist.shasum)) { throw new Error("tarball shasum is not a valid SHA-1 digest."); } - const actual = new Uint8Array(await crypto.subtle.digest("SHA-1", tgz)); + const actual = new Uint8Array( + await abortable(crypto.subtle.digest("SHA-1", tgz), signal), + ); throwIfAborted(signal); if (!equalBytes(actual, decodeHex(dist.shasum))) { throw new Error("tarball shasum mismatch (sha1)."); @@ -212,6 +218,13 @@ export async function unpackNpmTarball( let longPath: string | null = null; let paxPath: string | null = null; let terminated = false; + let archiveRoot: string | null = null; + + const confine = (rawPath: string): string => { + const confined = confineTarPath(rawPath, archiveRoot); + archiveRoot = confined.root; + return confined.relative; + }; while (offset < tar.length) { throwIfAborted(signal); @@ -239,12 +252,12 @@ export async function unpackNpmTarball( if (type === "L") { longPath = trimNull(decoder.decode(body)); - confineTarPath(longPath); + confine(longPath); continue; } if (type === "x") { paxPath = parsePaxPath(body); - if (paxPath !== null) confineTarPath(paxPath); + if (paxPath !== null) confine(paxPath); continue; } if (type !== "0" && type !== "\0") { @@ -256,7 +269,7 @@ export async function unpackNpmTarball( const rawPath = paxPath ?? longPath ?? readTarHeaderPath(header, decoder); longPath = null; paxPath = null; - const rel = confineTarPath(rawPath); + const rel = confine(rawPath); if (!TEXT_FILE_REGEXP.test(rel)) continue; const text = decoder.decode(body); @@ -434,8 +447,17 @@ function readTarHeaderPath(header: Uint8Array, decoder: TextDecoder): string { return prefix ? `${prefix}/${name}` : name; } -/** Require a regular npm archive path below its canonical `package/` root. */ -function confineTarPath(rawPath: string): string { +/** + * Require an npm archive path below one safe, consistent top-level root. + * + * Npm normally emits `package/`, while current DefinitelyTyped tarballs use + * roots such as `node/` and `react/`. The root spelling does not enter the + * mounted key; consistency and safe remaining segments provide confinement. + */ +function confineTarPath( + rawPath: string, + archiveRoot: string | null, +): { relative: string; root: string } { if ( rawPath.length === 0 || rawPath.includes("\\") || @@ -446,7 +468,6 @@ function confineTarPath(rawPath: string): string { } const segments = rawPath.split("/"); if ( - segments[0] !== "package" || segments.length < 2 || segments.some( (segment, index) => @@ -457,10 +478,16 @@ function confineTarPath(rawPath: string): string { ) ) { throw new Error( - `npm tar entry is outside the canonical package root: ${JSON.stringify(rawPath)}.`, + `npm tar entry is outside a confined package root: ${JSON.stringify(rawPath)}.`, + ); + } + const root = segments[0]!; + if (archiveRoot !== null && root !== archiveRoot) { + throw new Error( + `npm tar archive mixes package roots ${JSON.stringify(archiveRoot)} and ${JSON.stringify(root)}.`, ); } - return segments.slice(1).join("/"); + return { relative: segments.slice(1).join("/"), root }; } interface IIntegrityCandidate { @@ -558,14 +585,33 @@ async function collectBoundedStream( const reader = stream.getReader(); const chunks: Uint8Array[] = []; let length = 0; + let abort: (() => void) | undefined; + const aborted = + signal === undefined + ? undefined + : new Promise((_resolve, reject) => { + abort = () => { + let error: unknown; + try { + throwIfAborted(signal); + return; + } catch (caught) { + error = caught; + } + void reader.cancel(error).catch(() => undefined); + reject(error); + }; + signal.addEventListener("abort", abort, { once: true }); + }); try { for (;;) { throwIfAborted(signal); - const next = await reader.read(); + const read = reader.read(); + const next = aborted ? await Promise.race([read, aborted]) : await read; throwIfAborted(signal); if (next.done) break; if (next.value.byteLength > maxBytes - length) { - await reader.cancel(); + void reader.cancel().catch(() => undefined); throw new Error( `tarball exceeds the ${formatByteLimit(maxBytes)} ${kind} byte limit.`, ); @@ -574,8 +620,10 @@ async function collectBoundedStream( length += next.value.byteLength; } } catch (error) { - await reader.cancel(error).catch(() => undefined); + void reader.cancel(error).catch(() => undefined); throw error; + } finally { + if (abort !== undefined) signal?.removeEventListener("abort", abort); } const output = new Uint8Array(length); let offset = 0; @@ -589,3 +637,28 @@ async function collectBoundedStream( function formatByteLimit(bytes: number): string { return `${bytes.toLocaleString("en-US")}-byte`; } + +/** + * Reject promptly on abort even when an underlying browser task is not + * cancellable. + */ +function abortable( + task: Promise, + signal: AbortSignal | undefined, +): Promise { + if (signal === undefined) return task; + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const abort = () => { + try { + throwIfAborted(signal); + } catch (error) { + reject(error); + } + }; + signal.addEventListener("abort", abort, { once: true }); + void task.then(resolve, reject).finally(() => { + signal.removeEventListener("abort", abort); + }); + }); +} diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index 7bad513b6..bf3e0435c 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -10,31 +10,39 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; /** * Verifies cancellation is observed across the archive handoff. * - * 1. Abort while a streamed download produces its first chunk and assert the byte - * collector stops. - * 2. Pass an already aborted signal to verification and decompression and assert - * neither stage continues. + * 1. Abort while a streamed download is waiting and assert the byte collector + * rejects without waiting for another chunk. + * 2. Abort an in-flight digest, then pass an already aborted signal to + * decompression and assert every stage stops. */ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { const tarball = createNpmFixtureTarball(); const downloadController = new AbortController(); const reason = new DOMException("fixture aborted", "AbortError"); - await assert.rejects( - downloadTarball( - async () => - new Response( - new ReadableStream({ - pull(controller) { - controller.enqueue(new Uint8Array(tarball.slice(0, 8))); - downloadController.abort(reason); - }, - }), - ), - "https://tar.invalid/fixture.tgz", - downloadController.signal, - ), - { name: "AbortError" }, + const downloading = downloadTarball( + async () => + new Response( + new ReadableStream({ + pull: () => new Promise(() => undefined), + }), + ), + "https://tar.invalid/fixture.tgz", + downloadController.signal, ); + await Promise.resolve(); + downloadController.abort(reason); + await assert.rejects(downloading, { name: "AbortError" }); + + const digestController = new AbortController(); + const digesting = verifyTarball( + new ArrayBuffer(16 * 1024 * 1024), + { + integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + }, + digestController.signal, + ); + digestController.abort(reason); + await assert.rejects(digesting, { name: "AbortError" }); const stopped = new AbortController(); stopped.abort(reason); diff --git a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts index e0d8bcc95..909fd2b5a 100644 --- a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts +++ b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { gunzipSync } from "node:zlib"; +import { downloadTarball } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; import { createNpmFixtureTarball, installNpmFixture, @@ -11,6 +12,11 @@ import { * * A missing or falsely low Content-Length cannot bypass the compressed stream * counter, and a small gzip cannot expand beyond the independent tar budget. + * + * 1. Exceed the compressed limit with absent and falsely low length headers, and + * verify an oversized declared response is cancelled. + * 2. Keep the gzip small but set the expanded limit below its tar output. + * 3. Assert each independent byte budget fails with its own context. */ export const test_npm_registry_bounds_compressed_and_expanded_archives = async () => { @@ -31,6 +37,25 @@ export const test_npm_registry_bounds_compressed_and_expanded_archives = }), /compressed byte limit/, ); + let cancelCalls = 0; + await assert.rejects( + downloadTarball( + async () => + new Response( + new ReadableStream({ + cancel() { + ++cancelCalls; + }, + }), + { headers: { "content-length": "1000" } }, + ), + "https://tar.invalid/oversized.tgz", + undefined, + 999, + ), + /compressed byte limit/, + ); + assert.equal(cancelCalls, 1, "header rejection must cancel the response"); const expandedLength = gunzipSync(new Uint8Array(tarball)).byteLength; await assert.rejects( diff --git a/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts b/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts index 2cffaa29a..e714aecdd 100644 --- a/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts +++ b/tests/test-playground/src/features/test_npm_registry_does_not_skip_optional_archive_failures.ts @@ -8,6 +8,10 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; * * Once optional package metadata exists, an integrity failure is a corrupt * archive and must abort the install rather than silently omit the dependency. + * + * 1. Install a root whose optional edge resolves to registry metadata. + * 2. Give that edge a mismatched digest and then no tarball URL. + * 3. Assert both archive failures abort; only registry absence may skip. */ export const test_npm_registry_does_not_skip_optional_archive_failures = async () => { diff --git a/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts b/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts index 890bd8713..8b73f1e5a 100644 --- a/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts +++ b/tests/test-playground/src/features/test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata.ts @@ -11,6 +11,10 @@ import { * * SHA-1 `shasum` is used only when SRI is absent. Metadata carrying neither * field remains installable for private and historical registries. + * + * 1. Install with a matching SHA-1 shasum, then reject a mismatch. + * 2. Remove both authentication fields. + * 3. Assert the explicit legacy compatibility path remains installable. */ export const test_npm_registry_uses_shasum_and_allows_legacy_unsigned_metadata = async () => { diff --git a/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts b/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts index 22e3d10fa..507f22cfe 100644 --- a/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts +++ b/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts @@ -12,6 +12,10 @@ import { * The strongest supported algorithm controls the decision even when a weaker * digest matches. Changed bytes and malformed metadata must fail with package * context, while a correct SHA-512 digest installs normally. + * + * 1. Mix unsupported, weak mismatching, and strong matching digests. + * 2. Reverse the strong/weak result, alter bytes, and malform metadata. + * 3. Assert only an authenticated strongest digest reaches extraction. */ export const test_npm_registry_verifies_the_strongest_integrity_digest = async () => { diff --git a/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts b/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts new file mode 100644 index 000000000..5159f2f06 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; + +import { unpackNpmTarball } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; +import { createTarball } from "../internal/tarball"; + +/** + * Verifies DefinitelyTyped's official archive-root convention remains valid. + * + * 1. Build an `@types/node`-shaped archive whose single top-level root is `node/` + * rather than npm's usual `package/`. + * 2. Assert the root is stripped consistently and the package files unpack without + * weakening mixed-root rejection. + */ +export const test_npm_tarball_accepts_one_safe_nonstandard_root = async () => { + const longPath = `node/${"nested/".repeat(16)}long.d.ts`; + const unpacked = await unpackNpmTarball( + createTarball([ + { + body: JSON.stringify({ name: "@types/node", version: "26.1.1" }), + path: "node/package.json", + }, + { + body: "export declare const value: true;\n", + path: "node/index.d.ts", + }, + { + body: new TextEncoder().encode(`${longPath}\0`), + path: "././@LongLink", + type: "L", + }, + { + body: "export declare const long: true;\n", + path: "node/ignored.d.ts", + }, + ]), + undefined, + ); + + assert.equal(unpacked.packageJson.name, "@types/node"); + assert.equal( + unpacked.files["index.d.ts"], + "export declare const value: true;\n", + ); + assert.equal( + unpacked.files[longPath.slice("node/".length)], + "export declare const long: true;\n", + ); +}; diff --git a/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts b/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts index 0b5f79b84..602bcc155 100644 --- a/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts +++ b/tests/test-playground/src/features/test_npm_tarball_rejects_paths_outside_the_package_root.ts @@ -4,11 +4,15 @@ import { unpackNpmTarball } from "../../../../packages/playground/lib/src/npm/in import { createPaxRecord, createTarball } from "../internal/tarball"; /** - * Verifies every tar path source is confined below the canonical package root. + * Verifies every tar path source is confined below one consistent package root. * * Direct headers, PAX path overrides, and GNU long-name records must reject * parent, absolute, drive, and backslash spellings before any file map * returns. + * + * 1. Exercise unsafe direct, PAX, and GNU long-name spellings. + * 2. Mix two individually safe top-level roots in one archive. + * 3. Assert every form rejects before an unpacked file map is returned. */ export const test_npm_tarball_rejects_paths_outside_the_package_root = async () => { @@ -17,12 +21,12 @@ export const test_npm_tarball_rejects_paths_outside_the_package_root = "/package/outside.js", "C:/package/outside.js", "package\\outside.js", - "other/index.js", "package/./index.js", + "package/C:outside.js", ]) { await assert.rejects( unpackNpmTarball(createTarball([{ body: "bad", path }]), undefined), - /tar entry|canonical package root/, + /tar entry|confined package root/, `header path ${JSON.stringify(path)} must be rejected`, ); } @@ -39,7 +43,7 @@ export const test_npm_tarball_rejects_paths_outside_the_package_root = ]), undefined, ), - /canonical package root/, + /confined package root/, ); await assert.rejects( unpackNpmTarball( @@ -53,6 +57,17 @@ export const test_npm_tarball_rejects_paths_outside_the_package_root = ]), undefined, ), - /canonical package root/, + /confined package root/, + ); + + await assert.rejects( + unpackNpmTarball( + createTarball([ + { body: "first", path: "package/index.js" }, + { body: "second", path: "other/index.js" }, + ]), + undefined, + ), + /mixes package roots/, ); }; diff --git a/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts b/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts index cd11e9282..bcde2e894 100644 --- a/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts +++ b/tests/test-playground/src/features/test_npm_tarball_rejects_truncated_and_overflowing_entries.ts @@ -9,6 +9,10 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; * * Missing end markers, bodies that extend past the archive, and malformed * numeric fields must fail instead of producing partial maps. + * + * 1. Remove the end marker, overstate an entry body, and corrupt its size. + * 2. Recompress each malformed tar through the normal gzip entry point. + * 3. Assert record parsing fails before any partial package is published. */ export const test_npm_tarball_rejects_truncated_and_overflowing_entries = async () => { diff --git a/website/src/content/docs/playground.mdx b/website/src/content/docs/playground.mdx index 2fd4693d5..4ac784b6c 100644 --- a/website/src/content/docs/playground.mdx +++ b/website/src/content/docs/playground.mdx @@ -22,6 +22,6 @@ The `typia` packages used by the default examples remain bundled because the was Everything behind the hosted playground ships as the [`@ttsc/playground`](https://github.com/samchon/ttsc/tree/master/packages/playground) package: the worker compiler (`createWorkerCompiler`), the browser-side npm dependency loader (`installPlaygroundDependencies`), and the Monaco-based React UI (`PlaygroundShell`). The website itself consumes the package, so embedding the same playground in another site starts from the package README. -The npm loader authenticates archives with registry SRI (or legacy shasum), confines files below the npm `package/` root, and enforces independent compressed and expanded byte limits. Metadata without either digest remains compatible but unauthenticated. These checks protect the install handoff and browser-tab availability; they do not make downloaded code trustworthy. +The npm loader authenticates archives with registry SRI (or legacy shasum), confines files below one safe and consistent archive root, and enforces independent compressed and expanded byte limits. Metadata without either digest remains compatible but unauthenticated. These checks protect the install handoff and browser-tab availability; they do not make downloaded code trustworthy. `createSandboxRequire` is a CommonJS resolver and evaluator, not a browser security boundary. An embedder that accepts untrusted code or packages must provide an `executeBundle` policy with the origin, process, and capability isolation its site requires. Install byte limits do not constrain runtime loops, timers, network access, or globals exposed by that executor. From 67c2767fe95e2fc1fe7074a27bb4ec65d5d04c91 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:46:36 +0900 Subject: [PATCH 12/34] fix(unplugin): satisfy Bun runtime loader contracts --- packages/unplugin/README.md | 2 +- packages/unplugin/src/bun.ts | 17 +++++-- ...runtime_passes_through_unchanged_source.ts | 12 +++++ .../test-unplugin/src/internal/adapter-bun.ts | 46 +++++++++++++++++-- website/src/content/docs/setup/unplugin.mdx | 2 +- 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts diff --git a/packages/unplugin/README.md b/packages/unplugin/README.md index 25eef79a2..aec83f2cf 100644 --- a/packages/unplugin/README.md +++ b/packages/unplugin/README.md @@ -165,7 +165,7 @@ await Bun.build({ }); ``` -The adapter yields to the next Bun loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. The Bun runtime API has no corresponding hook, so one `Bun.plugin()` setup is treated as one immutable module-loading session: restart the Bun process after changing source, tsconfig, or plugin inputs. +Under `Bun.build`, the adapter yields to the next loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. Bun's runtime `onLoad` contract does not accept an undefined result, so `Bun.plugin()` explicitly passes those files through with their original source. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. The runtime API has no corresponding hook, so one setup is treated as one immutable module-loading session: restart the Bun process after changing source, tsconfig, or plugin inputs. ## Configuration diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index 1795a424d..a1c517caa 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -102,9 +102,11 @@ export interface BunLikeBuild { * * Bun does not implement the unplugin protocol, so this adapter wires the * shared ttsc transform core to Bun's `onLoad` hook directly. It reads each - * included file from disk and forwards the content to the transform. Excluded - * files and no-op transforms return `undefined` so Bun's next loader or - * built-in TypeScript loader retains ownership. + * included file from disk and forwards the content to the transform. Under + * `Bun.build`, excluded files and no-op transforms return `undefined` so the + * next loader retains ownership. The runtime `Bun.plugin()` API rejects an + * undefined `onLoad` result, so that path explicitly returns the original + * source and loader instead. * * The same object works for `Bun.build({ plugins: [ttsc()] })` (bundler) and * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see @@ -126,6 +128,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { const getOptions = () => (resolved ??= resolveOptions(resolveBunOptions(options))); const cache = createTtscTransformCache(); + const runtime = build.onStart === undefined; // Bun.plugin() has no onStart callback, but one setup invocation belongs // to exactly one runtime process and module-loading session. Mark that // session up front so first delivery of every emitted project module is @@ -136,7 +139,11 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { build.onStart?.(() => beginTtscTransformBuild(cache)); build.onLoad({ filter: sourceFilePattern }, async (args) => { if (!isTransformTarget(args.path)) { - return undefined; + if (!runtime) return undefined; + return { + contents: await fs.readFile(args.path, "utf8"), + loader: bunLoaderFor(args.path), + }; } const loader = bunLoaderFor(args.path); const source = await fs.readFile(args.path, "utf8"); @@ -151,7 +158,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { if (result !== undefined) { return { contents: result.code, loader }; } - return undefined; + return runtime ? { contents: source, loader } : undefined; }); }, }; diff --git a/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts b/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts new file mode 100644 index 000000000..f5d18728f --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts @@ -0,0 +1,12 @@ +import { assertBunRuntimePassesThroughUnchangedSource } from "../../internal/adapter-bun"; + +/** + * Verifies Bun runtime receives an object for unchanged TypeScript. + * + * 1. Register the adapter against a runtime-shaped builder without `onStart`. + * 2. Disable transforms and invoke its captured `onLoad` for a source file. + * 3. Assert the original contents and loader are returned instead of undefined. + */ +export const test_bun_runtime_passes_through_unchanged_source = async () => { + await assertBunRuntimePassesThroughUnchangedSource(); +}; diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index 6f6fee788..434da9262 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -19,15 +19,26 @@ type BunLoader = (args: { * first `onLoad` handler plus its filter, mirroring how Bun drives the plugin. * No real Bun runtime is required. */ -async function captureBunLoader(plugin: { - setup(build: unknown): void; -}): Promise<{ loader: BunLoader; options: BunLoadOptions }> { +async function captureBunLoader( + plugin: { + setup(build: unknown): void; + }, + mode: "bundler" | "runtime" = "runtime", +): Promise<{ + loader: BunLoader; + options: BunLoadOptions; +}> { const loaders: { loader: BunLoader; options: BunLoadOptions }[] = []; - plugin.setup({ + const build = { onLoad(options: BunLoadOptions, loader: BunLoader) { loaders.push({ loader, options }); }, - }); + } as { + onLoad(options: BunLoadOptions, loader: BunLoader): void; + onStart?: (callback: () => void | Promise) => void; + }; + if (mode === "bundler") build.onStart = () => undefined; + plugin.setup(build); const registration = loaders[0]; assert.ok(registration, "Bun adapter did not register an onLoad handler"); return registration; @@ -132,6 +143,7 @@ async function assertBunAdapterFallsThroughWhenItDoesNotTransform(): Promise { + const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); + const root = TestUnpluginProject.createProject({ plugins: [] }); + const file = TestUnpluginProject.mainFile(root); + const source = fs.readFileSync(file, "utf8"); + const { loader } = await captureBunLoader( + unpluginBun({ plugins: [] }), + "runtime", + ); + + assert.deepEqual(await loader({ path: file }), { + contents: source, + loader: "ts", + }); +} + /** * Asserts Bun bundler `onStart` forwards the shared transform build lifecycle. * @@ -254,5 +289,6 @@ export { assertBunAdapterFallsThroughWhenItDoesNotTransform, assertBunAdapterSurvivesPluginReportedDependencies, assertBunAdapterTransformsSource, + assertBunRuntimePassesThroughUnchangedSource, assertBunRuntimeDoesNotRehashProjectPerModule, }; diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index 6765d4cb4..8cb31699e 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -205,7 +205,7 @@ This works for every adapter automatically when the transform host emits the env A plugin that knows exactly which declarations it consulted can go the other way and declare its reported list [complete](/docs/development/concepts/protocol#dependency-completeness) for a file. The adapter then registers only that list plus the tsconfig chain for it, so a change to a file the transform never read stops re-running the loader. Nothing is required of you: the field is per file and opt-in per plugin, and a plugin that never declares it keeps the sound default above. -The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output. Adapters with a guaranteed build boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Bun runtime setup defines one process-scoped module-loading session; restart the Bun process after changing source, tsconfig, or plugin inputs. Long-lived hosts without either boundary (Metro workers, the Turbopack loader, and Vite's development server, whose initial `buildStart` spans later HMR edits) validate the complete project and external-input snapshots on every generation hit. +The transform cache uses the same input set: every regular file reached by the non-following project walk plus graph-reported inputs outside that walk. One whole-project compile already contains every module's output. Adapters with a guaranteed build boundary clear the previous generation there, then check only the supplied source on each module's first delivery in that build; a repeated delivery validates the complete snapshots. This avoids one complete project walk per module on an initial build. Bun runtime setup defines one process-scoped module-loading session; restart the Bun process after changing source, tsconfig, or plugin inputs. Its runtime `onLoad` contract requires an object, so unchanged or excluded TypeScript is returned explicitly; `Bun.build` can instead yield to the next loader. Long-lived hosts without either boundary (Metro workers, the Turbopack loader, and Vite's development server, whose initial `buildStart` spans later HMR edits) validate the complete project and external-input snapshots on every generation hit. This section is about the **transform** cache (which files a rebuild depends on), not the **source-plugin binary** cache (the compiled Go plugin ttsc builds on first use). They are independent: persisting a bundler cache such as Next.js `.next/cache` does not carry the plugin binary across builds, and a fresh CI or container build recompiles the plugin (the `ttsc: building source plugin ...` line) even with a warm bundler cache. To keep that first-build compile from recurring on every build, persist the binary cache as described in [Compile → Plugin cache](/docs/ttsc/compile#plugin-cache). In a monorepo, run `ttsc prepare` from the same project directory the bundler builds, so the warmed binary matches the cache key the bundler build looks up. From 51bce0ceae80e59e78864912e8457c8e20aa0985 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:55:24 +0900 Subject: [PATCH 13/34] fix(playground): match legacy main directory resolution --- .../src/sandbox/createSandboxRequire.ts | 37 ++++++++++--------- ...equire_completes_legacy_index_fallbacks.ts | 23 ++++++++++-- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/playground/src/sandbox/createSandboxRequire.ts b/packages/playground/src/sandbox/createSandboxRequire.ts index 85935a93c..d50f86cab 100644 --- a/packages/playground/src/sandbox/createSandboxRequire.ts +++ b/packages/playground/src/sandbox/createSandboxRequire.ts @@ -89,35 +89,40 @@ export function createSandboxRequire( return manifest?.exports !== null && manifest?.exports !== undefined; }; - /** Resolve one legacy CommonJS file-or-directory candidate. */ - const resolveLegacyPath = ( - candidate: string, - seen = new Set(), - ): string | null => { - const file = tryPaths( + const resolveLegacyFile = (candidate: string): string | null => + tryPaths( candidate, `${candidate}.js`, `${candidate}.cjs`, `${candidate}.mjs`, `${candidate}.json`, ); + + const resolveLegacyIndex = (candidate: string): string | null => + tryPaths( + `${candidate}/index.js`, + `${candidate}/index.cjs`, + `${candidate}/index.json`, + ); + + /** Resolve one legacy CommonJS file-or-directory candidate. */ + const resolveLegacyPath = (candidate: string): string | null => { + const file = resolveLegacyFile(candidate); if (file !== null) return file; - if (seen.has(candidate)) return null; - seen.add(candidate); const manifest = readPackageJson(candidate); if (typeof manifest?.main === "string" && manifest.main.length !== 0) { const main = posixJoin(candidate, manifest.main); if (main !== candidate) { - const resolvedMain = resolveLegacyPath(main, seen); + // Node's legacy tryPackage resolves the selected main as a file, then + // as that directory's index. It does not recursively interpret a + // second package.json below the selected main directory. + const resolvedMain = + resolveLegacyFile(main) ?? resolveLegacyIndex(main); if (resolvedMain !== null) return resolvedMain; } } - return tryPaths( - `${candidate}/index.js`, - `${candidate}/index.cjs`, - `${candidate}/index.json`, - ); + return resolveLegacyIndex(candidate); }; // Read package.json from pack and resolve via main/exports. @@ -522,10 +527,6 @@ function resolvePackageTargetKey(mount: string, target: string): string { } } -function stripDotSlash(p: string): string { - return p.startsWith("./") ? p.slice(2) : p; -} - function dirname(p: string): string { const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); diff --git a/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts b/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts index 27edd5280..b7115175d 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_completes_legacy_index_fallbacks.ts @@ -5,10 +5,14 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa /** * Verifies packages without exports retain Node-style legacy fallbacks. * - * 1. Provide a missing main with a root index, a JSON-only root index, and a main - * directory containing only `index.json`. - * 2. Assert failed main lookup continues to root fallback and JSON index files are - * available at both directory levels. + * Root, relative, and bare-subpath requests all enter CommonJS directory + * resolution, but a selected root `main` must not recurse into another + * manifest. + * + * 1. Exercise missing main, root JSON index, and main-directory JSON index. + * 2. Resolve relative and bare subdirectories through their own manifests. + * 3. Assert a root main target ignores a nested manifest and falls back to the + * root index. */ export const test_create_sandbox_require_completes_legacy_index_fallbacks = () => { @@ -34,6 +38,12 @@ export const test_create_sandbox_require_completes_legacy_index_fallbacks = "bare-sub/index.cjs": "module.exports = require('bare-sub/sub');", "bare-sub/sub/package.json": JSON.stringify({ main: "./main.cjs" }), "bare-sub/sub/main.cjs": "module.exports = 'bare-sub-main';", + "main-boundary/package.json": JSON.stringify({ main: "./sub" }), + "main-boundary/sub/package.json": JSON.stringify({ + main: "./nested.cjs", + }), + "main-boundary/sub/nested.cjs": "module.exports = 'wrong-nested-main';", + "main-boundary/index.js": "module.exports = 'root-fallback';", }, { console }, ); @@ -48,4 +58,9 @@ export const test_create_sandbox_require_completes_legacy_index_fallbacks = "relative-main", ]); assert.equal(require("bare-sub"), "bare-sub-main"); + assert.equal( + require("main-boundary"), + "root-fallback", + "a root main directory does not recursively interpret its manifest", + ); }; From 4a8a97dc3b88fe85521cf7334367c7497b3d9252 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 04:55:40 +0900 Subject: [PATCH 14/34] fix(playground): close archive cancellation gaps --- .../src/npm/internal/npmRegistry.ts | 15 ++++++-- ...rchive_pipeline_honors_abort_boundaries.ts | 3 ++ ...bounds_compressed_and_expanded_archives.ts | 36 +++++++++++++++++++ ...rball_accepts_one_safe_nonstandard_root.ts | 3 ++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 71e2f1ddf..b3d551dad 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -139,8 +139,10 @@ export async function downloadTarball( signal: AbortSignal | undefined, maxBytes = 16 * 1024 * 1024, ): Promise { + validateByteLimit(maxBytes, "compressed"); const response = await fetchImpl(tarball, { signal }); if (!response.ok) { + void response.body?.cancel().catch(() => undefined); throw new Error(`tarball download failed with HTTP ${response.status}.`); } const declaredLength = response.headers.get("content-length"); @@ -569,9 +571,7 @@ async function collectBoundedStream( signal: AbortSignal | undefined, fallback: () => Promise, ): Promise { - if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { - throw new Error(`${kind} byte limit must be a positive safe integer.`); - } + validateByteLimit(maxBytes, kind); if (stream === null) { const bytes = await fallback(); throwIfAborted(signal); @@ -638,6 +638,15 @@ function formatByteLimit(bytes: number): string { return `${bytes.toLocaleString("en-US")}-byte`; } +function validateByteLimit( + maxBytes: number, + kind: "compressed" | "expanded", +): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new Error(`${kind} byte limit must be a positive safe integer.`); + } +} + /** * Reject promptly on abort even when an underlying browser task is not * cancellable. diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index bf3e0435c..926efd3ed 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -10,6 +10,9 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; /** * Verifies cancellation is observed across the archive handoff. * + * Superseded per-keystroke installs have no reusable result, so waiting for + * another network chunk or digest only consumes browser-tab resources. + * * 1. Abort while a streamed download is waiting and assert the byte collector * rejects without waiting for another chunk. * 2. Abort an in-flight digest, then pass an already aborted signal to diff --git a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts index 909fd2b5a..7dae60b08 100644 --- a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts +++ b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts @@ -56,6 +56,42 @@ export const test_npm_registry_bounds_compressed_and_expanded_archives = /compressed byte limit/, ); assert.equal(cancelCalls, 1, "header rejection must cancel the response"); + let httpCancelCalls = 0; + await assert.rejects( + downloadTarball( + async () => + new Response( + new ReadableStream({ + cancel() { + ++httpCancelCalls; + }, + }), + { status: 500 }, + ), + "https://tar.invalid/error.tgz", + undefined, + ), + /HTTP 500/, + ); + assert.equal(httpCancelCalls, 1, "HTTP rejection must cancel the response"); + let invalidLimitFetches = 0; + await assert.rejects( + downloadTarball( + async () => { + ++invalidLimitFetches; + return new Response(tarball); + }, + "https://tar.invalid/invalid-limit.tgz", + undefined, + 0, + ), + /positive safe integer/, + ); + assert.equal( + invalidLimitFetches, + 0, + "an invalid limit must fail before opening a response", + ); const expandedLength = gunzipSync(new Uint8Array(tarball)).byteLength; await assert.rejects( diff --git a/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts b/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts index 5159f2f06..50de7da6c 100644 --- a/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts +++ b/tests/test-playground/src/features/test_npm_tarball_accepts_one_safe_nonstandard_root.ts @@ -6,6 +6,9 @@ import { createTarball } from "../internal/tarball"; /** * Verifies DefinitelyTyped's official archive-root convention remains valid. * + * Current `@types/*` tarballs use roots such as `node/`, not npm's usual + * `package/`, while still requiring the same single-root confinement. + * * 1. Build an `@types/node`-shaped archive whose single top-level root is `node/` * rather than npm's usual `package/`. * 2. Assert the root is stripped consistently and the package files unpack without From 6c220b6438f4e523c794506e9fff8238f855802f Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:01:30 +0900 Subject: [PATCH 15/34] fix(unplugin): leave Bun virtual modules unclaimed --- packages/unplugin/src/bun.ts | 15 ++++++++++---- ...e_leaves_virtual_modules_to_their_owner.ts | 17 ++++++++++++++++ .../test-unplugin/src/internal/adapter-bun.ts | 20 +++++++++++++++++++ website/src/content/docs/setup/unplugin.mdx | 4 +++- 4 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index a1c517caa..bf66d84d6 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -5,11 +5,18 @@ import { createTtscTransformCache, isTransformTarget, resolveOptions, - sourceFilePattern, transformTtsc, } from "./core/index"; import type { TtscUnpluginOptions } from "./core/options"; +/** + * Bun receives absolute filesystem paths for ordinary source files, while + * plugin-created virtual ids may contain a NUL sentinel. A virtual id must stay + * with the plugin that created it: claiming it here and trying to read it from + * disk prevents that plugin's later loader from running. + */ +const bunSourceFilePattern = /^[^\x00]*\.[cm]?tsx?$/; + /** * Minimal subset of the Bun plugin API consumed by this adapter. * @@ -111,7 +118,7 @@ export interface BunLikeBuild { * The same object works for `Bun.build({ plugins: [ttsc()] })` (bundler) and * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see * `bun-register`. Every result carries an explicit `loader` so Bun keeps - * transpiling the emitted TypeScript at runtime; `sourceFilePattern` only + * transpiling the emitted TypeScript at runtime; `bunSourceFilePattern` only * matches TypeScript, so the loader is always `ts`/`tsx`. A runtime plugin * instance is one immutable load session, like Bun's own module cache; restart * the process after changing compiler inputs. @@ -137,7 +144,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { // repeats it for subsequent builds. beginTtscTransformBuild(cache); build.onStart?.(() => beginTtscTransformBuild(cache)); - build.onLoad({ filter: sourceFilePattern }, async (args) => { + build.onLoad({ filter: bunSourceFilePattern }, async (args) => { if (!isTransformTarget(args.path)) { if (!runtime) return undefined; return { @@ -165,7 +172,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { } /** - * Pick the Bun loader for a matched file. `sourceFilePattern` is + * Pick the Bun loader for a matched file. `bunSourceFilePattern` is * `/\.[cm]?tsx?$/`, so a trailing `x` (`.tsx`/`.ctsx`/`.mtsx`) is JSX-flavored * TypeScript and everything else (`.ts`/`.cts`/`.mts`) is plain TypeScript. */ diff --git a/tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts b/tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts new file mode 100644 index 000000000..4beb76fdd --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts @@ -0,0 +1,17 @@ +import { assertBunRuntimeLeavesVirtualModulesToTheirOwner } from "../../internal/adapter-bun"; + +/** + * Verifies Bun runtime does not claim another plugin's virtual module. + * + * A NUL id is not a filesystem path. If the broad TypeScript hook accepts it, + * the runtime pass-through branch tries to read it from disk and prevents the + * module's owning loader from running. + * + * 1. Capture the runtime-shaped Bun loader registration. + * 2. Test its filter against NUL-prefixed and ordinary TypeScript paths. + * 3. Assert only real filesystem-shaped source ids are claimed. + */ +export const test_bun_runtime_leaves_virtual_modules_to_their_owner = + async () => { + await assertBunRuntimeLeavesVirtualModulesToTheirOwner(); + }; diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index 434da9262..e1f50a43c 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -190,6 +190,25 @@ async function assertBunRuntimePassesThroughUnchangedSource(): Promise { }); } +/** + * Asserts the Bun adapter never claims a NUL-prefixed virtual TypeScript id. + * + * Bun applies loader filters before callbacks. A virtual id that reaches this + * callback would be treated as a filesystem path; rejecting it in the filter + * leaves ownership with the plugin that created the virtual module. + */ +async function assertBunRuntimeLeavesVirtualModulesToTheirOwner(): Promise { + const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); + const { options } = await captureBunLoader( + unpluginBun({ plugins: [] }), + "runtime", + ); + + assert.equal(options.filter.test("\0virtual.ts"), false); + assert.equal(options.filter.test("/project/src/ordinary.ts"), true); + assert.equal(options.filter.test("C:\\project\\src\\ordinary.tsx"), true); +} + /** * Asserts Bun bundler `onStart` forwards the shared transform build lifecycle. * @@ -289,6 +308,7 @@ export { assertBunAdapterFallsThroughWhenItDoesNotTransform, assertBunAdapterSurvivesPluginReportedDependencies, assertBunAdapterTransformsSource, + assertBunRuntimeLeavesVirtualModulesToTheirOwner, assertBunRuntimePassesThroughUnchangedSource, assertBunRuntimeDoesNotRehashProjectPerModule, }; diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index 8cb31699e..8f88ac339 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -161,7 +161,7 @@ await Bun.build({ }); ``` -The adapter yields to the next Bun loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged, so its broad TypeScript hook does not shadow another plugin or Bun's built-in loader. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. +Under `Bun.build`, the adapter yields to the next loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. Bun's runtime `onLoad` contract does not accept an undefined result, so `Bun.plugin()` returns those files with their original source and does not chain another overlapping runtime loader. NUL-prefixed virtual modules remain outside the adapter's filter so their creating plugin keeps ownership. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. For the Bun **runtime**, `bun run`, `bun test`, or any `bun .ts` with no bundling step, register the transform on Bun's module loader with `@ttsc/unplugin/bun-register`. Add a `bunfig.toml` preload once: @@ -178,6 +178,8 @@ import { register } from "@ttsc/unplugin/bun-register"; register({ project: "tsconfig.build.json" }); ``` +One runtime registration is one immutable module-loading session. Restart the Bun process after changing source, tsconfig, or plugin inputs. + ## Options Most projects need none: the adapter finds `tsconfig.json` on its own, and your plugins and `lint.config.ts` apply unchanged. Three options layer on top: From 1c217ba1adc198449f3ff0641146c6ca8663f0da Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:01:44 +0900 Subject: [PATCH 16/34] fix(playground): validate archive budgets up front --- .../src/npm/installPlaygroundDependencies.ts | 3 + .../src/npm/internal/npmRegistry.ts | 8 ++- ...bounds_compressed_and_expanded_archives.ts | 56 ++++++++++++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/playground/src/npm/installPlaygroundDependencies.ts b/packages/playground/src/npm/installPlaygroundDependencies.ts index 9d3b62fea..858eb980d 100644 --- a/packages/playground/src/npm/installPlaygroundDependencies.ts +++ b/packages/playground/src/npm/installPlaygroundDependencies.ts @@ -15,6 +15,7 @@ import { throwIfAborted, toTypesPackageName, unpackNpmTarball, + validateNpmByteLimit, verifyTarball, } from "./internal/npmRegistry"; @@ -78,6 +79,8 @@ export async function installPlaygroundDependencies( const maxTarballBytes = options.maxTarballBytes ?? DEFAULT_MAX_TARBALL_BYTES; const maxUnpackedBytes = options.maxUnpackedBytes ?? DEFAULT_MAX_UNPACKED_BYTES; + validateNpmByteLimit(maxTarballBytes, "compressed"); + validateNpmByteLimit(maxUnpackedBytes, "expanded"); const queue: IQueueItem[] = []; const queued = new Map(); const done = new Map(); diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index b3d551dad..9f86fe7c9 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -139,7 +139,7 @@ export async function downloadTarball( signal: AbortSignal | undefined, maxBytes = 16 * 1024 * 1024, ): Promise { - validateByteLimit(maxBytes, "compressed"); + validateNpmByteLimit(maxBytes, "compressed"); const response = await fetchImpl(tarball, { signal }); if (!response.ok) { void response.body?.cancel().catch(() => undefined); @@ -211,6 +211,7 @@ export async function unpackNpmTarball( maxBytes = 64 * 1024 * 1024, ): Promise { throwIfAborted(signal); + validateNpmByteLimit(maxBytes, "expanded"); const tar = await gunzip(tgz, maxBytes, signal); throwIfAborted(signal); const decoder = new TextDecoder(); @@ -571,7 +572,7 @@ async function collectBoundedStream( signal: AbortSignal | undefined, fallback: () => Promise, ): Promise { - validateByteLimit(maxBytes, kind); + validateNpmByteLimit(maxBytes, kind); if (stream === null) { const bytes = await fallback(); throwIfAborted(signal); @@ -638,7 +639,8 @@ function formatByteLimit(bytes: number): string { return `${bytes.toLocaleString("en-US")}-byte`; } -function validateByteLimit( +/** Validate one public npm archive byte budget before starting related work. */ +export function validateNpmByteLimit( maxBytes: number, kind: "compressed" | "expanded", ): void { diff --git a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts index 7dae60b08..443e462bd 100644 --- a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts +++ b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { gunzipSync } from "node:zlib"; -import { downloadTarball } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; +import { installPlaygroundDependencies } from "../../../../packages/playground/lib/src/index.js"; +import { + downloadTarball, + unpackNpmTarball, +} from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; import { createNpmFixtureTarball, installNpmFixture, @@ -15,8 +19,9 @@ import { * * 1. Exceed the compressed limit with absent and falsely low length headers, and * verify an oversized declared response is cancelled. - * 2. Keep the gzip small but set the expanded limit below its tar output. - * 3. Assert each independent byte budget fails with its own context. + * 2. Reject invalid public limits before metadata fetch or decompressor setup. + * 3. Keep the gzip small but set the expanded limit below its tar output. + * 4. Assert each independent byte budget fails with its own context. */ export const test_npm_registry_bounds_compressed_and_expanded_archives = async () => { @@ -92,6 +97,51 @@ export const test_npm_registry_bounds_compressed_and_expanded_archives = 0, "an invalid limit must fail before opening a response", ); + for (const invalid of [{ maxTarballBytes: 0 }, { maxUnpackedBytes: 0 }]) { + let installFetches = 0; + await assert.rejects( + installPlaygroundDependencies(["fixture"], { + ...invalid, + fetch: async () => { + ++installFetches; + throw new Error("fetch must not run"); + }, + }), + /positive safe integer/, + ); + assert.equal( + installFetches, + 0, + "public limits must be validated before metadata resolution", + ); + } + const originalDecompressionStream = globalThis.DecompressionStream; + let decompressionConstructions = 0; + Object.defineProperty(globalThis, "DecompressionStream", { + configurable: true, + value: class { + public constructor() { + ++decompressionConstructions; + throw new Error("decompressor must not be constructed"); + } + }, + }); + try { + await assert.rejects( + unpackNpmTarball(tarball, undefined, 0), + /positive safe integer/, + ); + } finally { + Object.defineProperty(globalThis, "DecompressionStream", { + configurable: true, + value: originalDecompressionStream, + }); + } + assert.equal( + decompressionConstructions, + 0, + "an invalid expanded limit must fail before decompressor setup", + ); const expandedLength = gunzipSync(new Uint8Array(tarball)).byteLength; await assert.rejects( From a6f91a9fbbc0909cb0f0e149b262af168763a3ab Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:04:11 +0900 Subject: [PATCH 17/34] fix(playground): release rejected metadata responses --- .../src/npm/internal/npmRegistry.ts | 6 ++- ...try_cancels_rejected_metadata_responses.ts | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/test-playground/src/features/test_npm_registry_cancels_rejected_metadata_responses.ts diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 9f86fe7c9..cd0bdaaaa 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -85,8 +85,12 @@ export async function fetchNpmMetadata( signal, }, ); - if (response.status === 404 && optional) return null; + if (response.status === 404 && optional) { + void response.body?.cancel().catch(() => undefined); + return null; + } if (!response.ok) { + void response.body?.cancel().catch(() => undefined); throw new Error( `npm registry returned ${response.status} while resolving ${packageName}.`, ); diff --git a/tests/test-playground/src/features/test_npm_registry_cancels_rejected_metadata_responses.ts b/tests/test-playground/src/features/test_npm_registry_cancels_rejected_metadata_responses.ts new file mode 100644 index 000000000..b749d5579 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_registry_cancels_rejected_metadata_responses.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; + +import { fetchNpmMetadata } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; + +/** + * Verifies rejected registry metadata responses release their body streams. + * + * Optional absence and registry errors do not consume metadata. Leaving those + * bodies open can retain a browser connection after the dependency solve has + * already skipped or failed the package. + * + * 1. Return controlled response streams for an optional 404 and a hard 500. + * 2. Exercise the skip and rejection paths without reading either body. + * 3. Assert both streams are cancelled exactly once. + */ +export const test_npm_registry_cancels_rejected_metadata_responses = + async () => { + for (const scenario of [ + { optional: true, status: 404 }, + { optional: false, status: 500 }, + ]) { + let cancellations = 0; + const fetchImpl = async () => + new Response( + new ReadableStream({ + cancel() { + ++cancellations; + }, + }), + { status: scenario.status }, + ); + + if (scenario.optional) { + assert.equal( + await fetchNpmMetadata(fetchImpl, "missing-package", true, undefined), + null, + ); + } else { + await assert.rejects( + fetchNpmMetadata(fetchImpl, "broken-package", false, undefined), + /returned 500/, + ); + } + assert.equal(cancellations, 1); + } + }; From 8536c082bd998f490e74c560fca1abdf9f283b53 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:06:30 +0900 Subject: [PATCH 18/34] test(playground): preserve archive cancellation priority --- .../src/npm/internal/npmRegistry.ts | 1 + ...rchive_pipeline_honors_abort_boundaries.ts | 18 ++++++++++-- ...bounds_compressed_and_expanded_archives.ts | 29 ++----------------- 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index cd0bdaaaa..da9a2cbe0 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -143,6 +143,7 @@ export async function downloadTarball( signal: AbortSignal | undefined, maxBytes = 16 * 1024 * 1024, ): Promise { + throwIfAborted(signal); validateNpmByteLimit(maxBytes, "compressed"); const response = await fetchImpl(tarball, { signal }); if (!response.ok) { diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index 926efd3ed..5ddd61e8b 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -15,8 +15,8 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; * * 1. Abort while a streamed download is waiting and assert the byte collector * rejects without waiting for another chunk. - * 2. Abort an in-flight digest, then pass an already aborted signal to - * decompression and assert every stage stops. + * 2. Abort an in-flight digest, then pass an already aborted signal through + * download, verification, and decompression and assert every stage stops. */ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { const tarball = createNpmFixtureTarball(); @@ -52,6 +52,20 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { await assert.rejects(verifyTarball(tarball, {}, stopped.signal), { name: "AbortError", }); + let stoppedFetches = 0; + await assert.rejects( + downloadTarball( + async () => { + ++stoppedFetches; + return new Response(tarball); + }, + "https://tar.invalid/stopped.tgz", + stopped.signal, + 0, + ), + { name: "AbortError" }, + ); + assert.equal(stoppedFetches, 0); await assert.rejects(unpackNpmTarball(tarball, stopped.signal), { name: "AbortError", }); diff --git a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts index 443e462bd..a637ff797 100644 --- a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts +++ b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts @@ -115,32 +115,9 @@ export const test_npm_registry_bounds_compressed_and_expanded_archives = "public limits must be validated before metadata resolution", ); } - const originalDecompressionStream = globalThis.DecompressionStream; - let decompressionConstructions = 0; - Object.defineProperty(globalThis, "DecompressionStream", { - configurable: true, - value: class { - public constructor() { - ++decompressionConstructions; - throw new Error("decompressor must not be constructed"); - } - }, - }); - try { - await assert.rejects( - unpackNpmTarball(tarball, undefined, 0), - /positive safe integer/, - ); - } finally { - Object.defineProperty(globalThis, "DecompressionStream", { - configurable: true, - value: originalDecompressionStream, - }); - } - assert.equal( - decompressionConstructions, - 0, - "an invalid expanded limit must fail before decompressor setup", + await assert.rejects( + unpackNpmTarball(tarball, undefined, 0), + /positive safe integer/, ); const expandedLength = gunzipSync(new Uint8Array(tarball)).byteLength; From bc1f82fe67574d053f9d996c994ba172dd00e3c2 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:08:57 +0900 Subject: [PATCH 19/34] test: align campaign regression contracts --- ...e_sandbox_require_matches_node_export_target_decisions.ts | 4 ++++ ...t_create_sandbox_require_normalizes_url_export_targets.ts | 4 ++++ ...ate_sandbox_require_preserves_array_blocking_semantics.ts | 4 ++++ ...t_create_sandbox_require_preserves_fallback_resolution.ts | 5 +++++ ...t_create_sandbox_require_rejects_export_target_escapes.ts | 4 ++++ ...ndbox_require_resolves_aliased_package_self_references.ts | 4 ++++ ...dbox_require_stops_self_reference_at_the_nearest_scope.ts | 3 +++ ...t_npm_registry_bounds_compressed_and_expanded_archives.ts | 2 +- .../test_bun_runtime_passes_through_unchanged_source.ts | 3 +++ 9 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts b/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts index 5c6a9d968..cf52e5a68 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_matches_node_export_target_decisions.ts @@ -9,6 +9,10 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa * null, and unresolved members, but a valid selected target does not fall * through merely because its file is missing. Conditional objects continue * after an active nested branch resolves to no target. + * + * 1. Exercise missing, invalid, nested-condition, mixed-map, and numeric cases. + * 2. Require each package through the same public sandbox entry point. + * 3. Assert selection fallback occurs only where Node permits it. */ export const test_create_sandbox_require_matches_node_export_target_decisions = () => { diff --git a/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts b/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts index aeb567131..cdb17faf7 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_normalizes_url_export_targets.ts @@ -5,6 +5,10 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa /** * Verifies exports targets use URL pathname semantics before pack lookup. * + * Raw target strings are URLs, not in-memory map keys. Looking them up without + * normalization rejects valid packages or treats query and fragment text as a + * filename. + * * 1. Declare encoded characters, double encoding, query/hash suffixes, * backslashes, and repeated slashes that Node resolves inside the package. * 2. Assert each resolves to the normalized pack key without weakening the diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts index b0e1bb737..4d3e7adc7 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts @@ -5,6 +5,10 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa /** * Verifies exports arrays preserve Node's final null/invalid decision. * + * Flattening array candidates loses whether the last meaningful result blocked + * a subpath or merely failed to resolve, which can expose an outer fallback + * that the package explicitly denied. + * * 1. Put empty, null-only, invalid-then-null, and null-then-valid arrays under the * active `require` condition with an outer default. * 2. Assert blocked arrays cannot reach the default while the later valid target diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts index 771e9985c..bf9be3c41 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts @@ -10,6 +10,11 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa * * This is the boundary guard for the root-exports fix: it exercises every * resolution branch other than the root-exports one in a single sandbox pack. + * + * 1. Mount legacy, exact, pattern, array, condition, scoped, relative, and JSON + * package shapes. + * 2. Require every available entry and the blocked negative twin. + * 3. Assert each shared resolver branch retains its prior observable result. */ export const test_create_sandbox_require_preserves_fallback_resolution = () => { const require = createSandboxRequire( diff --git a/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts b/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts index 64b6404c9..f80ba9a2f 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_rejects_export_target_escapes.ts @@ -8,6 +8,10 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa * Validation runs after wildcard substitution and percent decoding. Request * keys remain lookup keys, however: an exact key containing `..` is not itself * a target escape when it maps to a safe file. + * + * 1. Mount direct, encoded, `node_modules`, separator, and wildcard escapes. + * 2. Resolve a safe exact request key containing `..` as the negative twin. + * 3. Assert only the safe target remains inside the package mount. */ export const test_create_sandbox_require_rejects_export_target_escapes = () => { const require = createSandboxRequire( diff --git a/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts b/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts index 0991e010d..72e207c41 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts @@ -9,6 +9,10 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa * Npm aliases mount a package under the requested alias while preserving its * real `package.json#name`. Node allows modules inside a package that declares * exports to require that real name through the package's own exports map. + * + * 1. Mount an exported package under an alias and require its real-name subpath. + * 2. Mount a legacy alias without exports as the adjacent negative case. + * 3. Assert only the exports-owning package gains self-reference behavior. */ export const test_create_sandbox_require_resolves_aliased_package_self_references = () => { diff --git a/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts b/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts index 9c86e13a5..67c5e3b83 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_stops_self_reference_at_the_nearest_scope.ts @@ -5,6 +5,9 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa /** * Verifies alias self-reference cannot cross a nearer package scope. * + * Node assigns a source file to its nearest package manifest. Continuing past + * that scope can execute a different package version mounted farther out. + * * 1. Nest a differently named package inside an aliased package that declares * exports, and require the outer real name from the nested entry. * 2. Assert the nearest manifest blocks the outer self-reference; also verify diff --git a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts index a637ff797..5997197ed 100644 --- a/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts +++ b/tests/test-playground/src/features/test_npm_registry_bounds_compressed_and_expanded_archives.ts @@ -116,7 +116,7 @@ export const test_npm_registry_bounds_compressed_and_expanded_archives = ); } await assert.rejects( - unpackNpmTarball(tarball, undefined, 0), + unpackNpmTarball(new Uint8Array([0, 1, 2, 3]).buffer, undefined, 0), /positive safe integer/, ); diff --git a/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts b/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts index f5d18728f..bb954ae96 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_runtime_passes_through_unchanged_source.ts @@ -3,6 +3,9 @@ import { assertBunRuntimePassesThroughUnchangedSource } from "../../internal/ada /** * Verifies Bun runtime receives an object for unchanged TypeScript. * + * Unlike `Bun.build`, Bun's runtime loader rejects `undefined`; a no-op ttsc + * transform must return the original source instead of crashing module load. + * * 1. Register the adapter against a runtime-shaped builder without `onStart`. * 2. Disable transforms and invoke its captured `onLoad` for a source file. * 3. Assert the original contents and loader are returned instead of undefined. From 4bb44b952393d130ff9aaab3e3021755597adf85 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:15:49 +0900 Subject: [PATCH 20/34] refactor(playground): require validated archive limits --- .../src/npm/internal/npmRegistry.ts | 25 +++++++++++-------- ...uire_preserves_array_blocking_semantics.ts | 5 ++-- ...x_require_preserves_fallback_resolution.ts | 10 +++----- ...esolves_aliased_package_self_references.ts | 3 +-- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index da9a2cbe0..7b74feea2 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -59,6 +59,11 @@ export type FetchLike = ( init?: RequestInit, ) => Promise; +declare const VALIDATED_NPM_BYTE_LIMIT: unique symbol; +type ValidatedNpmByteLimit = number & { + readonly [VALIDATED_NPM_BYTE_LIMIT]: true; +}; + const TEXT_FILE_REGEXP = /(^package\.json$|\.([cm]?js|jsx|[cm]?ts|tsx|json)$|\.d\.[cm]?ts$)/i; export const DECLARATION_FILE_REGEXP = /\.d\.[cm]?ts$/i; @@ -144,7 +149,7 @@ export async function downloadTarball( maxBytes = 16 * 1024 * 1024, ): Promise { throwIfAborted(signal); - validateNpmByteLimit(maxBytes, "compressed"); + const byteLimit = validateNpmByteLimit(maxBytes, "compressed"); const response = await fetchImpl(tarball, { signal }); if (!response.ok) { void response.body?.cancel().catch(() => undefined); @@ -153,16 +158,16 @@ export async function downloadTarball( const declaredLength = response.headers.get("content-length"); if (declaredLength !== null) { const parsed = Number(declaredLength); - if (Number.isFinite(parsed) && parsed >= 0 && parsed > maxBytes) { + if (Number.isFinite(parsed) && parsed >= 0 && parsed > byteLimit) { void response.body?.cancel().catch(() => undefined); throw new Error( - `tarball exceeds the ${formatByteLimit(maxBytes)} compressed byte limit.`, + `tarball exceeds the ${formatByteLimit(byteLimit)} compressed byte limit.`, ); } } return collectBoundedStream( response.body, - maxBytes, + byteLimit, "compressed", signal, () => response.arrayBuffer(), @@ -216,8 +221,8 @@ export async function unpackNpmTarball( maxBytes = 64 * 1024 * 1024, ): Promise { throwIfAborted(signal); - validateNpmByteLimit(maxBytes, "expanded"); - const tar = await gunzip(tgz, maxBytes, signal); + const byteLimit = validateNpmByteLimit(maxBytes, "expanded"); + const tar = await gunzip(tgz, byteLimit, signal); throwIfAborted(signal); const decoder = new TextDecoder(); const files: Record = {}; @@ -297,7 +302,7 @@ export async function unpackNpmTarball( async function gunzip( input: ArrayBuffer, - maxBytes: number, + maxBytes: ValidatedNpmByteLimit, signal: AbortSignal | undefined, ): Promise { if (!("DecompressionStream" in globalThis)) { @@ -572,12 +577,11 @@ function equalBytes(left: Uint8Array, right: Uint8Array): boolean { async function collectBoundedStream( stream: ReadableStream | null, - maxBytes: number, + maxBytes: ValidatedNpmByteLimit, kind: "compressed" | "expanded", signal: AbortSignal | undefined, fallback: () => Promise, ): Promise { - validateNpmByteLimit(maxBytes, kind); if (stream === null) { const bytes = await fallback(); throwIfAborted(signal); @@ -648,10 +652,11 @@ function formatByteLimit(bytes: number): string { export function validateNpmByteLimit( maxBytes: number, kind: "compressed" | "expanded", -): void { +): ValidatedNpmByteLimit { if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { throw new Error(`${kind} byte limit must be a positive safe integer.`); } + return maxBytes as ValidatedNpmByteLimit; } /** diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts index 4d3e7adc7..28b6e6ac8 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_array_blocking_semantics.ts @@ -11,8 +11,9 @@ import { createSandboxRequire } from "../../../../packages/playground/lib/src/sa * * 1. Put empty, null-only, invalid-then-null, and null-then-valid arrays under the * active `require` condition with an outer default. - * 2. Assert blocked arrays cannot reach the default while the later valid target - * remains selectable. + * 2. Exercise an inactive nested key and selected encoded, directory, and + * malformed targets that must fail during loading. + * 3. Assert only invalid selection falls through; blocking and loading do not. */ export const test_create_sandbox_require_preserves_array_blocking_semantics = () => { diff --git a/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts b/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts index bf9be3c41..992102990 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_preserves_fallback_resolution.ts @@ -3,13 +3,11 @@ import assert from "node:assert/strict"; import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; /** - * The root-`exports` change (RA-12 / #670) must not regress the resolver's - * other paths: `main` and `index` fallbacks, exact and wildcard subpath - * exports, scoped package names, relative sibling requires, and JSON modules - * all share `resolvePackageEntry` / `resolveSpecifier` and must keep working. + * Verifies root exports changes preserve every fallback resolver branch. * - * This is the boundary guard for the root-exports fix: it exercises every - * resolution branch other than the root-exports one in a single sandbox pack. + * `main` and `index` fallbacks, exact and wildcard subpath exports, scoped + * names, relative requires, and JSON modules all share the changed package + * entry and specifier owners. * * 1. Mount legacy, exact, pattern, array, condition, scoped, relative, and JSON * package shapes. diff --git a/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts b/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts index 72e207c41..324bea66d 100644 --- a/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts +++ b/tests/test-playground/src/features/test_create_sandbox_require_resolves_aliased_package_self_references.ts @@ -3,8 +3,7 @@ import assert from "node:assert/strict"; import { createSandboxRequire } from "../../../../packages/playground/lib/src/sandbox/createSandboxRequire.js"; /** - * Verifies package self-reference uses manifest identity rather than mount - * name. + * Verifies aliased package self-reference uses manifest identity. * * Npm aliases mount a package under the requested alias while preserving its * real `package.json#name`. Node allows modules inside a package that declares From 505d1d5c5983df0080db4ec0431f2f162707d18b Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:19:50 +0900 Subject: [PATCH 21/34] fix(unplugin): preserve Bun in-memory file ownership --- packages/unplugin/README.md | 2 +- packages/unplugin/src/bun.ts | 44 +++++++++++++-- ...st_bun_adapter_excludes_nul_virtual_ids.ts | 16 ++++++ ...er_yields_to_configured_in_memory_files.ts | 17 ++++++ ...e_leaves_virtual_modules_to_their_owner.ts | 17 ------ .../test-unplugin/src/internal/adapter-bun.ts | 53 ++++++++++++++++--- website/src/content/docs/setup/unplugin.mdx | 2 +- 7 files changed, 123 insertions(+), 28 deletions(-) create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_adapter_excludes_nul_virtual_ids.ts create mode 100644 tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts delete mode 100644 tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts diff --git a/packages/unplugin/README.md b/packages/unplugin/README.md index aec83f2cf..b68b1ddce 100644 --- a/packages/unplugin/README.md +++ b/packages/unplugin/README.md @@ -165,7 +165,7 @@ await Bun.build({ }); ``` -Under `Bun.build`, the adapter yields to the next loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. Bun's runtime `onLoad` contract does not accept an undefined result, so `Bun.plugin()` explicitly passes those files through with their original source. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. The runtime API has no corresponding hook, so one setup is treated as one immutable module-loading session: restart the Bun process after changing source, tsconfig, or plugin inputs. +Under `Bun.build`, the adapter yields to the next loader for declarations, `node_modules`, source that `ttsc` leaves unchanged, and entries supplied through `Bun.build({ files })`. In-memory entries remain with Bun because ttsc transforms filesystem-backed project inputs. Bun's runtime `onLoad` contract does not accept an undefined result, so `Bun.plugin()` explicitly passes excluded and unchanged filesystem files through with their original source. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. The runtime API has no corresponding hook, so one setup is treated as one immutable module-loading session: restart the Bun process after changing source, tsconfig, or plugin inputs. ## Configuration diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index bf66d84d6..228204ea5 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import { beginTtscTransformBuild, @@ -8,6 +9,7 @@ import { transformTtsc, } from "./core/index"; import type { TtscUnpluginOptions } from "./core/options"; +import { pathIdentityKey } from "./core/transform"; /** * Bun receives absolute filesystem paths for ordinary source files, while @@ -81,6 +83,17 @@ function resolveBunOptions( * plugin setup itself starts its one process-scoped module-loading session. */ export interface BunLikeBuild { + /** + * Build configuration exposed unchanged by Bun's bundler plugin builder. + * + * Runtime plugin builders do not supply `files`. Bun's bundler accepts an + * in-memory file map whose values deliberately remain `unknown` here because + * this adapter only needs to preserve ownership, not consume their contents. + */ + config?: { + files?: Readonly>; + root?: string; + }; /** * Register a callback for the start of a bundler build. * @@ -111,9 +124,12 @@ export interface BunLikeBuild { * shared ttsc transform core to Bun's `onLoad` hook directly. It reads each * included file from disk and forwards the content to the transform. Under * `Bun.build`, excluded files and no-op transforms return `undefined` so the - * next loader retains ownership. The runtime `Bun.plugin()` API rejects an - * undefined `onLoad` result, so that path explicitly returns the original - * source and loader instead. + * next loader retains ownership. Entries supplied through `BuildConfig.files` + * also stay with Bun's in-memory loader: they are not filesystem project inputs + * and reading the same path from disk would either fail or silently replace the + * configured contents. The runtime `Bun.plugin()` API rejects an undefined + * `onLoad` result, so that path explicitly returns the original source and + * loader instead. * * The same object works for `Bun.build({ plugins: [ttsc()] })` (bundler) and * for `Bun.plugin(ttsc())` / a `bunfig.toml` preload (runtime) — see @@ -136,6 +152,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { (resolved ??= resolveOptions(resolveBunOptions(options))); const cache = createTtscTransformCache(); const runtime = build.onStart === undefined; + const inMemoryFiles = collectBunInMemoryFiles(build); // Bun.plugin() has no onStart callback, but one setup invocation belongs // to exactly one runtime process and module-loading session. Mark that // session up front so first delivery of every emitted project module is @@ -145,6 +162,9 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { beginTtscTransformBuild(cache); build.onStart?.(() => beginTtscTransformBuild(cache)); build.onLoad({ filter: bunSourceFilePattern }, async (args) => { + if (!runtime && inMemoryFiles.has(pathIdentityKey(args.path))) { + return undefined; + } if (!isTransformTarget(args.path)) { if (!runtime) return undefined; return { @@ -179,3 +199,21 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { function bunLoaderFor(filePath: string): BunLoader { return /x$/i.test(filePath) ? "tsx" : "ts"; } + +/** + * Collect the filesystem identities owned by Bun's `BuildConfig.files` map. + * + * Bun reports an absolute `onLoad` path even when the corresponding map key is + * relative. Relative keys use the configured build root, or the process working + * directory when no root is supplied. + */ +function collectBunInMemoryFiles(build: BunLikeBuild): ReadonlySet { + const files = build.config?.files; + if (files === undefined) return new Set(); + const root = path.resolve(build.config?.root ?? process.cwd()); + return new Set( + Object.keys(files).map((file) => + pathIdentityKey(path.isAbsolute(file) ? file : path.resolve(root, file)), + ), + ); +} diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_excludes_nul_virtual_ids.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_excludes_nul_virtual_ids.ts new file mode 100644 index 000000000..5a1156c35 --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_excludes_nul_virtual_ids.ts @@ -0,0 +1,16 @@ +import { assertBunAdapterExcludesNulVirtualIds } from "../../internal/adapter-bun"; + +/** + * Verifies the Bun adapter filter excludes NUL-prefixed virtual ids. + * + * A NUL id is not a filesystem path. If the broad TypeScript hook accepts it, + * the adapter attempts a disk read before Bun can apply the appropriate + * virtual-module mechanism. + * + * 1. Capture the runtime-shaped Bun loader registration. + * 2. Test its filter against NUL-prefixed and ordinary TypeScript paths. + * 3. Assert the filter accepts only the filesystem-shaped paths. + */ +export const test_bun_adapter_excludes_nul_virtual_ids = async () => { + await assertBunAdapterExcludesNulVirtualIds(); +}; diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts new file mode 100644 index 000000000..c8e62bf81 --- /dev/null +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts @@ -0,0 +1,17 @@ +import { assertBunAdapterYieldsToConfiguredInMemoryFiles } from "../../internal/adapter-bun"; + +/** + * Verifies Bun build files retain their in-memory loader priority. + * + * Bun exposes configured `files` entries as ordinary absolute `file` namespace + * paths. Treating those paths as disk inputs either fails for a virtual entry + * or silently transforms stale disk contents instead of the supplied source. + * + * 1. Configure one relative override and one absolute virtual TypeScript file. + * 2. Invoke the captured bundler loader for both absolute paths. + * 3. Assert the adapter yields both entries to Bun without reading disk. + */ +export const test_bun_adapter_yields_to_configured_in_memory_files = + async () => { + await assertBunAdapterYieldsToConfiguredInMemoryFiles(); + }; diff --git a/tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts b/tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts deleted file mode 100644 index 4beb76fdd..000000000 --- a/tests/test-unplugin/src/features/adapters/test_bun_runtime_leaves_virtual_modules_to_their_owner.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { assertBunRuntimeLeavesVirtualModulesToTheirOwner } from "../../internal/adapter-bun"; - -/** - * Verifies Bun runtime does not claim another plugin's virtual module. - * - * A NUL id is not a filesystem path. If the broad TypeScript hook accepts it, - * the runtime pass-through branch tries to read it from disk and prevents the - * module's owning loader from running. - * - * 1. Capture the runtime-shaped Bun loader registration. - * 2. Test its filter against NUL-prefixed and ordinary TypeScript paths. - * 3. Assert only real filesystem-shaped source ids are claimed. - */ -export const test_bun_runtime_leaves_virtual_modules_to_their_owner = - async () => { - await assertBunRuntimeLeavesVirtualModulesToTheirOwner(); - }; diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index e1f50a43c..daadf146e 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -5,6 +5,10 @@ import path from "node:path"; /** Minimal shape of the options object passed to `onLoad` in the Bun plugin API. */ type BunLoadOptions = { filter: RegExp }; +type BunBuildConfig = { + files?: Readonly>; + root?: string; +}; /** * Minimal shape of a Bun load handler: receives a path and returns transformed @@ -24,16 +28,19 @@ async function captureBunLoader( setup(build: unknown): void; }, mode: "bundler" | "runtime" = "runtime", + config?: BunBuildConfig, ): Promise<{ loader: BunLoader; options: BunLoadOptions; }> { const loaders: { loader: BunLoader; options: BunLoadOptions }[] = []; const build = { + config, onLoad(options: BunLoadOptions, loader: BunLoader) { loaders.push({ loader, options }); }, } as { + config?: BunBuildConfig; onLoad(options: BunLoadOptions, loader: BunLoader): void; onStart?: (callback: () => void | Promise) => void; }; @@ -191,13 +198,13 @@ async function assertBunRuntimePassesThroughUnchangedSource(): Promise { } /** - * Asserts the Bun adapter never claims a NUL-prefixed virtual TypeScript id. + * Asserts the Bun adapter filter excludes NUL-prefixed virtual TypeScript ids. * - * Bun applies loader filters before callbacks. A virtual id that reaches this - * callback would be treated as a filesystem path; rejecting it in the filter - * leaves ownership with the plugin that created the virtual module. + * A virtual id that reaches this callback would be treated as a filesystem + * path. This assertion pins the filter boundary without assuming how another + * Bun runtime plugin schedules or represents its own virtual modules. */ -async function assertBunRuntimeLeavesVirtualModulesToTheirOwner(): Promise { +async function assertBunAdapterExcludesNulVirtualIds(): Promise { const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); const { options } = await captureBunLoader( unpluginBun({ plugins: [] }), @@ -209,6 +216,39 @@ async function assertBunRuntimeLeavesVirtualModulesToTheirOwner(): Promise assert.equal(options.filter.test("C:\\project\\src\\ordinary.tsx"), true); } +/** + * Asserts Bun build files remain owned by Bun's in-memory loader. + * + * `BuildConfig.files` can introduce a path with no disk entry or override an + * existing one. Reading either through the filesystem violates Bun's stated + * priority and can produce an `ENOENT` or transform stale disk contents. + */ +async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise { + const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); + const root = TestUnpluginProject.createProject(); + const main = TestUnpluginProject.mainFile(root); + const relativeMain = path.relative(root, main); + const virtual = path.resolve(root, "virtual.ts"); + const { loader } = await captureBunLoader(unpluginBun(), "bundler", { + files: { + [relativeMain]: "export const memory = true;", + [virtual]: "export const virtual = true;", + }, + root, + }); + + assert.equal( + await loader({ path: main }), + undefined, + "a relative files entry must override the existing disk source", + ); + assert.equal( + await loader({ path: virtual }), + undefined, + "an absolute files entry must not be read from the filesystem", + ); +} + /** * Asserts Bun bundler `onStart` forwards the shared transform build lifecycle. * @@ -305,10 +345,11 @@ async function assertBunRuntimeDoesNotRehashProjectPerModule(): Promise { export { assertBunAdapterClearsCacheOnBuildStart, + assertBunAdapterExcludesNulVirtualIds, assertBunAdapterFallsThroughWhenItDoesNotTransform, assertBunAdapterSurvivesPluginReportedDependencies, assertBunAdapterTransformsSource, - assertBunRuntimeLeavesVirtualModulesToTheirOwner, + assertBunAdapterYieldsToConfiguredInMemoryFiles, assertBunRuntimePassesThroughUnchangedSource, assertBunRuntimeDoesNotRehashProjectPerModule, }; diff --git a/website/src/content/docs/setup/unplugin.mdx b/website/src/content/docs/setup/unplugin.mdx index 8f88ac339..2c12ecdc5 100644 --- a/website/src/content/docs/setup/unplugin.mdx +++ b/website/src/content/docs/setup/unplugin.mdx @@ -161,7 +161,7 @@ await Bun.build({ }); ``` -Under `Bun.build`, the adapter yields to the next loader for declarations, `node_modules`, and source that `ttsc` leaves unchanged. Bun's runtime `onLoad` contract does not accept an undefined result, so `Bun.plugin()` returns those files with their original source and does not chain another overlapping runtime loader. NUL-prefixed virtual modules remain outside the adapter's filter so their creating plugin keeps ownership. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. +Under `Bun.build`, the adapter yields to the next loader for declarations, `node_modules`, source that `ttsc` leaves unchanged, and entries supplied through `Bun.build({ files })`. In-memory entries remain with Bun because ttsc transforms filesystem-backed project inputs. Bun's runtime `onLoad` contract does not accept an undefined result, so `Bun.plugin()` returns excluded and unchanged filesystem files with their original source and does not chain another overlapping runtime loader. NUL-prefixed virtual ids remain outside the adapter's filesystem filter. `Bun.build` clears the project generation through its `onStart` lifecycle on every build. For the Bun **runtime**, `bun run`, `bun test`, or any `bun .ts` with no bundling step, register the transform on Bun's module loader with `@ttsc/unplugin/bun-register`. Add a `bunfig.toml` preload once: From 509988e9d5dab81eb042481be10cff7b97252987 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:26:18 +0900 Subject: [PATCH 22/34] fix(unplugin): match Bun relative file identities --- packages/unplugin/src/bun.ts | 34 ++++++++----------- ...er_yields_to_configured_in_memory_files.ts | 8 ++--- .../test-unplugin/src/internal/adapter-bun.ts | 6 ++-- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index 228204ea5..aa317e5fd 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import path from "node:path"; import { beginTtscTransformBuild, @@ -12,10 +11,10 @@ import type { TtscUnpluginOptions } from "./core/options"; import { pathIdentityKey } from "./core/transform"; /** - * Bun receives absolute filesystem paths for ordinary source files, while - * plugin-created virtual ids may contain a NUL sentinel. A virtual id must stay - * with the plugin that created it: claiming it here and trying to read it from - * disk prevents that plugin's later loader from running. + * Bun normally reports filesystem source paths, while plugin-created virtual + * ids may contain a NUL sentinel. A virtual id must stay with the plugin that + * created it: claiming it here and trying to read it from disk prevents that + * plugin's later loader from running. */ const bunSourceFilePattern = /^[^\x00]*\.[cm]?tsx?$/; @@ -92,7 +91,6 @@ export interface BunLikeBuild { */ config?: { files?: Readonly>; - root?: string; }; /** * Register a callback for the start of a bundler build. @@ -103,11 +101,12 @@ export interface BunLikeBuild { /** * Register a loader callback for files matching `filter`. * - * The callback receives the absolute file path and must return the - * transformed file contents plus the `loader` Bun should apply next. The - * `loader` matters most for the runtime path (`Bun.plugin`), where Bun must - * be told the returned contents are still TypeScript so it keeps transpiling - * them before execution. + * The callback receives the file path and must return the transformed file + * contents plus the `loader` Bun should apply next. Configured in-memory + * files retain relative key spellings; ordinary disk files are normally + * absolute. The `loader` matters most for the runtime path (`Bun.plugin`), + * where Bun must be told the returned contents are still TypeScript so it + * keeps transpiling them before execution. */ onLoad( options: { filter: RegExp }, @@ -203,17 +202,12 @@ function bunLoaderFor(filePath: string): BunLoader { /** * Collect the filesystem identities owned by Bun's `BuildConfig.files` map. * - * Bun reports an absolute `onLoad` path even when the corresponding map key is - * relative. Relative keys use the configured build root, or the process working - * directory when no root is supplied. + * Bun preserves relative `files` keys in the corresponding `onLoad` path. + * Filesystem identity resolves both spellings from the process working + * directory, while absolute keys remain absolute. */ function collectBunInMemoryFiles(build: BunLikeBuild): ReadonlySet { const files = build.config?.files; if (files === undefined) return new Set(); - const root = path.resolve(build.config?.root ?? process.cwd()); - return new Set( - Object.keys(files).map((file) => - pathIdentityKey(path.isAbsolute(file) ? file : path.resolve(root, file)), - ), - ); + return new Set(Object.keys(files).map(pathIdentityKey)); } diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts index c8e62bf81..01733bf1d 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts @@ -3,12 +3,12 @@ import { assertBunAdapterYieldsToConfiguredInMemoryFiles } from "../../internal/ /** * Verifies Bun build files retain their in-memory loader priority. * - * Bun exposes configured `files` entries as ordinary absolute `file` namespace - * paths. Treating those paths as disk inputs either fails for a virtual entry - * or silently transforms stale disk contents instead of the supplied source. + * Bun exposes configured `files` entries as ordinary `file` namespace paths and + * preserves relative key spellings. Treating those paths as disk inputs either + * fails for a virtual entry or silently transforms stale disk contents. * * 1. Configure one relative override and one absolute virtual TypeScript file. - * 2. Invoke the captured bundler loader for both absolute paths. + * 2. Invoke the captured loader with Bun's relative and absolute path spellings. * 3. Assert the adapter yields both entries to Bun without reading disk. */ export const test_bun_adapter_yields_to_configured_in_memory_files = diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index daadf146e..aa4bedaca 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -227,7 +227,7 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); const root = TestUnpluginProject.createProject(); const main = TestUnpluginProject.mainFile(root); - const relativeMain = path.relative(root, main); + const relativeMain = path.relative(process.cwd(), main); const virtual = path.resolve(root, "virtual.ts"); const { loader } = await captureBunLoader(unpluginBun(), "bundler", { files: { @@ -238,9 +238,9 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise }); assert.equal( - await loader({ path: main }), + await loader({ path: relativeMain }), undefined, - "a relative files entry must override the existing disk source", + "a relative files entry must keep its cwd-relative Bun spelling", ); assert.equal( await loader({ path: virtual }), From 477e13cab2978268f570b107dd6d5b088e63c190 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:39:43 +0900 Subject: [PATCH 23/34] Fix Bun file ownership across cwd changes --- packages/metro/README.md | 2 +- packages/unplugin/src/bun.ts | 29 ++++++++--- packages/unplugin/src/core/transform.ts | 5 +- ...er_yields_to_configured_in_memory_files.ts | 10 ++-- ..._project_for_each_first_module_delivery.ts | 4 +- ..._plugin_emits_an_out_of_walk_output_key.ts | 2 +- ...one_compile_across_a_multi_file_project.ts | 11 ++-- ...victs_a_rejected_transform_and_recovers.ts | 4 +- ...tes_inputs_before_first_module_delivery.ts | 6 +-- .../test-unplugin/src/internal/adapter-bun.ts | 27 ++++++---- .../src/internal/transform-external.ts | 2 +- .../src/internal/transform-project-cache.ts | 50 +++++-------------- 12 files changed, 74 insertions(+), 78 deletions(-) diff --git a/packages/metro/README.md b/packages/metro/README.md index 220025fb8..9f4be5cad 100644 --- a/packages/metro/README.md +++ b/packages/metro/README.md @@ -72,7 +72,7 @@ For each TypeScript file Metro asks to transform: 1. `@ttsc/metro` runs the `ttsc` plugin pass (reusing `@ttsc/unplugin`'s transform core) → transformed **TypeScript** source. 2. The transformed source is handed to the upstream Expo/React-Native Babel transformer, which strips types, applies the RN transforms, and returns the Babel AST Metro consumes. -The plugin contract, `tsconfig` discovery, and per-build cache are identical to every other `ttsc` bundler integration. +The plugin contract and `tsconfig` discovery match the Unplugin integrations. Metro's worker has no build-start callback, so its shared transform cache validates the complete project snapshot on every hit instead of using a build-scoped first-delivery shortcut. ## Cache invalidation diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index aa317e5fd..c1a2c89c4 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import { beginTtscTransformBuild, @@ -151,7 +152,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { (resolved ??= resolveOptions(resolveBunOptions(options))); const cache = createTtscTransformCache(); const runtime = build.onStart === undefined; - const inMemoryFiles = collectBunInMemoryFiles(build); + const ownsInMemoryFile = createBunInMemoryFileMatcher(build); // Bun.plugin() has no onStart callback, but one setup invocation belongs // to exactly one runtime process and module-loading session. Mark that // session up front so first delivery of every emitted project module is @@ -161,7 +162,7 @@ export default function bun(options?: TtscBunOptions): BunLikePlugin { beginTtscTransformBuild(cache); build.onStart?.(() => beginTtscTransformBuild(cache)); build.onLoad({ filter: bunSourceFilePattern }, async (args) => { - if (!runtime && inMemoryFiles.has(pathIdentityKey(args.path))) { + if (!runtime && ownsInMemoryFile(args.path)) { return undefined; } if (!isTransformTarget(args.path)) { @@ -200,14 +201,26 @@ function bunLoaderFor(filePath: string): BunLoader { } /** - * Collect the filesystem identities owned by Bun's `BuildConfig.files` map. + * Create a stable ownership matcher for Bun's `BuildConfig.files` map. * * Bun preserves relative `files` keys in the corresponding `onLoad` path. - * Filesystem identity resolves both spellings from the process working - * directory, while absolute keys remain absolute. + * Preserve those spellings directly and resolve normalized variants against the + * setup-time working directory. A later `process.chdir()` must not change which + * paths this build configuration owns. */ -function collectBunInMemoryFiles(build: BunLikeBuild): ReadonlySet { +function createBunInMemoryFileMatcher( + build: BunLikeBuild, +): (file: string) => boolean { const files = build.config?.files; - if (files === undefined) return new Set(); - return new Set(Object.keys(files).map(pathIdentityKey)); + if (files === undefined) return () => false; + const spellings = new Set(Object.keys(files)); + const setupDirectory = process.cwd(); + const identities = new Set( + [...spellings].map((file) => + pathIdentityKey(path.resolve(setupDirectory, file)), + ), + ); + return (file) => + spellings.has(file) || + identities.has(pathIdentityKey(path.resolve(setupDirectory, file))); } diff --git a/packages/unplugin/src/core/transform.ts b/packages/unplugin/src/core/transform.ts index 31d9c3add..6353adaa3 100644 --- a/packages/unplugin/src/core/transform.ts +++ b/packages/unplugin/src/core/transform.ts @@ -193,8 +193,9 @@ export interface TtscTransformHooks { * @param options - Resolved plugin options. * @param aliases - Raw bundler alias configuration (Vite array or webpack * object). - * @param cache - Optional per-build cache; cleared by the caller on - * `buildStart`. + * @param cache - Optional project cache. Callers with a real `buildStart` + * boundary declare it through {@link beginTtscTransformBuild}; other hosts + * retain persistent validation. * @param hooks - Optional adapter callbacks; see {@link TtscTransformHooks}. * Dependency notifications fire on cache hits too; watch registrations are * per build, not per compilation. diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts index 01733bf1d..87c38b175 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts @@ -3,13 +3,13 @@ import { assertBunAdapterYieldsToConfiguredInMemoryFiles } from "../../internal/ /** * Verifies Bun build files retain their in-memory loader priority. * - * Bun exposes configured `files` entries as ordinary `file` namespace paths and - * preserves relative key spellings. Treating those paths as disk inputs either - * fails for a virtual entry or silently transforms stale disk contents. + * Bun exposes configured `files` entries as ordinary `file` namespace paths. + * Relative keys stay relative while separators may be normalized. Treating + * those paths as disk inputs either fails or transforms stale disk contents. * * 1. Configure one relative override and one absolute virtual TypeScript file. - * 2. Invoke the captured loader with Bun's relative and absolute path spellings. - * 3. Assert the adapter yields both entries to Bun without reading disk. + * 2. Change cwd and invoke Bun's normalized relative and absolute spellings. + * 3. Assert the adapter still yields both entries without reading disk. */ export const test_bun_adapter_yields_to_configured_in_memory_files = async () => { diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts index ecb0de4cf..a18678fca 100644 --- a/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery.ts @@ -8,8 +8,8 @@ import { assertFirstModuleDeliveriesDoNotRehashProject } from "../../internal/tr * initial build into N complete project walks. * * 1. Compile a 24-module project and cache its whole-project result. - * 2. Count project reads while requesting every remaining module once. - * 3. Assert no project file was re-read and the native transform ran once. + * 2. Change an unrelated input, then request every remaining module once. + * 3. Assert the build-scoped generation still ran only one transform. */ export const test_transformttsc_avoids_rehashing_the_project_for_each_first_module_delivery = async () => { diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts index 3982bb8b6..9358c32fa 100644 --- a/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts @@ -1,7 +1,7 @@ import { assertCacheHitsDespiteOutOfWalkOutputKey } from "../../internal/transform-project-cache"; /** - * Verifies samchon/ttsc#252: the per-build cache hits even when the transform + * Verifies #252: the shared cache accepts out-of-walk compiler output keys. * output is keyed outside the project's directory walk. * * The store-time hash snapshot once overlaid the compiler's output keys, which diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_caches_one_compile_across_a_multi_file_project.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_caches_one_compile_across_a_multi_file_project.ts index 0d1fb6000..905e9ba8f 100644 --- a/tests/test-unplugin/src/features/transform/test_transformttsc_caches_one_compile_across_a_multi_file_project.ts +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_caches_one_compile_across_a_multi_file_project.ts @@ -1,14 +1,13 @@ import { assertCacheTransformsMultiFileProjectOnce } from "../../internal/transform-project-cache"; /** - * Verifies the per-build cache compiles a multi-file project once (baseline). + * Verifies the shared project cache compiles a multi-file project once. * * The adapter compiles the whole tsconfig project once and serves every other - * module from the per-build cache. This is the happy-path baseline: every - * compiler output key sits inside the project walk, so the cache hits on both - * the old and fixed code. The out-of-walk regression (#252) is pinned by the - * sibling test; this one guards that the ordinary multi-file path keeps - * working. + * module from one shared cache. This is the happy-path baseline: every compiler + * output key sits inside the project walk, so the cache hits on both the old + * and fixed code. The out-of-walk regression (#252) is pinned by the sibling + * test; this one guards that the ordinary multi-file path keeps working. * * 1. Create a six-file project with a counting fixture transform. * 2. Run `transformTtsc` over every module sharing one cache. diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_evicts_a_rejected_transform_and_recovers.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_evicts_a_rejected_transform_and_recovers.ts index 27e6994a8..eedd99ffe 100644 --- a/tests/test-unplugin/src/features/transform/test_transformttsc_evicts_a_rejected_transform_and_recovers.ts +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_evicts_a_rejected_transform_and_recovers.ts @@ -4,8 +4,8 @@ import { assertRejectedTransformIsEvictedAndRecovers } from "../../internal/tran * Verifies a rejected transform generation is evicted so adapters recover * (#672). * - * The per-build cache stores the in-flight transform Promise before it settles - * so concurrent callers share one compile. A rejected generation used to stay + * The shared cache stores the in-flight transform Promise before it settles so + * concurrent callers share one compile. A rejected generation used to stay * cached, making a transient toolchain/host failure permanent for a long-lived * Metro or Turbopack worker: the unchanged module replayed the old rejection * forever. A failed generation must instead be surfaced and removed. diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts index e70cf3207..316cd7988 100644 --- a/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_persistent_cache_validates_inputs_before_first_module_delivery.ts @@ -3,9 +3,9 @@ import { assertPersistentCacheValidatesAnUnservedModule } from "../../internal/t /** * Verifies lifecycle-less caches validate before serving an unrequested module. * - * A Metro, Turbopack, or Bun-runtime cache can outlive a build. A project - * generation may contain a lazy module that was never requested during the old - * build, so "first delivery" alone does not prove its other inputs are fresh. + * A Metro or Turbopack cache can outlive a build. A project generation may + * contain a lazy module that was never requested during the old build, so + * "first delivery" alone does not prove its other inputs are fresh. * * 1. Compile a generation containing an unrequested lazy module. * 2. Change another project input without signaling a build start. diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index aa4bedaca..d38925123 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -228,6 +228,7 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise const root = TestUnpluginProject.createProject(); const main = TestUnpluginProject.mainFile(root); const relativeMain = path.relative(process.cwd(), main); + const reportedRelativeMain = relativeMain.split(path.sep).join("/"); const virtual = path.resolve(root, "virtual.ts"); const { loader } = await captureBunLoader(unpluginBun(), "bundler", { files: { @@ -237,16 +238,22 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise root, }); - assert.equal( - await loader({ path: relativeMain }), - undefined, - "a relative files entry must keep its cwd-relative Bun spelling", - ); - assert.equal( - await loader({ path: virtual }), - undefined, - "an absolute files entry must not be read from the filesystem", - ); + const setupDirectory = process.cwd(); + try { + process.chdir(root); + assert.equal( + await loader({ path: reportedRelativeMain }), + undefined, + "relative ownership must survive separator and cwd changes", + ); + assert.equal( + await loader({ path: virtual }), + undefined, + "an absolute files entry must not be read from the filesystem", + ); + } finally { + process.chdir(setupDirectory); + } } /** diff --git a/tests/test-unplugin/src/internal/transform-external.ts b/tests/test-unplugin/src/internal/transform-external.ts index 9484ba37b..b0cf3f2f2 100644 --- a/tests/test-unplugin/src/internal/transform-external.ts +++ b/tests/test-unplugin/src/internal/transform-external.ts @@ -16,7 +16,7 @@ import { emitGraphPlugins } from "./transform-graph"; * The project-walk snapshot cannot see inputs outside the project root or under * ignored directories, yet the reference graph and the plugin-reported * dependencies prove they feed the transform. Hosts without a per-build cache - * boundary (Metro workers, the Turbopack loader, Bun) keep one cache for the + * boundary (Metro workers and the Turbopack loader) keep one cache for the * process lifetime, so validation itself must re-hash those inputs. */ diff --git a/tests/test-unplugin/src/internal/transform-project-cache.ts b/tests/test-unplugin/src/internal/transform-project-cache.ts index 5a6fee737..9735c85ed 100644 --- a/tests/test-unplugin/src/internal/transform-project-cache.ts +++ b/tests/test-unplugin/src/internal/transform-project-cache.ts @@ -26,8 +26,8 @@ interface ICacheProjectOptions { process.env.TTSC_CACHE_DIR ??= TestProject.tmpdir("ttsc-unplugin-cache-"); /** - * Drive a real per-build transform over every module of a multi-file project - * sharing one cache, then return how many whole-project transforms the fixture + * Drive a real transform over every module of a multi-file project sharing one + * persistent cache, then return how many whole-project transforms the fixture * plugin actually ran plus the per-module results. * * The fixture plugin appends one byte to a run-log file on every invocation, so @@ -61,8 +61,8 @@ async function runProjectBuild(options: ICacheProjectOptions): Promise<{ } /** - * Asserts the per-build cache compiles a multi-file project once and serves - * every other module from cache — the happy-path baseline. + * Asserts the shared project cache compiles a multi-file project once and + * serves every other module from cache — the happy-path baseline. * * Every compiler output key sits inside the project walk, so this holds on both * the old and fixed code; the out-of-walk regression is pinned separately by @@ -92,7 +92,7 @@ async function assertCacheTransformsMultiFileProjectOnce(): Promise { * * 1. Build a multi-file project whose fixture transform emits one * `node_modules/**` output key. - * 2. Run a per-build transform over every module sharing one cache. + * 2. Run a transform over every module sharing one persistent cache. * 3. Assert the plugin ran exactly once (cache hit), not once per module. */ async function assertCacheHitsDespiteOutOfWalkOutputKey(): Promise { @@ -323,41 +323,17 @@ async function assertFirstModuleDeliveriesDoNotRehashProject(): Promise { await transformTtsc(first, sources.get(first)!, options, undefined, cache), ); - const originalReadFileSync = fs.readFileSync; - let projectReads = 0; - fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if ( - typeof file === "string" && - path.resolve(file).startsWith(`${path.resolve(project.root)}${path.sep}`) - ) { - ++projectReads; - } - return (originalReadFileSync as (...params: unknown[]) => unknown)( - file, - ...args, + fs.appendFileSync( + path.join(project.root, "plugin.cjs"), + "\n// changed after the build-scoped generation started\n", + "utf8", + ); + for (const file of modules.slice(1)) { + assert.ok( + await transformTtsc(file, sources.get(file)!, options, undefined, cache), ); - }) as typeof fs.readFileSync; - try { - for (const file of modules.slice(1)) { - assert.ok( - await transformTtsc( - file, - sources.get(file)!, - options, - undefined, - cache, - ), - ); - } - } finally { - fs.readFileSync = originalReadFileSync; } - assert.equal( - projectReads, - 0, - "first module deliveries must not re-hash the project snapshot", - ); assert.equal(fs.readFileSync(project.runLog, "utf8").length, 1); } From 9b58898f4f32adfb76f8f1a95a8cce85d7f3a858 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:40:48 +0900 Subject: [PATCH 24/34] Abort bodyless tarball downloads promptly --- .../src/npm/internal/npmRegistry.ts | 2 +- ...rchive_pipeline_honors_abort_boundaries.ts | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 7b74feea2..8d0f12c04 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -583,7 +583,7 @@ async function collectBoundedStream( fallback: () => Promise, ): Promise { if (stream === null) { - const bytes = await fallback(); + const bytes = await abortable(fallback(), signal); throwIfAborted(signal); if (bytes.byteLength > maxBytes) { throw new Error( diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index 5ddd61e8b..d077a984e 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -13,8 +13,8 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; * Superseded per-keystroke installs have no reusable result, so waiting for * another network chunk or digest only consumes browser-tab resources. * - * 1. Abort while a streamed download is waiting and assert the byte collector - * rejects without waiting for another chunk. + * 1. Abort while streamed and bodyless downloads are waiting and assert both byte + * collectors reject without waiting for their underlying operations. * 2. Abort an in-flight digest, then pass an already aborted signal through * download, verification, and decompression and assert every stage stops. */ @@ -36,6 +36,23 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { downloadController.abort(reason); await assert.rejects(downloading, { name: "AbortError" }); + const bodylessController = new AbortController(); + const bodylessDownload = downloadTarball( + async () => + ({ + arrayBuffer: () => new Promise(() => undefined), + body: null, + headers: new Headers(), + ok: true, + status: 200, + }) as Response, + "https://tar.invalid/bodyless.tgz", + bodylessController.signal, + ); + await Promise.resolve(); + bodylessController.abort(reason); + await assert.rejects(bodylessDownload, { name: "AbortError" }); + const digestController = new AbortController(); const digesting = verifyTarball( new ArrayBuffer(16 * 1024 * 1024), From f8ecec5b065fee263a001533ffbde62dcf0b955a Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:43:51 +0900 Subject: [PATCH 25/34] Avoid starting cancelled tarball fallbacks --- .../src/npm/internal/npmRegistry.ts | 1 + ...rchive_pipeline_honors_abort_boundaries.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 8d0f12c04..09a1230e8 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -583,6 +583,7 @@ async function collectBoundedStream( fallback: () => Promise, ): Promise { if (stream === null) { + throwIfAborted(signal); const bytes = await abortable(fallback(), signal); throwIfAborted(signal); if (bytes.byteLength > maxBytes) { diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index d077a984e..a19a992d3 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -53,6 +53,28 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { bodylessController.abort(reason); await assert.rejects(bodylessDownload, { name: "AbortError" }); + const fetchController = new AbortController(); + let fallbackCallsAfterFetchAbort = 0; + const abortedDuringFetch = downloadTarball( + async () => { + fetchController.abort(reason); + return { + arrayBuffer: async () => { + ++fallbackCallsAfterFetchAbort; + return new ArrayBuffer(0); + }, + body: null, + headers: new Headers(), + ok: true, + status: 200, + } as Response; + }, + "https://tar.invalid/aborted-during-fetch.tgz", + fetchController.signal, + ); + await assert.rejects(abortedDuringFetch, { name: "AbortError" }); + assert.equal(fallbackCallsAfterFetchAbort, 0); + const digestController = new AbortController(); const digesting = verifyTarball( new ArrayBuffer(16 * 1024 * 1024), From deb245f99c9821fc0e1154fc5dfd2035bfe82a87 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:49:29 +0900 Subject: [PATCH 26/34] Match Bun memory paths case-sensitively --- packages/unplugin/src/bun.ts | 26 +++++++++++++++---- ...er_yields_to_configured_in_memory_files.ts | 5 ++-- ..._plugin_emits_an_out_of_walk_output_key.ts | 1 - .../test-unplugin/src/internal/adapter-bun.ts | 22 ++++++++++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index c1a2c89c4..40e37ca32 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -9,7 +9,6 @@ import { transformTtsc, } from "./core/index"; import type { TtscUnpluginOptions } from "./core/options"; -import { pathIdentityKey } from "./core/transform"; /** * Bun normally reports filesystem source paths, while plugin-created virtual @@ -216,11 +215,28 @@ function createBunInMemoryFileMatcher( const spellings = new Set(Object.keys(files)); const setupDirectory = process.cwd(); const identities = new Set( - [...spellings].map((file) => - pathIdentityKey(path.resolve(setupDirectory, file)), - ), + [...spellings].map((file) => bunPathIdentityKey(setupDirectory, file)), ); return (file) => spellings.has(file) || - identities.has(pathIdentityKey(path.resolve(setupDirectory, file))); + identities.has(bunPathIdentityKey(setupDirectory, file)); +} + +/** + * Normalize the path forms Bun equates for its in-memory file map. + * + * Bun normalizes Windows separators, drive-letter case, and dot segments but + * preserves path-component case when matching `BuildConfig.files`. The normal + * filesystem identity key is intentionally broader on Windows and would let a + * differently cased virtual key suppress a real disk transform. + */ +function bunPathIdentityKey(directory: string, file: string): string { + const absolute = path.resolve(directory, file); + if (process.platform !== "win32") return absolute; + return absolute + .replace(/\\/g, "/") + .replace( + /^([a-z]):/i, + (_match, drive: string) => `${drive.toLowerCase()}:`, + ); } diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts index 87c38b175..66d90dcf1 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts @@ -5,11 +5,12 @@ import { assertBunAdapterYieldsToConfiguredInMemoryFiles } from "../../internal/ * * Bun exposes configured `files` entries as ordinary `file` namespace paths. * Relative keys stay relative while separators may be normalized. Treating - * those paths as disk inputs either fails or transforms stale disk contents. + * those paths as disk inputs either fails or transforms stale disk contents, + * while a case-only different key remains distinct even on Windows. * * 1. Configure one relative override and one absolute virtual TypeScript file. * 2. Change cwd and invoke Bun's normalized relative and absolute spellings. - * 3. Assert the adapter still yields both entries without reading disk. + * 3. Assert those entries yield while a case-only different key does not. */ export const test_bun_adapter_yields_to_configured_in_memory_files = async () => { diff --git a/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts b/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts index 9358c32fa..38e9722f8 100644 --- a/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts +++ b/tests/test-unplugin/src/features/transform/test_transformttsc_cache_hits_when_a_plugin_emits_an_out_of_walk_output_key.ts @@ -2,7 +2,6 @@ import { assertCacheHitsDespiteOutOfWalkOutputKey } from "../../internal/transfo /** * Verifies #252: the shared cache accepts out-of-walk compiler output keys. - * output is keyed outside the project's directory walk. * * The store-time hash snapshot once overlaid the compiler's output keys, which * include `node_modules` declarations the per-module validator never re-hashes. diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index d38925123..f2c830556 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -254,6 +254,28 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise } finally { process.chdir(setupDirectory); } + + const caseVariant = relativeMain.replace(/(^|[\\/])src([\\/])/, "$1SRC$2"); + assert.notEqual(caseVariant, relativeMain); + let optionResolutions = 0; + const { loader: caseSensitiveLoader } = await captureBunLoader( + unpluginBun(() => { + ++optionResolutions; + return { plugins: [] }; + }), + "bundler", + { + files: { + [caseVariant]: "export const differentlyCased = true;", + }, + }, + ); + await caseSensitiveLoader({ path: reportedRelativeMain }); + assert.equal( + optionResolutions, + 1, + "a differently cased files key must not suppress a disk transform", + ); } /** From b03aece078d6bed61c3be38cdfa4072b3ca24004 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 05:49:47 +0900 Subject: [PATCH 27/34] Abort stalled npm fetch boundaries --- .../src/npm/internal/npmRegistry.ts | 21 +++++--- ...rchive_pipeline_honors_abort_boundaries.ts | 48 +++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index 09a1230e8..c67ab8fb5 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -81,15 +81,17 @@ export async function fetchNpmMetadata( optional: boolean, signal: AbortSignal | undefined, ): Promise { - const response = await fetchImpl( - `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, - { + throwIfAborted(signal); + const response = await abortable( + fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, { headers: { Accept: "application/vnd.npm.install-v1+json, application/json", }, signal, - }, + }), + signal, ); + throwIfAborted(signal); if (response.status === 404 && optional) { void response.body?.cancel().catch(() => undefined); return null; @@ -100,7 +102,13 @@ export async function fetchNpmMetadata( `npm registry returned ${response.status} while resolving ${packageName}.`, ); } - return (await response.json()) as INpmMetadata; + throwIfAborted(signal); + const metadata = (await abortable( + response.json() as Promise, + signal, + )) as INpmMetadata; + throwIfAborted(signal); + return metadata; } export function selectVersion( @@ -150,7 +158,8 @@ export async function downloadTarball( ): Promise { throwIfAborted(signal); const byteLimit = validateNpmByteLimit(maxBytes, "compressed"); - const response = await fetchImpl(tarball, { signal }); + const response = await abortable(fetchImpl(tarball, { signal }), signal); + throwIfAborted(signal); if (!response.ok) { void response.body?.cancel().catch(() => undefined); throw new Error(`tarball download failed with HTTP ${response.status}.`); diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index a19a992d3..c9dd02b1f 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { downloadTarball, + fetchNpmMetadata, unpackNpmTarball, verifyTarball, } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; @@ -13,15 +14,54 @@ import { createNpmFixtureTarball } from "../internal/npmFixture"; * Superseded per-keystroke installs have no reusable result, so waiting for * another network chunk or digest only consumes browser-tab resources. * - * 1. Abort while streamed and bodyless downloads are waiting and assert both byte - * collectors reject without waiting for their underlying operations. - * 2. Abort an in-flight digest, then pass an already aborted signal through + * 1. Abort stalled metadata fetch, JSON, and tarball fetch operations. + * 2. Abort while streamed and bodyless downloads wait, including fetch handoff. + * 3. Abort an in-flight digest, then pass an already aborted signal through * download, verification, and decompression and assert every stage stops. */ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { const tarball = createNpmFixtureTarball(); - const downloadController = new AbortController(); const reason = new DOMException("fixture aborted", "AbortError"); + + const metadataFetchController = new AbortController(); + const metadataFetch = fetchNpmMetadata( + () => new Promise(() => undefined), + "fixture", + false, + metadataFetchController.signal, + ); + await Promise.resolve(); + metadataFetchController.abort(reason); + await assert.rejects(metadataFetch, { name: "AbortError" }); + + const metadataJsonController = new AbortController(); + const metadataJson = fetchNpmMetadata( + async () => + ({ + body: null, + json: () => new Promise(() => undefined), + ok: true, + status: 200, + }) as Response, + "fixture", + false, + metadataJsonController.signal, + ); + await Promise.resolve(); + metadataJsonController.abort(reason); + await assert.rejects(metadataJson, { name: "AbortError" }); + + const tarballFetchController = new AbortController(); + const tarballFetch = downloadTarball( + () => new Promise(() => undefined), + "https://tar.invalid/fetch.tgz", + tarballFetchController.signal, + ); + await Promise.resolve(); + tarballFetchController.abort(reason); + await assert.rejects(tarballFetch, { name: "AbortError" }); + + const downloadController = new AbortController(); const downloading = downloadTarball( async () => new Response( From 7282649e5b02b704fa5aff79a8977e6aa98a4b8d Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:01:19 +0900 Subject: [PATCH 28/34] Preserve Bun file key spellings --- packages/unplugin/src/bun.ts | 31 +++++++------------ ...er_yields_to_configured_in_memory_files.ts | 5 +-- .../test-unplugin/src/internal/adapter-bun.ts | 30 +++++++++++++++--- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/unplugin/src/bun.ts b/packages/unplugin/src/bun.ts index 40e37ca32..1e56aec51 100644 --- a/packages/unplugin/src/bun.ts +++ b/packages/unplugin/src/bun.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import path from "node:path"; import { beginTtscTransformBuild, @@ -203,37 +202,29 @@ function bunLoaderFor(filePath: string): BunLoader { * Create a stable ownership matcher for Bun's `BuildConfig.files` map. * * Bun preserves relative `files` keys in the corresponding `onLoad` path. - * Preserve those spellings directly and resolve normalized variants against the - * setup-time working directory. A later `process.chdir()` must not change which - * paths this build configuration owns. + * Preserve relative versus absolute spelling and dot segments exactly. Windows + * normalizes separators and drive-letter case, but not component case. No path + * is resolved against cwd, so `process.chdir()` cannot change ownership. */ function createBunInMemoryFileMatcher( build: BunLikeBuild, ): (file: string) => boolean { const files = build.config?.files; if (files === undefined) return () => false; - const spellings = new Set(Object.keys(files)); - const setupDirectory = process.cwd(); - const identities = new Set( - [...spellings].map((file) => bunPathIdentityKey(setupDirectory, file)), - ); - return (file) => - spellings.has(file) || - identities.has(bunPathIdentityKey(setupDirectory, file)); + const identities = new Set(Object.keys(files).map(bunPathIdentityKey)); + return (file) => identities.has(bunPathIdentityKey(file)); } /** * Normalize the path forms Bun equates for its in-memory file map. * - * Bun normalizes Windows separators, drive-letter case, and dot segments but - * preserves path-component case when matching `BuildConfig.files`. The normal - * filesystem identity key is intentionally broader on Windows and would let a - * differently cased virtual key suppress a real disk transform. + * Bun normalizes Windows separators and drive-letter case, but preserves path + * component case, relative versus absolute spelling, and dot segments. A + * filesystem identity key is broader and would suppress real disk transforms. */ -function bunPathIdentityKey(directory: string, file: string): string { - const absolute = path.resolve(directory, file); - if (process.platform !== "win32") return absolute; - return absolute +function bunPathIdentityKey(file: string): string { + if (process.platform !== "win32") return file; + return file .replace(/\\/g, "/") .replace( /^([a-z]):/i, diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts index 66d90dcf1..b9a2b4055 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts @@ -6,11 +6,12 @@ import { assertBunAdapterYieldsToConfiguredInMemoryFiles } from "../../internal/ * Bun exposes configured `files` entries as ordinary `file` namespace paths. * Relative keys stay relative while separators may be normalized. Treating * those paths as disk inputs either fails or transforms stale disk contents, - * while a case-only different key remains distinct even on Windows. + * while case-only, relative/absolute, and dot-segment variants remain + * distinct. * * 1. Configure one relative override and one absolute virtual TypeScript file. * 2. Change cwd and invoke Bun's normalized relative and absolute spellings. - * 3. Assert those entries yield while a case-only different key does not. + * 3. Assert those entries yield while Bun-distinct spellings do not. */ export const test_bun_adapter_yields_to_configured_in_memory_files = async () => { diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index f2c830556..c4f4ec51e 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -227,7 +227,7 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise const unpluginBun = await TestUnpluginRuntime.loadUnpluginAdapter("bun"); const root = TestUnpluginProject.createProject(); const main = TestUnpluginProject.mainFile(root); - const relativeMain = path.relative(process.cwd(), main); + const relativeMain = path.join("src", path.basename(main)); const reportedRelativeMain = relativeMain.split(path.sep).join("/"); const virtual = path.resolve(root, "virtual.ts"); const { loader } = await captureBunLoader(unpluginBun(), "bundler", { @@ -255,8 +255,8 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise process.chdir(setupDirectory); } - const caseVariant = relativeMain.replace(/(^|[\\/])src([\\/])/, "$1SRC$2"); - assert.notEqual(caseVariant, relativeMain); + const caseVariant = main.replace(/(^|[\\/])src([\\/])/, "$1SRC$2"); + assert.notEqual(caseVariant, main); let optionResolutions = 0; const { loader: caseSensitiveLoader } = await captureBunLoader( unpluginBun(() => { @@ -270,12 +270,34 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise }, }, ); - await caseSensitiveLoader({ path: reportedRelativeMain }); + await caseSensitiveLoader({ path: main }); assert.equal( optionResolutions, 1, "a differently cased files key must not suppress a disk transform", ); + + const dotAbsoluteMain = `${path.dirname(main)}${path.sep}..${path.sep}src${path.sep}${path.basename(main)}`; + let spellingOptionResolutions = 0; + const { loader: spellingLoader } = await captureBunLoader( + unpluginBun(() => { + ++spellingOptionResolutions; + return { plugins: [] }; + }), + "bundler", + { + files: { + [dotAbsoluteMain]: "export const dotSpelling = true;", + [relativeMain]: "export const relativeSpelling = true;", + }, + }, + ); + await spellingLoader({ path: main }); + assert.equal( + spellingOptionResolutions, + 1, + "relative and dot-segment files keys must not claim an absolute disk path", + ); } /** From f54a700b2823748b6e547402d2aeffc7ac8fa0c2 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:02:07 +0900 Subject: [PATCH 29/34] Dispose cancelled npm responses --- .../src/npm/internal/npmRegistry.ts | 78 +++++++--- ...rchive_pipeline_honors_abort_boundaries.ts | 135 ++++++++++++++---- 2 files changed, 170 insertions(+), 43 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index c67ab8fb5..b71a22a97 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -82,16 +82,19 @@ export async function fetchNpmMetadata( signal: AbortSignal | undefined, ): Promise { throwIfAborted(signal); - const response = await abortable( - fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, { - headers: { - Accept: "application/vnd.npm.install-v1+json, application/json", - }, - signal, - }), + const response = await fetchWithAbort( + () => + fetchImpl( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, + { + headers: { + Accept: "application/vnd.npm.install-v1+json, application/json", + }, + signal, + }, + ), signal, ); - throwIfAborted(signal); if (response.status === 404 && optional) { void response.body?.cancel().catch(() => undefined); return null; @@ -104,7 +107,7 @@ export async function fetchNpmMetadata( } throwIfAborted(signal); const metadata = (await abortable( - response.json() as Promise, + () => response.json() as Promise, signal, )) as INpmMetadata; throwIfAborted(signal); @@ -158,8 +161,10 @@ export async function downloadTarball( ): Promise { throwIfAborted(signal); const byteLimit = validateNpmByteLimit(maxBytes, "compressed"); - const response = await abortable(fetchImpl(tarball, { signal }), signal); - throwIfAborted(signal); + const response = await fetchWithAbort( + () => fetchImpl(tarball, { signal }), + signal, + ); if (!response.ok) { void response.body?.cancel().catch(() => undefined); throw new Error(`tarball download failed with HTTP ${response.status}.`); @@ -198,7 +203,7 @@ export async function verifyTarball( ); const actual = new Uint8Array( await abortable( - crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz), + () => crypto.subtle.digest(strongest[0]!.webAlgorithm, tgz), signal, ), ); @@ -215,7 +220,7 @@ export async function verifyTarball( throw new Error("tarball shasum is not a valid SHA-1 digest."); } const actual = new Uint8Array( - await abortable(crypto.subtle.digest("SHA-1", tgz), signal), + await abortable(() => crypto.subtle.digest("SHA-1", tgz), signal), ); throwIfAborted(signal); if (!equalBytes(actual, decodeHex(dist.shasum))) { @@ -593,7 +598,7 @@ async function collectBoundedStream( ): Promise { if (stream === null) { throwIfAborted(signal); - const bytes = await abortable(fallback(), signal); + const bytes = await abortable(fallback, signal); throwIfAborted(signal); if (bytes.byteLength > maxBytes) { throw new Error( @@ -674,13 +679,18 @@ export function validateNpmByteLimit( * cancellable. */ function abortable( - task: Promise, + start: () => Promise, signal: AbortSignal | undefined, + disposeLateValue?: (value: T) => void, ): Promise { - if (signal === undefined) return task; throwIfAborted(signal); + const task = start(); + if (signal === undefined) return task; return new Promise((resolve, reject) => { + let aborted = false; const abort = () => { + if (aborted) return; + aborted = true; try { throwIfAborted(signal); } catch (error) { @@ -688,8 +698,38 @@ function abortable( } }; signal.addEventListener("abort", abort, { once: true }); - void task.then(resolve, reject).finally(() => { - signal.removeEventListener("abort", abort); - }); + void task + .then((value) => { + if (signal.aborted) { + try { + disposeLateValue?.(value); + } catch { + // Disposal is best-effort and must not replace the abort reason. + } + abort(); + return; + } + resolve(value); + }, reject) + .finally(() => { + signal.removeEventListener("abort", abort); + }); + if (signal.aborted) abort(); }); } + +/** Fetch one response and cancel any body that loses the abort race. */ +async function fetchWithAbort( + start: () => Promise, + signal: AbortSignal | undefined, +): Promise { + const cancel = (response: Response): void => { + void response.body?.cancel().catch(() => undefined); + }; + const response = await abortable(start, signal, cancel); + if (signal?.aborted) { + cancel(response); + throwIfAborted(signal); + } + return response; +} diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index c9dd02b1f..0b5e6c8f6 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -8,38 +8,60 @@ import { } from "../../../../packages/playground/lib/src/npm/internal/npmRegistry.js"; import { createNpmFixtureTarball } from "../internal/npmFixture"; +function createStartSignal(): { + promise: Promise; + resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + /** * Verifies cancellation is observed across the archive handoff. * * Superseded per-keystroke installs have no reusable result, so waiting for * another network chunk or digest only consumes browser-tab resources. * - * 1. Abort stalled metadata fetch, JSON, and tarball fetch operations. - * 2. Abort while streamed and bodyless downloads wait, including fetch handoff. - * 3. Abort an in-flight digest, then pass an already aborted signal through + * 1. Abort after stalled metadata fetch, JSON, and tarball fetch work starts. + * 2. Abort active streamed and bodyless reads and dispose a late response. + * 3. Reject a fetch after synchronous abort without an unhandled rejection. + * 4. Abort an in-flight digest, then pass an already aborted signal through * download, verification, and decompression and assert every stage stops. */ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { const tarball = createNpmFixtureTarball(); const reason = new DOMException("fixture aborted", "AbortError"); + const rejectsAbort = (task: Promise) => + assert.rejects(task, (error) => error === reason); const metadataFetchController = new AbortController(); + const metadataFetchStarted = createStartSignal(); const metadataFetch = fetchNpmMetadata( - () => new Promise(() => undefined), + () => { + metadataFetchStarted.resolve(); + return new Promise(() => undefined); + }, "fixture", false, metadataFetchController.signal, ); - await Promise.resolve(); + await metadataFetchStarted.promise; metadataFetchController.abort(reason); - await assert.rejects(metadataFetch, { name: "AbortError" }); + await rejectsAbort(metadataFetch); const metadataJsonController = new AbortController(); + const metadataJsonStarted = createStartSignal(); const metadataJson = fetchNpmMetadata( async () => ({ body: null, - json: () => new Promise(() => undefined), + json: () => { + metadataJsonStarted.resolve(); + return new Promise(() => undefined); + }, ok: true, status: 200, }) as Response, @@ -47,40 +69,66 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { false, metadataJsonController.signal, ); - await Promise.resolve(); + await metadataJsonStarted.promise; metadataJsonController.abort(reason); - await assert.rejects(metadataJson, { name: "AbortError" }); + await rejectsAbort(metadataJson); const tarballFetchController = new AbortController(); + const tarballFetchStarted = createStartSignal(); const tarballFetch = downloadTarball( - () => new Promise(() => undefined), + () => { + tarballFetchStarted.resolve(); + return new Promise(() => undefined); + }, "https://tar.invalid/fetch.tgz", tarballFetchController.signal, ); - await Promise.resolve(); + await tarballFetchStarted.promise; tarballFetchController.abort(reason); - await assert.rejects(tarballFetch, { name: "AbortError" }); + await rejectsAbort(tarballFetch); const downloadController = new AbortController(); + const streamReadStarted = createStartSignal(); + let streamCancelCalls = 0; const downloading = downloadTarball( async () => - new Response( - new ReadableStream({ - pull: () => new Promise(() => undefined), - }), - ), + ({ + arrayBuffer: () => new Promise(() => undefined), + body: { + getReader: () => { + streamReadStarted.resolve(); + return { + cancel: async () => { + ++streamCancelCalls; + }, + read: () => + new Promise>( + () => undefined, + ), + }; + }, + }, + headers: new Headers(), + ok: true, + status: 200, + }) as Response, "https://tar.invalid/fixture.tgz", downloadController.signal, ); - await Promise.resolve(); + await streamReadStarted.promise; downloadController.abort(reason); - await assert.rejects(downloading, { name: "AbortError" }); + await rejectsAbort(downloading); + assert.ok(streamCancelCalls >= 1); const bodylessController = new AbortController(); + const bodylessReadStarted = createStartSignal(); const bodylessDownload = downloadTarball( async () => ({ - arrayBuffer: () => new Promise(() => undefined), + arrayBuffer: () => { + bodylessReadStarted.resolve(); + return new Promise(() => undefined); + }, body: null, headers: new Headers(), ok: true, @@ -89,9 +137,32 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { "https://tar.invalid/bodyless.tgz", bodylessController.signal, ); - await Promise.resolve(); + await bodylessReadStarted.promise; bodylessController.abort(reason); - await assert.rejects(bodylessDownload, { name: "AbortError" }); + await rejectsAbort(bodylessDownload); + + const lateResponseController = new AbortController(); + let resolveLateResponse!: (response: Response) => void; + const lateResponsePromise = new Promise((resolve) => { + resolveLateResponse = resolve; + }); + let lateResponseCancelCalls = 0; + const lateResponseDownload = downloadTarball( + () => lateResponsePromise, + "https://tar.invalid/late-response.tgz", + lateResponseController.signal, + ); + lateResponseController.abort(reason); + await rejectsAbort(lateResponseDownload); + resolveLateResponse({ + body: { + cancel: async () => { + ++lateResponseCancelCalls; + }, + }, + } as Response); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(lateResponseCancelCalls, 1); const fetchController = new AbortController(); let fallbackCallsAfterFetchAbort = 0; @@ -112,9 +183,25 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { "https://tar.invalid/aborted-during-fetch.tgz", fetchController.signal, ); - await assert.rejects(abortedDuringFetch, { name: "AbortError" }); + await rejectsAbort(abortedDuringFetch); assert.equal(fallbackCallsAfterFetchAbort, 0); + const rejectedFetchController = new AbortController(); + let rejectFetch!: (error: Error) => void; + const rejectedAfterAbort = downloadTarball( + () => { + rejectedFetchController.abort(reason); + return new Promise((_resolve, reject) => { + rejectFetch = reject; + }); + }, + "https://tar.invalid/rejected-after-abort.tgz", + rejectedFetchController.signal, + ); + await rejectsAbort(rejectedAfterAbort); + rejectFetch(new Error("late fetch failure")); + await new Promise((resolve) => setImmediate(resolve)); + const digestController = new AbortController(); const digesting = verifyTarball( new ArrayBuffer(16 * 1024 * 1024), @@ -124,7 +211,7 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { digestController.signal, ); digestController.abort(reason); - await assert.rejects(digesting, { name: "AbortError" }); + await rejectsAbort(digesting); const stopped = new AbortController(); stopped.abort(reason); From 2c8933bc946e75b7fb68eba6bab645821dd7366e Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:16:56 +0900 Subject: [PATCH 30/34] Preserve npm aborts across response handoffs --- .../src/npm/internal/npmRegistry.ts | 44 ++++++--- ...rchive_pipeline_honors_abort_boundaries.ts | 97 ++++++++++++++++++- 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/packages/playground/src/npm/internal/npmRegistry.ts b/packages/playground/src/npm/internal/npmRegistry.ts index b71a22a97..3cd15f7c0 100644 --- a/packages/playground/src/npm/internal/npmRegistry.ts +++ b/packages/playground/src/npm/internal/npmRegistry.ts @@ -95,12 +95,13 @@ export async function fetchNpmMetadata( ), signal, ); + throwIfResponseAborted(response, signal); if (response.status === 404 && optional) { - void response.body?.cancel().catch(() => undefined); + cancelResponseBody(response); return null; } if (!response.ok) { - void response.body?.cancel().catch(() => undefined); + cancelResponseBody(response); throw new Error( `npm registry returned ${response.status} while resolving ${packageName}.`, ); @@ -165,15 +166,16 @@ export async function downloadTarball( () => fetchImpl(tarball, { signal }), signal, ); + throwIfResponseAborted(response, signal); if (!response.ok) { - void response.body?.cancel().catch(() => undefined); + cancelResponseBody(response); throw new Error(`tarball download failed with HTTP ${response.status}.`); } const declaredLength = response.headers.get("content-length"); if (declaredLength !== null) { const parsed = Number(declaredLength); if (Number.isFinite(parsed) && parsed >= 0 && parsed > byteLimit) { - void response.body?.cancel().catch(() => undefined); + cancelResponseBody(response); throw new Error( `tarball exceeds the ${formatByteLimit(byteLimit)} compressed byte limit.`, ); @@ -684,13 +686,20 @@ function abortable( disposeLateValue?: (value: T) => void, ): Promise { throwIfAborted(signal); - const task = start(); + let task: Promise; + try { + task = start(); + } catch (error) { + throwIfAborted(signal); + throw error; + } if (signal === undefined) return task; return new Promise((resolve, reject) => { let aborted = false; const abort = () => { if (aborted) return; aborted = true; + signal.removeEventListener("abort", abort); try { throwIfAborted(signal); } catch (error) { @@ -718,18 +727,27 @@ function abortable( }); } +/** Cancel an unused response body without obscuring the deciding outcome. */ +function cancelResponseBody(response: Response): void { + void response.body?.cancel().catch(() => undefined); +} + +/** Preserve the abort reason across the response-to-caller handoff. */ +function throwIfResponseAborted( + response: Response, + signal: AbortSignal | undefined, +): void { + if (!signal?.aborted) return; + cancelResponseBody(response); + throwIfAborted(signal); +} + /** Fetch one response and cancel any body that loses the abort race. */ async function fetchWithAbort( start: () => Promise, signal: AbortSignal | undefined, ): Promise { - const cancel = (response: Response): void => { - void response.body?.cancel().catch(() => undefined); - }; - const response = await abortable(start, signal, cancel); - if (signal?.aborted) { - cancel(response); - throwIfAborted(signal); - } + const response = await abortable(start, signal, cancelResponseBody); + throwIfResponseAborted(response, signal); return response; } diff --git a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts index 0b5e6c8f6..8265ccdf6 100644 --- a/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts +++ b/tests/test-playground/src/features/test_npm_archive_pipeline_honors_abort_boundaries.ts @@ -27,8 +27,10 @@ function createStartSignal(): { * * 1. Abort after stalled metadata fetch, JSON, and tarball fetch work starts. * 2. Abort active streamed and bodyless reads and dispose a late response. - * 3. Reject a fetch after synchronous abort without an unhandled rejection. - * 4. Abort an in-flight digest, then pass an already aborted signal through + * 3. Abort at the response handoff and retain the exact reason while disposing + * metadata and tarball bodies. + * 4. Prioritize synchronous aborts and remove listeners from stalled work. + * 5. Abort an in-flight digest, then pass an already aborted signal through * download, verification, and decompression and assert every stage stops. */ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { @@ -164,6 +166,59 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { await new Promise((resolve) => setImmediate(resolve)); assert.equal(lateResponseCancelCalls, 1); + const metadataHandoffController = new AbortController(); + let resolveMetadataHandoff!: (response: Response) => void; + const metadataHandoffPromise = new Promise((resolve) => { + resolveMetadataHandoff = resolve; + }); + let metadataHandoffCancelCalls = 0; + const metadataAtHandoff = fetchNpmMetadata( + () => metadataHandoffPromise, + "optional-fixture", + true, + metadataHandoffController.signal, + ); + void metadataHandoffPromise.then(() => { + queueMicrotask(() => metadataHandoffController.abort(reason)); + }); + resolveMetadataHandoff({ + body: { + cancel: async () => { + ++metadataHandoffCancelCalls; + }, + }, + ok: false, + status: 404, + } as Response); + await rejectsAbort(metadataAtHandoff); + assert.equal(metadataHandoffCancelCalls, 1); + + const tarballHandoffController = new AbortController(); + let resolveTarballHandoff!: (response: Response) => void; + const tarballHandoffPromise = new Promise((resolve) => { + resolveTarballHandoff = resolve; + }); + let tarballHandoffCancelCalls = 0; + const tarballAtHandoff = downloadTarball( + () => tarballHandoffPromise, + "https://tar.invalid/response-handoff.tgz", + tarballHandoffController.signal, + ); + void tarballHandoffPromise.then(() => { + queueMicrotask(() => tarballHandoffController.abort(reason)); + }); + resolveTarballHandoff({ + body: { + cancel: async () => { + ++tarballHandoffCancelCalls; + }, + }, + ok: false, + status: 503, + } as Response); + await rejectsAbort(tarballAtHandoff); + assert.equal(tarballHandoffCancelCalls, 1); + const fetchController = new AbortController(); let fallbackCallsAfterFetchAbort = 0; const abortedDuringFetch = downloadTarball( @@ -202,6 +257,44 @@ export const test_npm_archive_pipeline_honors_abort_boundaries = async () => { rejectFetch(new Error("late fetch failure")); await new Promise((resolve) => setImmediate(resolve)); + const throwingFetchController = new AbortController(); + const synchronousThrowAfterAbort = downloadTarball( + () => { + throwingFetchController.abort(reason); + throw new Error("synchronous fetch failure"); + }, + "https://tar.invalid/synchronous-throw.tgz", + throwingFetchController.signal, + ); + await rejectsAbort(synchronousThrowAfterAbort); + + let stalledSignalAborted = false; + const stalledSignalListeners = new Set(); + const stalledSignal = { + addEventListener: (_type: string, listener: unknown) => { + stalledSignalListeners.add(listener); + }, + get aborted() { + return stalledSignalAborted; + }, + get reason() { + return reason; + }, + removeEventListener: (_type: string, listener: unknown) => { + stalledSignalListeners.delete(listener); + }, + } as unknown as AbortSignal; + const stalledAfterSynchronousAbort = downloadTarball( + () => { + stalledSignalAborted = true; + return new Promise(() => undefined); + }, + "https://tar.invalid/stalled-after-synchronous-abort.tgz", + stalledSignal, + ); + await rejectsAbort(stalledAfterSynchronousAbort); + assert.equal(stalledSignalListeners.size, 0); + const digestController = new AbortController(); const digesting = verifyTarball( new ArrayBuffer(16 * 1024 * 1024), From 8ec5346b44b7b9142c14d272dae86baec778f91e Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:18:10 +0900 Subject: [PATCH 31/34] Complete Bun in-memory path identity coverage --- ...er_yields_to_configured_in_memory_files.ts | 8 ++-- .../test-unplugin/src/internal/adapter-bun.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts index b9a2b4055..11b91fad7 100644 --- a/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts +++ b/tests/test-unplugin/src/features/adapters/test_bun_adapter_yields_to_configured_in_memory_files.ts @@ -6,12 +6,14 @@ import { assertBunAdapterYieldsToConfiguredInMemoryFiles } from "../../internal/ * Bun exposes configured `files` entries as ordinary `file` namespace paths. * Relative keys stay relative while separators may be normalized. Treating * those paths as disk inputs either fails or transforms stale disk contents, - * while case-only, relative/absolute, and dot-segment variants remain - * distinct. + * while path-component-case-only, relative/absolute, and dot-segment variants + * remain distinct. * * 1. Configure one relative override and one absolute virtual TypeScript file. * 2. Change cwd and invoke Bun's normalized relative and absolute spellings. - * 3. Assert those entries yield while Bun-distinct spellings do not. + * 3. Exercise Windows drive-case and separator equivalence without collapsing + * component case, relative/absolute, or dot-segment identity. + * 4. Assert equivalent entries yield while Bun-distinct spellings do not. */ export const test_bun_adapter_yields_to_configured_in_memory_files = async () => { diff --git a/tests/test-unplugin/src/internal/adapter-bun.ts b/tests/test-unplugin/src/internal/adapter-bun.ts index c4f4ec51e..0f5bb1f95 100644 --- a/tests/test-unplugin/src/internal/adapter-bun.ts +++ b/tests/test-unplugin/src/internal/adapter-bun.ts @@ -298,6 +298,44 @@ async function assertBunAdapterYieldsToConfiguredInMemoryFiles(): Promise 1, "relative and dot-segment files keys must not claim an absolute disk path", ); + + if (process.platform === "win32") { + const driveCaseVariant = main.replace( + /^([a-z]):/i, + (_match, drive: string) => + `${drive === drive.toLowerCase() ? drive.toUpperCase() : drive.toLowerCase()}:`, + ); + assert.notEqual(driveCaseVariant, main); + let equivalentOptionResolutions = 0; + const { loader: equivalentLoader } = await captureBunLoader( + unpluginBun(() => { + ++equivalentOptionResolutions; + return { plugins: [] }; + }), + "bundler", + { + files: { + [driveCaseVariant]: "export const driveCase = true;", + [dotAbsoluteMain]: "export const dotted = true;", + }, + }, + ); + assert.equal( + await equivalentLoader({ path: main }), + undefined, + "Windows drive-letter case must preserve in-memory ownership", + ); + assert.equal( + await equivalentLoader({ path: dotAbsoluteMain.replace(/\\/g, "/") }), + undefined, + "separator normalization must preserve an identical dotted spelling", + ); + assert.equal( + equivalentOptionResolutions, + 0, + "Bun-equivalent Windows spellings must not enter the disk transform", + ); + } } /** From 99d3850479c712f17fec5295489baf835ec56edb Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:18:24 +0900 Subject: [PATCH 32/34] Keep integrity mutation strictly typed --- ...est_npm_registry_verifies_the_strongest_integrity_digest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts b/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts index 507f22cfe..dad07a160 100644 --- a/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts +++ b/tests/test-playground/src/features/test_npm_registry_verifies_the_strongest_integrity_digest.ts @@ -41,7 +41,8 @@ export const test_npm_registry_verifies_the_strongest_integrity_digest = ); const changed = tarball.slice(0); - new Uint8Array(changed)[10] ^= 1; + const changedBytes = new Uint8Array(changed); + changedBytes[10] = changedBytes[10]! ^ 1; await assert.rejects( installNpmFixture({ dist: { integrity: `sha512-${sha512}` }, From bed99558dd0f4b0a5dbff483c0c5d5ca3d77e514 Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:50:42 +0900 Subject: [PATCH 33/34] Preserve abort priority at install failures --- .../src/npm/installPlaygroundDependencies.ts | 2 +- ...izes_abort_at_the_install_error_handoff.ts | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts diff --git a/packages/playground/src/npm/installPlaygroundDependencies.ts b/packages/playground/src/npm/installPlaygroundDependencies.ts index 858eb980d..dade5cf58 100644 --- a/packages/playground/src/npm/installPlaygroundDependencies.ts +++ b/packages/playground/src/npm/installPlaygroundDependencies.ts @@ -277,7 +277,7 @@ export async function installPlaygroundDependencies( report("extract", item, `Extracting ${item.name}@${version}`, version); unpacked = await unpackNpmTarball(tgz, options.signal, maxUnpackedBytes); } catch (error) { - if (options.signal?.aborted) throw error; + throwIfAborted(options.signal); const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to install ${item.name}@${version}: ${message}`, { cause: error, diff --git a/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts b/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts new file mode 100644 index 000000000..e3eb2eb46 --- /dev/null +++ b/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; + +import { installPlaygroundDependencies } from "../../../../packages/playground/lib/src/index.js"; + +/** + * Verifies cancellation wins at the public install error handoff. + * + * An archive stage can reject just before its caller resumes. If the active + * solve is cancelled in that gap, returning the older stage error hides why the + * install was superseded and can publish the wrong state transition. + * + * 1. Resolve one package to a controlled successful tarball response. + * 2. Queue cancellation while response-header access throws a stage error. + * 3. Assert the install rejects with the exact signal reason. + */ +export const test_npm_registry_prioritizes_abort_at_the_install_error_handoff = + async () => { + const controller = new AbortController(); + const reason = new DOMException("fixture aborted", "AbortError"); + const tarball = "https://tar.invalid/fixture.tgz"; + + const installing = installPlaygroundDependencies(["fixture"], { + signal: controller.signal, + fetch: async (url) => { + if (url.startsWith("https://registry.npmjs.org/")) { + return Response.json({ + name: "fixture", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "fixture", + version: "1.0.0", + dist: { tarball }, + }, + }, + }); + } + return { + body: null, + headers: { + get() { + queueMicrotask(() => controller.abort(reason)); + throw new Error("fixture response header failure"); + }, + }, + ok: true, + status: 200, + } as unknown as Response; + }, + }); + + await assert.rejects(installing, (error) => error === reason); + }; From 62c026436c079be0c77812c8a78e369c77688caa Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Fri, 24 Jul 2026 06:55:37 +0900 Subject: [PATCH 34/34] Fence metadata errors behind cancellation --- .../src/npm/installPlaygroundDependencies.ts | 18 +++++--- ...izes_abort_at_the_install_error_handoff.ts | 43 +++++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/playground/src/npm/installPlaygroundDependencies.ts b/packages/playground/src/npm/installPlaygroundDependencies.ts index dade5cf58..b6dd21df6 100644 --- a/packages/playground/src/npm/installPlaygroundDependencies.ts +++ b/packages/playground/src/npm/installPlaygroundDependencies.ts @@ -199,12 +199,18 @@ export async function installPlaygroundDependencies( throwIfAborted(options.signal); report("resolve", item, `Resolving ${item.name}`); - const metadata = await fetchNpmMetadata( - fetchImpl, - item.registryName ?? item.name, - item.optional, - options.signal, - ); + let metadata: Awaited>; + try { + metadata = await fetchNpmMetadata( + fetchImpl, + item.registryName ?? item.name, + item.optional, + options.signal, + ); + } catch (error) { + throwIfAborted(options.signal); + throw error; + } throwIfAborted(options.signal); if (!metadata) { const mounted = installedDependencies.get(item.name); diff --git a/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts b/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts index e3eb2eb46..130405b0b 100644 --- a/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts +++ b/tests/test-playground/src/features/test_npm_registry_prioritizes_abort_at_the_install_error_handoff.ts @@ -9,12 +9,49 @@ import { installPlaygroundDependencies } from "../../../../packages/playground/l * solve is cancelled in that gap, returning the older stage error hides why the * install was superseded and can publish the wrong state transition. * - * 1. Resolve one package to a controlled successful tarball response. - * 2. Queue cancellation while response-header access throws a stage error. - * 3. Assert the install rejects with the exact signal reason. + * 1. Control metadata JSON rejection and a successful tarball response. + * 2. Queue cancellation across each stage-error handoff in turn. + * 3. Assert both installs reject with their exact signal reason. */ export const test_npm_registry_prioritizes_abort_at_the_install_error_handoff = async () => { + const metadataController = new AbortController(); + const metadataReason = new DOMException( + "metadata fixture aborted", + "AbortError", + ); + let rejectMetadata!: (error: Error) => void; + const metadataJson = new Promise((_resolve, reject) => { + rejectMetadata = reject; + }); + let metadataJsonStarted!: () => void; + const metadataStarted = new Promise((resolve) => { + metadataJsonStarted = resolve; + }); + const metadataResponse = new Response(null); + Object.defineProperty(metadataResponse, "json", { + value: () => { + metadataJsonStarted(); + return metadataJson; + }, + }); + const metadataInstalling = installPlaygroundDependencies( + ["metadata-fixture"], + { + signal: metadataController.signal, + fetch: async () => metadataResponse, + }, + ); + await metadataStarted; + void metadataJson.catch(() => { + queueMicrotask(() => metadataController.abort(metadataReason)); + }); + rejectMetadata(new Error("metadata JSON failed")); + await assert.rejects( + metadataInstalling, + (error) => error === metadataReason, + ); + const controller = new AbortController(); const reason = new DOMException("fixture aborted", "AbortError"); const tarball = "https://tar.invalid/fixture.tgz";