Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fc09401
chore(campaign): claim unplugin and playground fixes
samchon Jul 23, 2026
d404d5f
fix(unplugin): track linked compiler inputs
samchon Jul 23, 2026
55124be
fix(unplugin): avoid repeated project validation
samchon Jul 23, 2026
90a3749
fix(unplugin): honor host cache lifecycles
samchon Jul 23, 2026
66a0a4b
fix(playground): align sandbox package exports
samchon Jul 23, 2026
65c5f0e
fix(unplugin): distinguish runtime cache sessions
samchon Jul 23, 2026
d89ef2f
fix(playground): complete package resolution boundaries
samchon Jul 23, 2026
24b064e
test(unplugin): pin runtime cache contracts
samchon Jul 23, 2026
3afc77a
fix(playground): secure npm archive installs
samchon Jul 23, 2026
7146bd0
fix(playground): separate package selection from loading
samchon Jul 23, 2026
9b83bf6
fix(playground): preserve bounded archive compatibility
samchon Jul 23, 2026
67c2767
fix(unplugin): satisfy Bun runtime loader contracts
samchon Jul 23, 2026
51bce0c
fix(playground): match legacy main directory resolution
samchon Jul 23, 2026
4a8a97d
fix(playground): close archive cancellation gaps
samchon Jul 23, 2026
6c220b6
fix(unplugin): leave Bun virtual modules unclaimed
samchon Jul 23, 2026
1c217ba
fix(playground): validate archive budgets up front
samchon Jul 23, 2026
a6f91a9
fix(playground): release rejected metadata responses
samchon Jul 23, 2026
8536c08
test(playground): preserve archive cancellation priority
samchon Jul 23, 2026
bc1f82f
test: align campaign regression contracts
samchon Jul 23, 2026
4bb44b9
refactor(playground): require validated archive limits
samchon Jul 23, 2026
505d1d5
fix(unplugin): preserve Bun in-memory file ownership
samchon Jul 23, 2026
509988e
fix(unplugin): match Bun relative file identities
samchon Jul 23, 2026
477e13c
Fix Bun file ownership across cwd changes
samchon Jul 23, 2026
9b58898
Abort bodyless tarball downloads promptly
samchon Jul 23, 2026
f8ecec5
Avoid starting cancelled tarball fallbacks
samchon Jul 23, 2026
deb245f
Match Bun memory paths case-sensitively
samchon Jul 23, 2026
b03aece
Abort stalled npm fetch boundaries
samchon Jul 23, 2026
7282649
Preserve Bun file key spellings
samchon Jul 23, 2026
f54a700
Dispose cancelled npm responses
samchon Jul 23, 2026
2c8933b
Preserve npm aborts across response handoffs
samchon Jul 23, 2026
8ec5346
Complete Bun in-memory path identity coverage
samchon Jul 23, 2026
99d3850
Keep integrity mutation strictly typed
samchon Jul 23, 2026
bed9955
Preserve abort priority at install failures
samchon Jul 23, 2026
62c0264
Fence metadata errors behind cancellation
samchon Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/metro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 5 additions & 5 deletions packages/metro/src/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/unplugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 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 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

Expand Down
90 changes: 47 additions & 43 deletions packages/unplugin/src/bun.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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
Expand All @@ -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 {},
};

Expand All @@ -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>): void;
/**
* Register a loader callback for files matching `filter`.
*
Expand All @@ -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
Expand All @@ -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<typeof unplugin.raw> | 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<typeof resolveOptions> | 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;
});
},
};
Expand Down
6 changes: 4 additions & 2 deletions packages/unplugin/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createUnplugin } from "unplugin";
import type { TtscUnpluginOptions } from "./options";
import { resolveOptions } from "./options";
import {
beginTtscTransformBuild,
collectExternalInputHashes,
collectProjectInputHashes,
createTtscTransformCache,
Expand Down Expand Up @@ -81,7 +82,7 @@ const unpluginFactory: UnpluginFactory<
},

buildStart() {
transformCache.clear();
beginTtscTransformBuild(transformCache);
},

transformInclude(id) {
Expand Down Expand Up @@ -142,6 +143,7 @@ export type {
} from "./options";
export type { TtscTransformHooks } from "./transform";
export {
beginTtscTransformBuild,
collectExternalInputHashes,
collectProjectInputHashes,
createTtscTransformCache,
Expand All @@ -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) &&
Expand Down
Loading
Loading