Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 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
4 changes: 2 additions & 2 deletions packages/metro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ 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

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
8 changes: 8 additions & 0 deletions packages/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -148,6 +150,12 @@ 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 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.

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.
Expand Down
40 changes: 30 additions & 10 deletions packages/playground/src/npm/installPlaygroundDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import {
throwIfAborted,
toTypesPackageName,
unpackNpmTarball,
validateNpmByteLimit,
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
Expand Down Expand Up @@ -72,6 +76,11 @@ 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;
validateNpmByteLimit(maxTarballBytes, "compressed");
validateNpmByteLimit(maxUnpackedBytes, "expanded");
const queue: IQueueItem[] = [];
const queued = new Map<string, IQueueItem>();
const done = new Map<string, string>();
Expand Down Expand Up @@ -250,19 +259,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<ReturnType<typeof unpackNpmTarball>>;
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,
Expand Down
Loading
Loading