Merge pull request #3798 from code-yeongyu/fix/node-runtime-compat

fix(bun-spawn-shim): eliminate globalThis.Bun top-level destructures (#3797)
This commit is contained in:
YeonGyu-Kim
2026-05-06 13:33:11 +09:00
committed by GitHub
35 changed files with 407 additions and 39 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import * as path from "node:path";
import { spawn } from "bun";
import { spawn } from "./bun-spawn-shim";
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
import { extractZip } from "./zip-extractor";
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test"
import { spawn, spawnSync } from "./bun-spawn-shim"
describe("bun-spawn-shim", () => {
test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => {
const proc = spawn(["bun", "--version"], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
expect(exitCode).toBe(0)
expect(proc.exitCode).toBe(0)
})
test("#given piped stdout #when spawn writes output #then stdout is readable", async () => {
const proc = spawn(["bun", "--print", "'shim-ok'"], { stdout: "pipe", stderr: "pipe" })
const [exitCode, stdout] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
])
expect(exitCode).toBe(0)
expect(stdout.trim()).toBe("shim-ok")
})
test("#given detached object command #when spawn starts #then process exposes daemon controls", async () => {
const proc = spawn({
cmd: ["bun", "--print", "'detached-ok'"],
stdout: "pipe",
stderr: "pipe",
detached: true,
})
proc.unref()
const exitCode = await proc.exited
expect(exitCode).toBe(0)
expect(typeof proc.ref).toBe("function")
expect(typeof proc.unref).toBe("function")
expect(proc.pid).toBeGreaterThan(0)
})
test("#given stdio tuple #when spawn runs #then ignored streams are still safe to read", async () => {
const proc = spawn({
cmd: ["bun", "--print", "'ignored'"],
stdio: ["ignore", "ignore", "ignore"],
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
expect(exitCode).toBe(0)
expect(stdout).toBe("")
})
test("#given spawnSync command #when it writes output #then stdout and exit code match", () => {
const result = spawnSync(["bun", "--print", "'sync-ok'"], { stdout: "pipe", stderr: "pipe" })
expect(result.exitCode).toBe(0)
expect(result.success).toBe(true)
expect(result.stdout).toBeDefined()
expect(Buffer.from(result.stdout!).toString().trim()).toBe("sync-ok")
})
test("#given spawnSync command #when it completes #then result.pid is a positive number", () => {
const result = spawnSync(["bun", "--version"], { stdout: "pipe", stderr: "pipe" })
expect(result.pid).toBeGreaterThan(0)
})
test("#given default stdio #when child reads stdin #then it does not hang waiting for input", async () => {
const proc = spawn(["cat"], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
expect(exitCode).toBe(0)
})
test("#given missing executable #when spawn invoked #then the error is surfaced to the caller", async () => {
let observedError: unknown
try {
const proc = spawn(["__omo-shim-missing-binary__"], { stdout: "pipe", stderr: "pipe" })
await proc.exited
} catch (error) {
observedError = error
}
expect(observedError).toBeDefined()
})
})
+166
View File
@@ -0,0 +1,166 @@
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process"
import { Readable, Writable } from "node:stream"
type AnyRecord = Record<string, unknown>
type StdioMode = "pipe" | "inherit" | "ignore"
type StdioTuple = [StdioMode, StdioMode, StdioMode]
export interface SpawnOptions {
cmd?: string[]
cwd?: string
env?: NodeJS.ProcessEnv
stdin?: StdioMode
stdout?: StdioMode
stderr?: StdioMode
stdio?: StdioTuple
detached?: boolean
}
export interface SpawnedProcess {
readonly exitCode: number | null
readonly exited: Promise<number>
readonly stdout: ReadableStream<Uint8Array>
readonly stderr: ReadableStream<Uint8Array>
readonly stdin: NodeJS.WritableStream
readonly pid: number | undefined
kill(signal?: NodeJS.Signals): void
ref(): void
unref(): void
}
export interface SpawnSyncResult {
readonly exitCode: number
readonly stdout: Buffer | undefined
readonly stderr: Buffer | undefined
readonly success: boolean
readonly pid: number
}
type BunSpawnRuntime = {
spawn(command: string[], options?: SpawnOptions): SpawnedProcess
spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess
spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult
spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult
}
const runtime = globalThis as typeof globalThis & { Bun?: BunSpawnRuntime }
const IS_BUN = typeof runtime.Bun !== "undefined"
function emptyReadableStream(): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.close()
},
})
}
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> {
if (!stream) return emptyReadableStream()
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
}
function emptyWritableStream(): Writable {
return new Writable({
write(_chunk, _encoding, callback) {
callback()
},
})
}
function resolveCommand(cmdOrOpts: unknown, optsArg?: unknown): { cmd: string[]; opts: SpawnOptions } {
const isObj = !Array.isArray(cmdOrOpts)
const opts = isObj ? (cmdOrOpts as SpawnOptions) : ((optsArg ?? {}) as SpawnOptions)
return {
cmd: isObj ? ((cmdOrOpts as AnyRecord).cmd as string[]) : (cmdOrOpts as string[]),
opts,
}
}
function resolveStdio(options: SpawnOptions): StdioTuple {
if (options.stdio) return options.stdio
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"]
}
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
let exitCode: number | null = null
const exited = new Promise<number>((resolve, reject) => {
proc.on("exit", (code) => {
exitCode = code ?? 1
resolve(exitCode)
})
proc.on("error", (error) => {
if (exitCode === null) {
exitCode = 1
reject(error)
}
})
})
return {
get exitCode() {
return exitCode
},
exited,
stdout: toReadableStream(proc.stdout),
stderr: toReadableStream(proc.stderr),
stdin: proc.stdin ?? emptyWritableStream(),
kill(signal?: NodeJS.Signals) {
if (proc.killed || exitCode !== null) return
try {
proc.kill(signal)
} catch (error) {
if (!String(error).includes("kill")) throw error
}
},
pid: proc.pid,
ref() {
proc.ref()
},
unref() {
proc.unref()
},
}
}
export function spawn(command: string[], options?: SpawnOptions): SpawnedProcess
export function spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess
export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess {
if (IS_BUN) return runtime.Bun!.spawn(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions)
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
const [bin, ...args] = cmd
const proc = nodeSpawn(bin, args, {
cwd: options.cwd,
env: options.env,
stdio: resolveStdio(options),
detached: options.detached,
})
return wrapNodeProcess(proc)
}
export function spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult
export function spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult
export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult {
if (IS_BUN) return runtime.Bun!.spawnSync(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions)
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
const [bin, ...args] = cmd
const result = nodeSpawnSync(bin, args, {
cwd: options.cwd,
env: options.env,
stdio: resolveStdio(options),
})
return {
exitCode: result.status ?? 1,
stdout: result.stdout ?? undefined,
stderr: result.stderr ?? undefined,
success: (result.status ?? 1) === 0,
pid: result.pid ?? -1,
}
}
@@ -0,0 +1,65 @@
import { existsSync } from "node:fs"
import { describe, expect, test } from "bun:test"
const DIST_INDEX = "dist/index.js"
const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm
const TOP_LEVEL_REQUIRE_CALL = "__require("
describe("dist bundle Bun globals", () => {
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => {
const dist = await Bun.file(DIST_INDEX).text()
const matches = dist.match(GLOBAL_BUN_DESTRUCTURE) ?? []
expect(matches).toEqual([])
})
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no top-level __require call remains", async () => {
const dist = await Bun.file(DIST_INDEX).text()
const offending: string[] = []
let depth = 0
for (const [index, line] of dist.split("\n").entries()) {
if (depth === 0 && line.includes(TOP_LEVEL_REQUIRE_CALL)) {
offending.push(`${index + 1}: ${line.trim()}`)
}
for (const char of line) {
if (char === "{") {
depth += 1
} else if (char === "}") {
depth -= 1
if (depth < 0) depth = 0
}
}
}
expect(offending).toEqual([])
})
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when imported under node --input-type=module #then it loads without error", async () => {
const node = Bun.which("node")
if (!node) return
const proc = Bun.spawn({
cmd: [node, "--input-type=module", "-e", "await import('./dist/index.js'); console.log('node-esm-load-ok')"],
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
expect({
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
}).toEqual({
exitCode: 0,
stdout: "node-esm-load-ok",
stderr: "",
})
})
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { spawn as bunSpawn } from "bun"
import { spawn as bunSpawn } from "./bun-spawn-shim"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { Readable } from "node:stream"
@@ -75,7 +75,7 @@ export function spawnWithWindowsHide(command: string[], options: SpawnOptions):
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,
env: options.env,
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
stdio: [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"],
windowsHide: true,
shell: true,
})
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../bun-spawn-shim"
import type { TmuxLayout } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../bun-spawn-shim"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
export interface PaneDimensions {
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../bun-spawn-shim"
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../bun-spawn-shim"
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../bun-spawn-shim"
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
+1 -1
View File
@@ -1 +1 @@
export { spawn } from "bun"
export { spawn } from "../../bun-spawn-shim"
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../bun-spawn-shim"
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "bun"
import { spawn, spawnSync } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../bun-spawn-shim"
export async function readZipSymlinkTarget(
archivePath: string,
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
import { log } from "../logger"
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "bun"
import { spawn, spawnSync } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
import { readZipSymlinkTarget } from "./read-zip-symlink-target"
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "bun"
import { spawn, spawnSync } from "./bun-spawn-shim"
import { release } from "os"
import { validateArchiveEntries } from "./archive-entry-validator"