Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andGabriele Magno ab3cf9d69a fix(plugins): compile dependencies that import Node ambient types
Carry the contributor fix onto the current compiler worker layout. Declaration-only Node imports are host types, so plugin installation must not resolve them as packages or walk builtins newer than the daemon runtime.

Co-authored-by: Gabriele Magno <36011800+nkwib@users.noreply.github.com>
2026-11-03 01:29:03 +00:00
Gabriele Magno ae6910cadb Merge branch 'main' into fix/plugin-type-walk-node-builtins 2026-10-19 14:07:47 +02:00
Gabriele Magno a9a140f6ea Merge branch 'main' into fix/plugin-type-walk-node-builtins 2026-10-17 19:05:08 +02:00
Gabriele Magno 555191f95d Merge branch 'main' into fix/plugin-type-walk-node-builtins 2026-10-17 10:49:30 +02:00
Gabriele Magno b86cbd38e5 fix(plugins): treat Node type declarations as host types in the type walk
Dependency declarations that `import "node"` (e.g. utif2) pulled @types/node
into the install-time type walk, which failed on node: specifiers newer than
the host Node (node:stream/iter on Node 24) or, with @types/node absent,
on the unresolved "node" import. Map bare "node" in declarations to
@types/node and treat any node: specifier as a builtin.
2026-10-16 13:19:22 +02:00
9 changed files with 136 additions and 501 deletions
@@ -1,50 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdir, mkdtemp } from "node:fs/promises";
import { resolve } from "node:path";
import { connectToDaemon } from "../../utils/client.js";
import { runPaseoCli, startTestDaemon } from "../../../tests/helpers/test-daemon.js";
describe("paseo logs JSON output", () => {
let daemon: Awaited<ReturnType<typeof startTestDaemon>>;
let agentId: string;
let host: string;
beforeAll(async () => {
await mkdir(".artifacts", { recursive: true });
daemon = await startTestDaemon({
paseoHome: await mkdtemp(resolve(".artifacts/logs-json-home-")),
workDir: await mkdtemp(resolve(".artifacts/logs-json-work-")),
});
host = `127.0.0.1:${daemon.port}`;
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
try {
// Creating an idle agent needs no inference or external provider connection.
const agent = await client.createAgent({
provider: "claude",
cwd: daemon.workDir,
title: "JSON logs",
});
agentId = agent.id;
} finally {
await client.close();
}
}, 60_000);
afterAll(async () => {
await daemon?.stop();
});
it.each([
{ label: "top-level --json", args: ["logs", "--json"] },
{ label: "agent logs --json", args: ["agent", "logs", "--json"] },
{ label: "--format json alias", args: ["logs", "--format", "json"] },
])(
"prints JSON through $label",
async ({ args }) => {
const result = await runPaseoCli(daemon, [...args, agentId, "--host", host]);
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ agentId, items: [] });
},
30_000,
);
});
+1 -33
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import {
createFollowJsonWriter,
createFollowTranscriptWriter,
formatAgentActivityTranscript,
} from "./logs.js";
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
let output = "";
@@ -153,31 +149,3 @@ describe("createFollowTranscriptWriter", () => {
expect(output).toBe("# Title\n\nBody\n");
});
});
describe("createFollowJsonWriter", () => {
it("writes one JSON item per line and honours the filter", () => {
let output = "";
const writer = createFollowJsonWriter("tools", (chunk) => {
output += chunk;
});
const toolCall = {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
detail: { type: "unknown", input: null, output: null },
error: null,
} as AgentTimelineItem;
writer.push({ type: "assistant_message", text: "Running it.", messageId: "msg_1" });
writer.push(toolCall);
writer.end();
expect(
output
.trimEnd()
.split("\n")
.map((line) => JSON.parse(line)),
).toEqual([toolCall]);
});
});
+71 -146
View File
@@ -70,35 +70,6 @@ export function formatAgentActivityTranscript(
);
}
/**
* Picks the items `--tail` keeps, the same way the transcript does: the last n
* timeline items, before any of them are dropped from the curated view.
*/
function selectTailItems(
timelineItems: AgentTimelineItem[],
tailCount?: number,
): AgentTimelineItem[] {
if (tailCount === undefined) {
return timelineItems;
}
if (tailCount === 0) {
return [];
}
return timelineItems.slice(-tailCount);
}
function formatAgentLogsJson(
agentId: string,
timelineItems: AgentTimelineItem[],
tailCount?: number,
): string {
return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2);
}
function usesJsonOutput(options: AgentLogsOptions): boolean {
return options.json === true || options.format?.trim().toLowerCase() === "json";
}
type StreamingTextType = "assistant_message" | "reasoning";
const THOUGHT_PREFIX = "[Thought] ";
@@ -227,21 +198,6 @@ export function createFollowTranscriptWriter(
};
}
/** Follow mode with `--json`: one JSON timeline item per line (NDJSON). */
export function createFollowJsonWriter(
filter?: string,
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
): FollowTranscriptWriter {
return {
push(item) {
if (matchesFilter(item, filter)) {
write(`${JSON.stringify(item)}\n`);
}
},
end() {},
};
}
function parseTailCount(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
@@ -350,11 +306,6 @@ export async function runLogsCommand(
await client.close();
if (usesJsonOutput(options)) {
console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount));
return;
}
// Use curateAgentActivity to format the transcript
if (tailCount === 0) {
return;
@@ -380,104 +331,78 @@ async function runFollowMode({
options,
sinceTimestampMs,
}: FollowModeInput): Promise<void> {
// Observe shutdown before history writes can encounter a closed reader.
// Keep an early stop resolved while subscription setup is in flight.
const stopController = new AbortController();
const stopped = waitForStop(stopController.signal);
let unsubscribe: (() => void) | undefined;
const json = usesJsonOutput(options);
const writer = json
? createFollowJsonWriter(options.filter)
: createFollowTranscriptWriter(undefined, options.filter);
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems: AgentTimelineItem[] = [];
try {
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems: AgentTimelineItem[] = [];
try {
const history = await Promise.race([
fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
sinceTimestampMs,
}),
stopped,
]);
if (!history) return;
existingItems = history;
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
// Print existing transcript (tail-like behavior)
if (json) {
for (const item of selectTailItems(existingItems, tailCount)) {
console.log(JSON.stringify(item));
}
} else if (tailCount > 0) {
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
console.log(existingTranscript);
}
}
// Subscribe to new events
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
// Keep stdout parseable in JSON mode: one timeline item per line, notices on stderr.
const notice = json ? console.error : console.log;
const subscription = client.subscribeAgentTimeline(agentId, (message) => {
if (message.type === "agent.timeline.replacement") {
writer.end();
notice("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
writer.end();
console.error(`Timeline observation stopped: ${message.payload.error}`);
return;
}
if (message.type === "agent.timeline.subscription_restored") {
writer.end();
notice("\n[Reconnected; live output resumed. Events may have been missed.]");
return;
}
if (message.payload.event.type === "timeline") {
const matchesSince =
sinceTimestampMs === undefined ||
Date.parse(message.payload.timestamp) >= sinceTimestampMs;
if (!matchesSince) {
return;
}
const item = message.payload.event.item;
writer.push(item, message.payload.event.turnId);
} else if (
message.payload.event.type === "turn_completed" ||
message.payload.event.type === "turn_failed" ||
message.payload.event.type === "turn_canceled"
) {
writer.end();
}
existingItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
sinceTimestampMs,
});
unsubscribe = subscription;
const ready = await Promise.race([subscription.ready.then(() => true), stopped]);
if (!ready) return;
notice(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await stopped;
} finally {
stopController.abort();
writer.end();
unsubscribe?.();
await client.close();
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
// Print existing transcript (tail-like behavior)
if (tailCount > 0) {
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
console.log(existingTranscript);
}
}
// Subscribe to new events
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
const writer = createFollowTranscriptWriter(undefined, options.filter);
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
if (message.type === "agent.timeline.replacement") {
writer.end();
console.log("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
writer.end();
console.error(`Timeline observation stopped: ${message.payload.error}`);
return;
}
if (message.type === "agent.timeline.subscription_restored") {
writer.end();
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
return;
}
if (message.payload.event.type === "timeline") {
const matchesSince =
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
if (!matchesSince) {
return;
}
const item = message.payload.event.item;
writer.push(item, message.payload.event.turnId);
} else if (
message.payload.event.type === "turn_completed" ||
message.payload.event.type === "turn_failed" ||
message.payload.event.type === "turn_canceled"
) {
writer.end();
}
});
await unsubscribe.ready;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await waitForStop();
writer.end();
unsubscribe();
await client.close();
}
+3 -27
View File
@@ -4,13 +4,13 @@ import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
function createPagingClient(archivedAgentNumber?: number) {
function createPagingClient() {
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
provider: "codex",
title: `agent ${n}`,
status: n === archivedAgentNumber ? "idle" : "closed",
archivedAt: n === archivedAgentNumber ? "2026-10-10T00:00:00.000Z" : null,
status: "closed",
archivedAt: null,
cwd: "/tmp/project",
createdAt: `2026-10-0${n}T00:00:00.000Z`,
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
@@ -130,28 +130,4 @@ describe("runLsCommand", () => {
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
});
it("marks archived agents in table and JSON list data", async () => {
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
createPagingClient(2),
);
expect(
result.data.map(({ name, archived, archivedAt }) => ({ name, archived, archivedAt })),
).toEqual([
{ name: "agent 2", archived: true, archivedAt: "2026-10-10T00:00:00.000Z" },
{ name: "agent 3", archived: false, archivedAt: null },
{ name: "agent 1", archived: false, archivedAt: null },
]);
const archivedColumn = result.schema.columns.find((column) => column.header === "ARCHIVED");
expect(archivedColumn).toBeDefined();
expect(typeof archivedColumn?.field).toBe("function");
if (!archivedColumn || typeof archivedColumn.field !== "function") {
throw new Error("Expected the ARCHIVED column to have a display accessor");
}
expect(result.data[0]!.status).toBe("idle");
expect(archivedColumn.field(result.data[0]!)).toBe("yes");
expect(archivedColumn.field(result.data[1]!)).toBe("-");
});
});
-9
View File
@@ -33,8 +33,6 @@ export interface AgentListItem {
provider: string;
thinking: string;
status: string;
archived: boolean;
archivedAt: string | null;
cwd: string;
created: string;
}
@@ -86,11 +84,6 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
return undefined;
},
},
{
header: "ARCHIVED",
field: (item) => (item.archived ? "yes" : "-"),
width: 8,
},
{ header: "CWD", field: "cwd", width: 30 },
{ header: "CREATED", field: "created", width: 15 },
],
@@ -106,8 +99,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
provider: model ? `${agent.provider}/${model}` : agent.provider,
thinking: agent.effectiveThinkingOptionId ?? "auto",
status: agent.status,
archived: agent.archivedAt != null,
archivedAt: agent.archivedAt ?? null,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
};
+1 -4
View File
@@ -3,10 +3,9 @@
// their output closes its end, as `| head` does. A closed reader shows up as
// EPIPE on the next write to stdout, so the command stops at its next line of
// output.
export function waitForStop(signal?: AbortSignal): Promise<void> {
export function waitForStop(): Promise<void> {
return new Promise((resolve) => {
const stop = () => {
signal?.removeEventListener("abort", stop);
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
process.stdout.off("error", stopOnClosedStdout);
@@ -21,7 +20,5 @@ export function waitForStop(signal?: AbortSignal): Promise<void> {
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
process.stdout.on("error", stopOnClosedStdout);
signal?.addEventListener("abort", stop, { once: true });
if (signal?.aborted) stop();
});
}
@@ -1,10 +1,7 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdir, mkdtemp } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
@@ -140,225 +137,3 @@ test("omitted after limit pages forward while explicit zero preserves complete C
await daemon.close();
}
}, 60_000);
test.each([
{ label: "--json", flags: ["--json"] },
{ label: "--format json", flags: ["--format", "json"] },
])(
"logs --follow $label keeps history, live items and notices on their channels",
async ({ flags }) => {
await mkdir(".artifacts", { recursive: true });
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-json-"));
const staticDir = join(paseoHomeRoot, "static");
await mkdir(staticDir);
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let follow: ReturnType<typeof spawn> | undefined;
let stdout = "";
let stderr = "";
try {
await client.connect();
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
const history = [
{ type: "user_message" as const, text: "Saved JSON prompt" },
{ type: "assistant_message" as const, text: "Saved JSON reply", messageId: "saved" },
];
for (const item of history)
await daemon.daemon.agentManager.appendTimelineItem(agent.id, item);
follow = spawn(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
"logs",
agent.id,
"--follow",
...flags,
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
},
);
const exited = once(follow, "exit");
follow.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
follow.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
await expect
.poll(
() => {
if (follow?.exitCode !== null)
throw new Error(`CLI exited before following logs: ${stderr}`);
return stdout + stderr;
},
{ timeout: 20_000 },
)
.toContain("Following logs");
const live = {
type: "assistant_message" as const,
text: "Live JSON reply",
messageId: "live",
};
await daemon.daemon.agentManager.appendTimelineItem(agent.id, live);
await expect.poll(() => stdout, { timeout: 10_000 }).toContain(live.text);
follow.kill("SIGINT");
await exited;
expect(
stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line)),
).toEqual([...history, live]);
expect(stderr).toContain("Following logs");
expect(stdout).not.toContain("Following logs");
} finally {
follow?.kill("SIGTERM");
await client.close();
await daemon.close();
}
},
60_000,
);
test.each([
{ label: "--json", command: ["logs"], flags: ["--json"] },
{ label: "--format json", command: ["agent", "logs"], flags: ["--format", "json"] },
])(
"logs --follow $label exits when stdout closes before saved history is written",
async ({ command, flags }) => {
await mkdir(".artifacts", { recursive: true });
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-closed-"));
const staticDir = join(paseoHomeRoot, "static");
await mkdir(staticDir);
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let follow: ReturnType<typeof spawn> | undefined;
let stderr = "";
try {
await client.connect();
// No turn is started: the saved history must trigger shutdown on its own.
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "Saved reply before the reader closes",
messageId: "saved",
});
follow = spawn(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
...command,
agent.id,
"--follow",
...flags,
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
},
);
const exited = once(follow, "exit");
follow.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
follow.stdout?.destroy();
await expect.poll(() => follow?.exitCode, { timeout: 10_000 }).toBe(0);
expect(await exited, stderr).toEqual([0, null]);
} finally {
follow?.kill("SIGKILL");
await client.close();
await daemon.close();
}
},
30_000,
);
// Windows kill("SIGINT") force-terminates rather than delivering a console interrupt.
test.skipIf(process.platform === "win32")(
"logs --follow stops on SIGINT while subscription startup is waiting",
async () => {
await mkdir(".artifacts", { recursive: true });
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-startup-"));
const staticDir = join(paseoHomeRoot, "static");
await mkdir(staticDir);
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const proxy = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await once(proxy, "listening");
const address = proxy.address();
if (typeof address === "string" || address === null) throw new Error("Missing proxy port");
const upstreams: WebSocket[] = [];
let waitingForSubscription = false;
proxy.on("connection", (downstream) => {
const upstream = new WebSocket(`ws://127.0.0.1:${daemon.port}/ws`);
upstreams.push(upstream);
const connected = once(upstream, "open");
downstream.on("message", async (data, binary) => {
await connected;
const frame = binary ? undefined : JSON.parse(data.toString());
// Hold only the subscription request. All other traffic uses the real daemon.
if (frame?.message?.type === "agent.timeline.set_subscription.request") {
waitingForSubscription = true;
return;
}
upstream.send(data, { binary });
});
upstream.on("message", (data, binary) => downstream.send(data, { binary }));
downstream.on("close", () => upstream.close());
});
let follow: ReturnType<typeof spawn> | undefined;
try {
await client.connect();
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
follow = spawn(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${address.port}`,
"logs",
agent.id,
"--follow",
"--json",
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
},
);
follow.stdout?.resume();
follow.stderr?.resume();
const exited = once(follow, "exit");
await expect.poll(() => waitingForSubscription, { timeout: 10_000 }).toBe(true);
follow.kill("SIGINT");
await expect.poll(() => follow?.exitCode, { timeout: 5_000 }).toBe(0);
expect(await exited).toEqual([0, null]);
} finally {
if (follow && follow.exitCode === null && follow.signalCode === null) {
follow.kill("SIGKILL");
await once(follow, "exit");
}
for (const upstream of upstreams) upstream.terminate();
for (const downstream of proxy.clients) downstream.terminate();
await new Promise<void>((done) => proxy.close(() => done()));
await client.close();
await daemon.close();
}
},
30_000,
);
@@ -36,6 +36,23 @@ function containsPath(directory: string, filePath: string): boolean {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
// @types/node can declare builtins newer than the host Node (node:stream/iter on Node 24).
function isNodeSpecifier(specifier: string): boolean {
return isBuiltin(specifier) || specifier.startsWith("node:") || specifier === "@types/node";
}
function declaredPackage(input: {
specifier: string;
kind: PluginImportKind;
typeOnly: boolean;
}): string {
const { specifier, kind, typeOnly } = input;
if (kind === "type-reference") return `@types/${specifier.replace(/^@/, "").replace("/", "__")}`;
// Declarations load Node's ambient types with `import "node"`.
if (typeOnly && specifier === "node") return "@types/node";
return specifier;
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
@@ -180,10 +197,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const packageSpecifier = declaredPackage({ specifier, kind, typeOnly });
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
@@ -197,7 +211,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
isNodeSpecifier(specifier) ||
packageSpecifier === "@types/node"
)
continue;
@@ -288,8 +302,7 @@ function runtimeSpecifierError(
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isNodeSpecifier(specifier)) kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
@@ -389,6 +389,46 @@ export type Value = string;`,
},
);
it.each([
{ name: "import 'node' with Node types installed", source: 'import "node";', nodeTypes: true },
{ name: "import 'node' without Node types", source: 'import "node";', nodeTypes: false },
{
name: "a node: builtin unknown to the host",
source: 'import type { Future } from "node:paseo-future-builtin";',
nodeTypes: false,
},
])("treats Node declarations as host types in server dependencies: $name", async (fixture) => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/image-codec");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "image-codec", main: "index.js", types: "index.d.ts" }),
);
await writeFile(path.join(dependency, "index.js"), 'module.exports = "decoded";');
await writeFile(
path.join(dependency, "index.d.ts"),
`${fixture.source}\ndeclare const value: string;\nexport = value;`,
);
if (fixture.nodeTypes) {
const nodeTypes = path.join(entries.directory, "node_modules/@types/node");
await mkdir(nodeTypes, { recursive: true });
await writeFile(
path.join(nodeTypes, "package.json"),
JSON.stringify({ name: "@types/node", types: "index.d.ts" }),
);
await writeFile(
path.join(nodeTypes, "index.d.ts"),
'declare module "node:paseo-future-builtin" { export * from "node:stream"; }\nimport type { Future } from "node:paseo-future-builtin";',
);
}
await writeFile(entries.server, 'import value from "image-codec"; export default value;');
await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({
serverBundle: expect.stringContaining("decoded"),
});
});
it("checks the physical owner of type-only symlinks", async () => {
const entries = await createSplitPlugin();
await writeFile(