Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions docs/src/app/dependencies/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ What happened, step by step:

Your own code — the `.action` callback body, the functions it calls — still compiles statically. The engine runs only what must be dynamic, and [`scriptc coverage --dynamic`](/coverage) shows the exact split, including which Node builtins the embedded packages import and whether each is shimmed.

Workspace packages whose executable entry is TypeScript compile automatically as part of your program, including package-internal `.js` import spellings that resolve to `.ts` source. Workspace packages that ship executable JavaScript keep the normal dependency policy: use `--dynamic` for the island or opt them into `--npm-static`.

## What the island is

- **It's quickjs-ng, not V8.** Embedded dependency code runs correctly but slower than under Node for CPU-bound work. The win is startup, size, memory, and deployment shape — not raw dependency throughput.
Expand Down
8 changes: 8 additions & 0 deletions packages/compiler/src/frontend/lowering/lower-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@ export interface FileParts {
// island runtime implementation; the preflight/import-use SC1010
// fences are the whole story.
if (lowerer.externalTypes.has(spec)) continue;
// A source-only workspace package is part of the program module
// graph: resolveImport follows its node_modules link to the loaded
// TypeScript entry. Its internal `.js` spellings have already been
// resolved to `.ts` by that graph, and no source may be handed to
// the JavaScript island.
if (!isRelativeSpecifier(spec) && resolveImport(lowerer.program, fp.sf, spec) !== null) {
continue;
}
const npm = resolveNpmImport(fp.sf.fileName, spec);
// --npm-static: an opted-in package that made it through preflight
// is a PROGRAM-MODULE dependency — its entry sits in the module
Expand Down
5 changes: 3 additions & 2 deletions packages/compiler/src/frontend/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2157,7 +2157,8 @@ function preflight7(load: LoadResult): {
diags.push(externalHostModuleDiag7(fromSpec, stmt));
continue;
}
if (!isRelativeSpecifier(fromSpec)) {
const projectReexport = resolveImport7(program, sf, fromSpec);
if (!isRelativeSpecifier(fromSpec) && projectReexport === null) {
// NAMED re-exports from a SUPPORTED builtin pass (`export { ok }
// from "node:assert"` — a universal re-export facade facade): the
// statement binds nothing locally and evaluates nothing (builtins
Expand Down Expand Up @@ -2194,7 +2195,7 @@ function preflight7(load: LoadResult): {
diags.push(unsupportedDiag("SC1014", locOf7(stmt), "re-exports from packages or builtin modules"));
continue;
}
const reDep = resolveImport7(program, sf, fromSpec);
const reDep = projectReexport;
// `export * as ns from "./m"` re-exports the module NAMESPACE
// object under a name: importers' `x.ns.member` reads resolve
// statically through the same alias machinery as `import * as ns`
Expand Down
24 changes: 23 additions & 1 deletion packages/compiler/src/frontend/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { realpathSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { clearResolveCaches, projectDtsRuntimeSibling, resolveProjectModule, setProjectPathMappings, setProjectRealm } from "./resolve.js";
import { clearResolveCaches, projectDtsRuntimeSibling, resolveProjectModule, resolveWorkspaceSourceModule, setProjectPathMappings, setProjectRealm } from "./resolve.js";

const fixturesRoot = join(import.meta.dirname, "../../../..", "tests/fixtures");

test("resolver reset clears the active project package realm", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-resolve-reset-"));
Expand Down Expand Up @@ -31,3 +34,22 @@ test("resolver reset clears the active project package realm", async () => {
await rm(dir, { recursive: true, force: true });
}
});

test("source-only workspace packages are project modules while JavaScript packages stay external", () => {
const sourceEntry = join(fixturesRoot, "workspace-source/main.ts");
const sourceDir = realpathSync(join(fixturesRoot, "workspace-source/node_modules/wssource"));
const sourceModule = join(sourceDir, "index.ts");
const sourceMember = join(sourceDir, "describe.ts");
const jsEntry = join(fixturesRoot, "npm/cases/workspace-linked/main.ts");

clearResolveCaches();
try {
expect(resolveWorkspaceSourceModule(sourceEntry, "wssource")?.typesFile).toBe(sourceModule);
expect(resolveProjectModule(sourceEntry, "wssource")).toBe(sourceModule);
expect(resolveProjectModule(sourceModule, "./describe.js")).toBe(sourceMember);
expect(resolveWorkspaceSourceModule(jsEntry, "wslinked")).toBeNull();
expect(resolveProjectModule(jsEntry, "wslinked")).toBeNull();
} finally {
clearResolveCaches();
}
});
122 changes: 99 additions & 23 deletions packages/compiler/src/frontend/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ const EXPORT_CONDITIONS = new Set(["types", "import", "default"]);
* package.json the tsgo host serves for the same package. */
const JS_ONLY_CONDITIONS = new Set(["import", "default"]);

/** Node's ESM runtime conditions. Source-only workspace detection follows
* the executable branch rather than the checker-only "types" branch. */
const RUNTIME_IMPORT_CONDITIONS = new Set(["import", "node", "default"]);

/** package.json "exports" lookup: exact subpath keys, then '*' patterns
* (longest literal prefix wins), condition objects matched against the
* supplied set in object-key order, arrays first-resolvable. Returns the
Expand Down Expand Up @@ -611,14 +615,17 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin

/** The one resolver entry point for source modules that compile into the
* current program. Relative paths, tsconfig aliases, package imports, package
* self-references, and provenance entries all meet here; builtins and ordinary
* npm packages deliberately answer null for their dedicated callers. */
* self-references, provenance entries, and TypeScript-entry workspace
* packages all meet here; builtins and JavaScript npm packages deliberately
* answer null for their dedicated callers. */
export function resolveProjectModule(fromFile: string, specifier: string): string | null {
if (isRelativeSpecifier(specifier) || isAbsolute(specifier)) {
return resolveRelativeModule(fromFile, specifier);
}
if (specifier.startsWith("node:")) return null;
return resolveProjectImport(fromFile, specifier);
return resolveProjectImport(fromFile, specifier) ??
resolveWorkspaceSourceModule(fromFile, specifier)?.typesFile ??
null;
}

/* 5.9.3 with allowJs resolves node_modules in TWO FULL PASSES (probed): the
Expand All @@ -628,7 +635,7 @@ export function resolveProjectModule(fromFile: string, specifier: string): strin
* fails does a second identical walk run admitting the JavaScript files
* themselves. An untyped package with an @types twin therefore answers the
* @types files; an untyped package without one answers its own .js. */
type ResolutionPass = "types" | "js";
type ResolutionPass = "types" | "source" | "js";

function extensionsFor(pass: ResolutionPass, flavor: "plain" | "x" | "m" | "c"): string[] {
if (pass === "types") {
Expand All @@ -639,6 +646,14 @@ function extensionsFor(pass: ResolutionPass, flavor: "plain" | "x" | "m" | "c"):
default: return [".ts", ".tsx", ".d.ts"];
}
}
if (pass === "source") {
switch (flavor) {
case "m": return [".mts"];
case "c": return [".cts"];
case "x": return [".tsx", ".ts"];
default: return [".ts", ".tsx"];
}
}
switch (flavor) {
case "m": return [".mjs"];
case "c": return [".cjs"];
Expand All @@ -650,10 +665,17 @@ function extensionsFor(pass: ResolutionPass, flavor: "plain" | "x" | "m" | "c"):
/** File resolution of an exports-map target (or types/typings/main field)
* inside node_modules, for one pass: recognized-extension substitution,
* extension addition, then directory index. */
function loadTargetInPass(pkgDir: string, target: string, pass: ResolutionPass): string | null {
function loadTargetInPass(
pkgDir: string,
target: string,
pass: ResolutionPass,
runtimeOnly = false,
): string | null {
const path = join(pkgDir, target);
if (pass === "types") {
if (/\.(d\.ts|d\.mts|d\.cts|ts|tsx|mts|cts)$/.test(path) && isFile(path)) return path;
} else if (pass === "source") {
if (/\.(ts|tsx|mts|cts)$/.test(path) && !/\.d\.(ts|mts|cts)$/.test(path) && isFile(path)) return path;
} else if (/\.(js|jsx|mjs|cjs)$/.test(path) && isFile(path)) {
return path;
}
Expand All @@ -678,9 +700,10 @@ function loadTargetInPass(pkgDir: string, target: string, pass: ResolutionPass):
if (isDirectory(path)) {
const nested = pkgJsonOf(path);
if (nested) {
for (const field of [nested.types, nested.typings, nested.main]) {
const fields = runtimeOnly ? [nested.main] : [nested.types, nested.typings, nested.main];
for (const field of fields) {
if (typeof field === "string" && field !== "") {
const via = loadTargetInPass(path, field, pass);
const via = loadTargetInPass(path, field, pass, runtimeOnly);
if (via) return via;
}
}
Expand All @@ -695,24 +718,31 @@ function loadTargetInPass(pkgDir: string, target: string, pass: ResolutionPass):

/** node_modules file-or-directory resolution for a package-internal path (a
* subpath without exports, the root lookup), for one pass. */
function loadPathInPass(pkgDir: string, rel: string, pass: ResolutionPass): string | null {
function loadPathInPass(
pkgDir: string,
rel: string,
pass: ResolutionPass,
runtimeOnly = false,
): string | null {
const base = rel === "." ? pkgDir : join(pkgDir, rel);
if (rel !== ".") {
const viaFile = loadTargetInPass(pkgDir, rel, pass);
const viaFile = loadTargetInPass(pkgDir, rel, pass, runtimeOnly);
if (viaFile) return viaFile;
return null;
}
if (!isDirectory(base)) return null;
const pkg = pkgJsonOf(base);
if (pkg) {
for (const field of [pkg.types, pkg.typings]) {
if (typeof field === "string" && field !== "") {
const viaTypes = loadTargetInPass(base, field, pass);
if (viaTypes) return viaTypes;
if (!runtimeOnly) {
for (const field of [pkg.types, pkg.typings]) {
if (typeof field === "string" && field !== "") {
const viaTypes = loadTargetInPass(base, field, pass, runtimeOnly);
if (viaTypes) return viaTypes;
}
}
}
if (typeof pkg.main === "string" && pkg.main !== "") {
const viaMain = loadTargetInPass(base, pkg.main, pass);
const viaMain = loadTargetInPass(base, pkg.main, pass, runtimeOnly);
if (viaMain) return viaMain;
}
}
Expand Down Expand Up @@ -765,20 +795,27 @@ function packageAnswer(pkgDir: string, fallbackName: string, typesFile: string):
export function resolveBareModule(
fromFile: string,
specifier: string,
/** "js-only" forces the runtime-JS resolution regardless of the active
* --npm-static set (the auto-detection probe); "types-only" forces the
* unshadowed declaration pass for overload-overlay discovery; default
* follows the active set. */
mode?: "js-only" | "types-only",
/** "js-only" forces --npm-static's runtime-JS resolution regardless of
* the active set (the auto-detection probe); "types-only" forces the
* unshadowed declaration pass for overload-overlay discovery;
* "runtime-js" and "runtime-source" probe Node's import-condition entry
* by executable file kind; default follows the active set. */
mode?: "js-only" | "types-only" | "runtime-js" | "runtime-source",
): BareResolution | null {
const pkgName = packageNameOfSpecifier(specifier);
const rest = specifier.slice(pkgName.length).replace(/^\//, "");
const subpath = rest === "" ? "." : `./${rest}`;
// An opted-in --npm-static package resolves to its RUNTIME JS: the js
// pass only, the "types" export condition dropped, the @types mangling
// never consulted — mirroring the shadowed world the tsgo host serves.
const npmStatic = mode === "js-only" || (mode !== "types-only" && isNpmStaticPackage(pkgName));
const conditions = npmStatic ? JS_ONLY_CONDITIONS : EXPORT_CONDITIONS;
const runtimeImport = mode === "runtime-js" || mode === "runtime-source";
const npmStatic = mode === "js-only" || (!runtimeImport && mode !== "types-only" && isNpmStaticPackage(pkgName));
const conditions = runtimeImport
? RUNTIME_IMPORT_CONDITIONS
: npmStatic
? JS_ONLY_CONDITIONS
: EXPORT_CONDITIONS;
const runtimeOnly = runtimeImport || npmStatic;

const inPackage = (nmPkgDir: string, name: string, pass: ResolutionPass): BareResolution | null => {
// A workspace link: the answer's realpath escaped node_modules, so the
Expand Down Expand Up @@ -819,12 +856,12 @@ export function resolveBareModule(
if (pkg?.exports !== undefined) {
const target = resolveExports(pkg.exports, subpath, conditions);
if (target !== null) {
const file = loadTargetInPass(nmPkgDir, target, pass);
const file = loadTargetInPass(nmPkgDir, target, pass, runtimeOnly);
if (file) return withWorkspace(packageAnswer(nmPkgDir, name, file));
}
return null;
}
const file = loadPathInPass(nmPkgDir, subpath === "." ? "." : `./${rest}`, pass);
const file = loadPathInPass(nmPkgDir, subpath === "." ? "." : `./${rest}`, pass, runtimeOnly);
if (!file) return null;
// A SUBPATH answered through a nested package.json (the
// @restart/hooks/useMergedRefs shape): 5.9.3 forms the packageId from
Expand Down Expand Up @@ -862,9 +899,48 @@ export function resolveBareModule(
};

if (mode === "types-only") return passOnce("types");
if (mode === "runtime-source") return passOnce("source");
if (mode === "runtime-js") return passOnce("js");
return npmStatic ? passOnce("js") : (passOnce("types") ?? passOnce("js"));
}

/** A workspace-linked bare package whose executable entry is TypeScript,
* not shipped JavaScript. The type-resolution pass must reach that source
* directly, and the runtime-JS pass must have no answer: a workspace that
* ships dist/index.js beside src/index.ts remains an ordinary npm package
* whose JavaScript executes in the island unless --npm-static opts it in.
*
* Source-only workspace packages are part of the program author's module
* graph instead. The TypeScript program already loaded their source and
* resolved package-internal `.js` spellings to `.ts`; returning that same
* entry from resolveProjectModule lets preflight, module ordering, and
* lowering share the checker-resolved graph without feeding TypeScript to
* the JavaScript island. */
export function resolveWorkspaceSourceModule(
fromFile: string,
specifier: string,
): BareResolution | null {
if (
isRelativeSpecifier(specifier) ||
isAbsolute(specifier) ||
specifier.startsWith("node:") ||
specifier.startsWith("#")
) {
return null;
}
const source = resolveBareModule(fromFile, specifier, "runtime-source");
if (
source?.workspaceDir === undefined ||
!/\.(?:ts|tsx|mts|cts)$/.test(source.typesFile) ||
/\.d\.(?:ts|mts|cts)$/.test(source.typesFile)
) {
return null;
}
return resolveBareModule(fromFile, specifier, "runtime-js") === null
? source
: null;
}

/** Resolves a `/// <reference types="name" />`-style TYPE DIRECTIVE the way
* ts.resolveTypeReferenceDirective does with typeRoots emptied (the
* secondary lookup only): walking up from the anchor file, per directory
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/npm/workspace/wssource/describe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface NamedValue {
value: string;
}

export function describe(input: NamedValue): string {
return `wssource:${input.value}:${input.value.length}`;
}
2 changes: 2 additions & 0 deletions tests/fixtures/npm/workspace/wssource/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { describe } from "./describe.js";
export type { NamedValue } from "./describe.js";
7 changes: 7 additions & 0 deletions tests/fixtures/npm/workspace/wssource/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "wssource",
"version": "1.0.0",
"type": "module",
"main": "./index.ts",
"types": "./index.ts"
}
3 changes: 3 additions & 0 deletions tests/fixtures/workspace-source/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { describe } from "wssource";

console.log(describe({ value: "linked" }));
1 change: 1 addition & 0 deletions tests/fixtures/workspace-source/node_modules/wssource

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions tests/harness/workspace-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { execFile } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { compile } from "@scriptc/compiler";

const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const entry = join(repoRoot, "tests/fixtures/workspace-source/main.ts");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests/workspace-source");
const sanitize = process.env["SCRIPTC_SAN"] === "1";

test.for(["c", "llvm"] as const)(
"a TypeScript-source workspace package compiles as program modules with the %s backend",
async (backend) => {
const outDir = join(cacheDir, `${backend}-${sanitize ? "san" : "plain"}`);
mkdirSync(outDir, { recursive: true });
const result = await compile(entry, {
outPath: join(outDir, "program"),
outDir,
backend,
sanitize,
});
if (!result.ok) {
throw new Error(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`).join("\n"));
}

const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" });
expect(stdout).toBe("wssource:linked:6\n");
},
120_000,
);
Loading