Merge pull request #3964 from code-yeongyu/fix/electron-bun-runtime-shims
fix(electron): eliminate raw Bun.* runtime calls so plugin loads on OpenCode Desktop
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { spawn } from "./bun-spawn-shim";
|
||||
import { bunWrite } from "./bun-file-shim";
|
||||
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
|
||||
import { extractZip } from "./zip-extractor";
|
||||
|
||||
@@ -26,7 +27,7 @@ export async function downloadArchive(downloadUrl: string, archivePath: string):
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
await Bun.write(archivePath, arrayBuffer);
|
||||
await bunWrite(archivePath, arrayBuffer);
|
||||
}
|
||||
|
||||
export async function extractTarGz(
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/// <reference path="../../bun-test.d.ts" />
|
||||
|
||||
import { Buffer as NodeBuffer } from "node:buffer"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { access, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
||||
|
||||
import { bunFile, bunWrite } from "./bun-file-shim"
|
||||
|
||||
type NodeFallbackBunFileLike = {
|
||||
text(): Promise<string>
|
||||
arrayBuffer(): Promise<ArrayBuffer>
|
||||
exists(): Promise<boolean>
|
||||
delete(): Promise<void>
|
||||
}
|
||||
|
||||
type NodeFallbackBunFile = (path: string) => NodeFallbackBunFileLike
|
||||
type NodeFallbackBunWrite = (path: string, data: string | ArrayBuffer | Uint8Array) => Promise<number>
|
||||
|
||||
type NodeFallbackExports = {
|
||||
bunFile: NodeFallbackBunFile
|
||||
bunWrite: NodeFallbackBunWrite
|
||||
}
|
||||
|
||||
type BunFileTestRuntime = {
|
||||
Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string }
|
||||
}
|
||||
|
||||
type BunFileSandbox = {
|
||||
access: typeof access
|
||||
Buffer: typeof NodeBuffer
|
||||
console: Console
|
||||
Promise: PromiseConstructor
|
||||
readFile: typeof readFile
|
||||
TextEncoder: typeof TextEncoder
|
||||
Uint8Array: Uint8ArrayConstructor
|
||||
unlink: typeof unlink
|
||||
writeFile: typeof writeFile
|
||||
__exports?: NodeFallbackExports
|
||||
}
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun: BunFileTestRuntime }
|
||||
const NODE_FALLBACK = loadNodeFallbackBunFileShim()
|
||||
|
||||
let temporaryDirectory = ""
|
||||
let nodeFallbackTemporaryDirectory = ""
|
||||
|
||||
function temporaryPath(fileName: string): string {
|
||||
return join(temporaryDirectory, fileName)
|
||||
}
|
||||
|
||||
function nodeFallbackPath(fileName: string): string {
|
||||
return join(nodeFallbackTemporaryDirectory, fileName)
|
||||
}
|
||||
|
||||
function loadNodeFallbackBunFileShim(): NodeFallbackExports {
|
||||
const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-file-shim.ts")
|
||||
const source = readFileSync(sourcePath, "utf8")
|
||||
const importStatement = 'import { access, readFile, unlink, writeFile } from "node:fs/promises"\n\n'
|
||||
const interfaceSignature = "export interface BunFileLike {"
|
||||
const bunFileSignature = "export function bunFile(path: string): BunFileLike {"
|
||||
const bunWriteSignature =
|
||||
"export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise<number> {"
|
||||
|
||||
if (!source.startsWith(importStatement)) {
|
||||
throw new Error("bun-file-shim import statement changed")
|
||||
}
|
||||
|
||||
for (const signature of [interfaceSignature, bunFileSignature, bunWriteSignature]) {
|
||||
if (!source.includes(signature)) {
|
||||
throw new Error(`bun-file-shim signature changed: ${signature}`)
|
||||
}
|
||||
}
|
||||
|
||||
const transformedSource = source
|
||||
.slice(importStatement.length)
|
||||
.replace(interfaceSignature, "interface BunFileLike {")
|
||||
.replace(bunFileSignature, "function bunFile(path: string): BunFileLike {")
|
||||
.replace(
|
||||
bunWriteSignature,
|
||||
"async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise<number> {",
|
||||
)
|
||||
const scriptSource = `${transformedSource}\nglobalThis.__exports = { bunFile, bunWrite }\n`
|
||||
const transpiler = new runtime.Bun.Transpiler({ loader: "ts" })
|
||||
const script = transpiler.transformSync(scriptSource)
|
||||
const sandbox: BunFileSandbox = {
|
||||
access,
|
||||
Buffer: NodeBuffer,
|
||||
console,
|
||||
Promise,
|
||||
readFile,
|
||||
TextEncoder,
|
||||
Uint8Array,
|
||||
unlink,
|
||||
writeFile,
|
||||
}
|
||||
|
||||
runInNewContext(script, sandbox, { filename: sourcePath })
|
||||
|
||||
if (!sandbox.__exports) {
|
||||
throw new Error("Node fallback bun-file-shim loader failed")
|
||||
}
|
||||
|
||||
return sandbox.__exports
|
||||
}
|
||||
|
||||
function arrayBufferFromBytes(bytes: number[]): ArrayBuffer {
|
||||
const arrayBuffer = new ArrayBuffer(bytes.length)
|
||||
const view = new Uint8Array(arrayBuffer)
|
||||
|
||||
view.set(bytes)
|
||||
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
describe("bun-file-shim", () => {
|
||||
beforeAll(async () => {
|
||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-"))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (temporaryDirectory.length === 0) return
|
||||
|
||||
await rm(temporaryDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given bunFile", () => {
|
||||
it("#when text is called then it reads file contents", async () => {
|
||||
const filePath = temporaryPath("text.txt")
|
||||
const content = "hello from file"
|
||||
|
||||
await writeFile(filePath, content)
|
||||
|
||||
expect(await bunFile(filePath).text()).toBe(content)
|
||||
})
|
||||
|
||||
it("#when arrayBuffer is called then it returns exact file bytes", async () => {
|
||||
const filePath = temporaryPath("bytes.bin")
|
||||
const bytes = new Uint8Array([0, 1, 2, 255])
|
||||
|
||||
await writeFile(filePath, bytes)
|
||||
|
||||
const arrayBuffer = await bunFile(filePath).arrayBuffer()
|
||||
|
||||
expect(arrayBuffer.byteLength).toBe(bytes.byteLength)
|
||||
expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes))
|
||||
})
|
||||
|
||||
it("#when exists is called then it reflects file presence", async () => {
|
||||
const existingPath = temporaryPath("existing.txt")
|
||||
const missingPath = temporaryPath("missing.txt")
|
||||
|
||||
await writeFile(existingPath, "present")
|
||||
|
||||
expect(await bunFile(existingPath).exists()).toBe(true)
|
||||
expect(await bunFile(missingPath).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("#when delete is called then it removes the file", async () => {
|
||||
const filePath = temporaryPath("delete-me.txt")
|
||||
|
||||
await writeFile(filePath, "remove")
|
||||
await bunFile(filePath).delete()
|
||||
|
||||
expect(await bunFile(filePath).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given bunWrite", () => {
|
||||
it("#when writing string data then it writes contents and returns byte count", async () => {
|
||||
const filePath = temporaryPath("write-string.txt")
|
||||
const content = "write me"
|
||||
const bytesWritten = await bunWrite(filePath, content)
|
||||
|
||||
expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength)
|
||||
expect(await readFile(filePath, "utf8")).toBe(content)
|
||||
})
|
||||
|
||||
it("#when writing array buffer data then it writes exact bytes", async () => {
|
||||
const filePath = temporaryPath("write-array-buffer.bin")
|
||||
const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68])
|
||||
const bytesWritten = await bunWrite(filePath, arrayBuffer)
|
||||
const written = await readFile(filePath)
|
||||
|
||||
expect(bytesWritten).toBe(arrayBuffer.byteLength)
|
||||
expect(Array.from(written)).toEqual([65, 66, 67, 68])
|
||||
})
|
||||
|
||||
it("#when writing then reading text then it round trips content", async () => {
|
||||
const filePath = temporaryPath("round-trip.txt")
|
||||
const content = "round trip content"
|
||||
|
||||
await bunWrite(filePath, content)
|
||||
|
||||
expect(await bunFile(filePath).text()).toBe(content)
|
||||
})
|
||||
|
||||
it("#when writing unicode text then it round trips content", async () => {
|
||||
const filePath = temporaryPath("unicode-round-trip.txt")
|
||||
const content = "Hello 世界 🌍"
|
||||
|
||||
await bunWrite(filePath, content)
|
||||
|
||||
expect(await bunFile(filePath).text()).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Node fallback without Bun global", () => {
|
||||
beforeAll(async () => {
|
||||
nodeFallbackTemporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-node-"))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (nodeFallbackTemporaryDirectory.length === 0) return
|
||||
|
||||
await rm(nodeFallbackTemporaryDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#when text is called then it reads file contents", async () => {
|
||||
const filePath = nodeFallbackPath("text.txt")
|
||||
const content = "hello from Node fallback"
|
||||
|
||||
await writeFile(filePath, content)
|
||||
|
||||
expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content)
|
||||
})
|
||||
|
||||
it("#when arrayBuffer is called then it returns exact file bytes", async () => {
|
||||
const filePath = nodeFallbackPath("bytes.bin")
|
||||
const bytes = new Uint8Array([0, 1, 2, 255, 128])
|
||||
|
||||
await writeFile(filePath, bytes)
|
||||
|
||||
const arrayBuffer = await NODE_FALLBACK.bunFile(filePath).arrayBuffer()
|
||||
|
||||
expect(arrayBuffer.byteLength).toBe(bytes.byteLength)
|
||||
expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes))
|
||||
})
|
||||
|
||||
it("#when exists is called then it reflects file presence", async () => {
|
||||
const existingPath = nodeFallbackPath("existing.txt")
|
||||
const missingPath = nodeFallbackPath("missing.txt")
|
||||
|
||||
await writeFile(existingPath, "present")
|
||||
|
||||
expect(await NODE_FALLBACK.bunFile(existingPath).exists()).toBe(true)
|
||||
expect(await NODE_FALLBACK.bunFile(missingPath).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("#when delete is called then it removes the file", async () => {
|
||||
const filePath = nodeFallbackPath("delete-me.txt")
|
||||
|
||||
await writeFile(filePath, "remove")
|
||||
await NODE_FALLBACK.bunFile(filePath).delete()
|
||||
|
||||
expect(await NODE_FALLBACK.bunFile(filePath).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("#when writing string data then it writes contents and returns byte count", async () => {
|
||||
const filePath = nodeFallbackPath("write-string.txt")
|
||||
const content = "write me from Node fallback"
|
||||
const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, content)
|
||||
|
||||
expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength)
|
||||
expect(await readFile(filePath, "utf8")).toBe(content)
|
||||
})
|
||||
|
||||
it("#when writing array buffer data then it writes exact bytes", async () => {
|
||||
const filePath = nodeFallbackPath("write-array-buffer.bin")
|
||||
const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68, 69])
|
||||
const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, arrayBuffer)
|
||||
const written = await readFile(filePath)
|
||||
|
||||
expect(bytesWritten).toBe(arrayBuffer.byteLength)
|
||||
expect(Array.from(written)).toEqual([65, 66, 67, 68, 69])
|
||||
})
|
||||
|
||||
it("#when writing then reading text then it round trips content", async () => {
|
||||
const filePath = nodeFallbackPath("round-trip.txt")
|
||||
const content = "round trip through Node fallback"
|
||||
|
||||
await NODE_FALLBACK.bunWrite(filePath, content)
|
||||
|
||||
expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content)
|
||||
})
|
||||
|
||||
it("#when writing unicode text then it round trips content", async () => {
|
||||
const filePath = nodeFallbackPath("unicode-round-trip.txt")
|
||||
const content = "Hello 世界 🌍 from Node fallback"
|
||||
|
||||
await NODE_FALLBACK.bunWrite(filePath, content)
|
||||
|
||||
expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { access, readFile, unlink, writeFile } from "node:fs/promises"
|
||||
|
||||
export interface BunFileLike {
|
||||
text(): Promise<string>
|
||||
arrayBuffer(): Promise<ArrayBuffer>
|
||||
exists(): Promise<boolean>
|
||||
delete(): Promise<void>
|
||||
}
|
||||
|
||||
type BunFileRuntime = {
|
||||
file(path: string): BunFileLike
|
||||
write(path: string, data: string | ArrayBuffer | Uint8Array): Promise<number>
|
||||
}
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: BunFileRuntime }
|
||||
const IS_BUN = typeof runtime.Bun !== "undefined"
|
||||
|
||||
function byteLength(data: string | ArrayBuffer | Uint8Array): number {
|
||||
if (typeof data === "string") return Buffer.byteLength(data, "utf8")
|
||||
|
||||
return data.byteLength
|
||||
}
|
||||
|
||||
function toWritableData(data: string | ArrayBuffer | Uint8Array): string | Uint8Array {
|
||||
if (typeof data === "string") return data
|
||||
if (data instanceof Uint8Array) return data
|
||||
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
|
||||
function createNodeFile(path: string): BunFileLike {
|
||||
return {
|
||||
text() {
|
||||
return readFile(path, "utf8")
|
||||
},
|
||||
async arrayBuffer() {
|
||||
const buffer = await readFile(path)
|
||||
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||
},
|
||||
exists() {
|
||||
return access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
delete() {
|
||||
return unlink(path)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function bunFile(path: string): BunFileLike {
|
||||
if (IS_BUN) return runtime.Bun!.file(path)
|
||||
|
||||
return createNodeFile(path)
|
||||
}
|
||||
|
||||
export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise<number> {
|
||||
if (IS_BUN) return runtime.Bun!.write(path, data)
|
||||
|
||||
await writeFile(path, toWritableData(data))
|
||||
|
||||
return byteLength(data)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { bunHashXxh32 as runtimeBunHashXxh32 } from "./bun-hash-shim"
|
||||
|
||||
type HashFunction = (input: string, seed: number) => number
|
||||
type HashPair = { input: string; seed: number }
|
||||
type BunHashTestRuntime = {
|
||||
hash: { xxHash32(data: string | Uint8Array, seed: number): number }
|
||||
Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string }
|
||||
}
|
||||
type HashSandbox = {
|
||||
Math: Math
|
||||
TextEncoder: typeof TextEncoder
|
||||
Uint8Array: Uint8ArrayConstructor
|
||||
__bunHashShim?: { bunHashXxh32: HashFunction }
|
||||
}
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun: BunHashTestRuntime }
|
||||
const FUZZ_PAIR_COUNT = 1_200
|
||||
const FIXED_LENGTHS = [0, 1, 2, 3, 4, 15, 16, 17, 31, 32, 33, 64, 100, 255, 500]
|
||||
const FIXED_SEEDS = [0, 1, 42, 12345, 0xdeadbeef, 0xffffffff]
|
||||
const CONTENT_FRAGMENTS = ["你好世界", "\u{1f389}", "\u{1f525}", "\n", "\r\n", "\t", " "]
|
||||
const SPECIAL_INPUTS = [
|
||||
"",
|
||||
" ",
|
||||
"\t\n\r\n",
|
||||
"hello world",
|
||||
"你好世界",
|
||||
"\u{1f389}\u{1f525}",
|
||||
"mixed 你好 \u{1f389} ascii",
|
||||
"line one\nline two\r\n\tindented",
|
||||
]
|
||||
const PURE_JS_HASH = loadPureJsBunHashXxh32()
|
||||
const FUZZ_PAIRS = createFuzzPairs()
|
||||
|
||||
function loadPureJsBunHashXxh32(): HashFunction {
|
||||
const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-hash-shim.ts")
|
||||
const source = readFileSync(sourcePath, "utf8")
|
||||
const exportSignature = "export function bunHashXxh32(input: string, seed: number): number {"
|
||||
|
||||
if (!source.includes(exportSignature)) {
|
||||
throw new Error("bunHashXxh32 export signature changed")
|
||||
}
|
||||
|
||||
const scriptSource = `${source.replace(
|
||||
exportSignature,
|
||||
"function bunHashXxh32(input: string, seed: number): number {",
|
||||
)}\nglobalThis.__bunHashShim = { bunHashXxh32 }\n`
|
||||
const transpiler = new runtime.Bun.Transpiler({ loader: "ts" })
|
||||
const script = transpiler.transformSync(scriptSource)
|
||||
const sandbox: HashSandbox = { Math, TextEncoder, Uint8Array }
|
||||
|
||||
runInNewContext(script, sandbox, { filename: sourcePath })
|
||||
|
||||
const pureJsHash = sandbox.__bunHashShim?.bunHashXxh32
|
||||
if (!pureJsHash) {
|
||||
throw new Error("pure-JS bunHashXxh32 loader failed")
|
||||
}
|
||||
|
||||
return pureJsHash
|
||||
}
|
||||
|
||||
function createUint32Generator(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function createSeed(pairIndex: number, nextUint32: () => number): number {
|
||||
if (pairIndex % (FIXED_SEEDS.length + 1) === FIXED_SEEDS.length) return nextUint32()
|
||||
|
||||
return FIXED_SEEDS[pairIndex % FIXED_SEEDS.length] ?? 0
|
||||
}
|
||||
|
||||
function createRandomString(length: number, nextUint32: () => number): string {
|
||||
let value = ""
|
||||
|
||||
while (value.length < length) {
|
||||
if (nextUint32() % 10 < 6) {
|
||||
value += String.fromCharCode(32 + (nextUint32() % 95))
|
||||
continue
|
||||
}
|
||||
|
||||
const fragment = CONTENT_FRAGMENTS[nextUint32() % CONTENT_FRAGMENTS.length] ?? " "
|
||||
if (value.length + fragment.length <= length) {
|
||||
value += fragment
|
||||
continue
|
||||
}
|
||||
|
||||
value += String.fromCharCode(32 + (nextUint32() % 95))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function createFuzzPairs(): HashPair[] {
|
||||
const nextUint32 = createUint32Generator(0x5eed1234)
|
||||
const pairs: HashPair[] = []
|
||||
|
||||
for (const input of SPECIAL_INPUTS) {
|
||||
pairs.push({ input, seed: createSeed(pairs.length, nextUint32) })
|
||||
}
|
||||
|
||||
for (const length of FIXED_LENGTHS) {
|
||||
pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) })
|
||||
}
|
||||
|
||||
while (pairs.length < FUZZ_PAIR_COUNT) {
|
||||
const randomLength = nextUint32() % 501
|
||||
const length = pairs.length % 13 === 0 ? (FIXED_LENGTHS[pairs.length % FIXED_LENGTHS.length] ?? randomLength) : randomLength
|
||||
pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) })
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
function nativeXxh32(input: string, seed: number): number {
|
||||
return runtime.Bun.hash.xxHash32(input, seed)
|
||||
}
|
||||
|
||||
function createMismatchMessage(label: string, input: string, seed: number, expected: number, actual: number): string {
|
||||
return `${label} mismatch for input=${JSON.stringify(input)} seed=${seed} expected=${expected} actual=${actual}`
|
||||
}
|
||||
|
||||
function expectPureJsHashToMatchBun(label: string, input: string, seed: number): void {
|
||||
const expected = nativeXxh32(input, seed)
|
||||
const actual = PURE_JS_HASH(input, seed)
|
||||
|
||||
if (actual !== expected) {
|
||||
throw new Error(createMismatchMessage(label, input, seed, expected, actual))
|
||||
}
|
||||
}
|
||||
|
||||
describe("#given known XXH32 test vectors", () => {
|
||||
test("#when pure-JS hash is called #then returns canonical values", () => {
|
||||
expect(PURE_JS_HASH("", 0)).toBe(0x02cc5d05)
|
||||
expect(PURE_JS_HASH("a", 0)).toBe(0x550d7456)
|
||||
expect(PURE_JS_HASH("abc", 0)).toBe(0x32d153ff)
|
||||
})
|
||||
|
||||
test("#when a non-zero seed is used #then matches Bun hash", () => {
|
||||
expectPureJsHashToMatchBun("seeded vector", "test", 42)
|
||||
expect(runtimeBunHashXxh32("test", 42)).toBe(nativeXxh32("test", 42))
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given random inputs #when hashed with pure-JS and Bun.hash", () => {
|
||||
test("#then all fuzz pairs are bit-exact", () => {
|
||||
expect(FUZZ_PAIRS).toHaveLength(FUZZ_PAIR_COUNT)
|
||||
|
||||
for (const [pairIndex, pair] of FUZZ_PAIRS.entries()) {
|
||||
expectPureJsHashToMatchBun(`fuzz pair ${pairIndex}`, pair.input, pair.seed)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given production-like inputs", () => {
|
||||
test("#when hashed with line-number seeds #then pure-JS matches Bun hash", () => {
|
||||
const inputs = [" const x = 42;", "import { foo } from 'bar'", "// comment", ""]
|
||||
const seeds = [0, 1, 50, 100, 999]
|
||||
|
||||
for (const input of inputs) {
|
||||
for (const seed of seeds) {
|
||||
expectPureJsHashToMatchBun("production-like input", input, seed)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
|
||||
const IS_BUN = typeof runtime.Bun !== "undefined"
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const PRIME32_1 = 0x9e3779b1
|
||||
const PRIME32_2 = 0x85ebca77
|
||||
const PRIME32_3 = 0xc2b2ae3d
|
||||
const PRIME32_4 = 0x27d4eb2f
|
||||
const PRIME32_5 = 0x165667b1
|
||||
|
||||
function rotateLeft32(value: number, bits: number): number {
|
||||
return ((value << bits) | (value >>> (32 - bits))) >>> 0
|
||||
}
|
||||
|
||||
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((input[offset] ?? 0) |
|
||||
((input[offset + 1] ?? 0) << 8) |
|
||||
((input[offset + 2] ?? 0) << 16) |
|
||||
((input[offset + 3] ?? 0) << 24)) >>>
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
function round32(accumulator: number, value: number): number {
|
||||
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
|
||||
|
||||
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
|
||||
}
|
||||
|
||||
function xxHash32Js(input: Uint8Array, seed: number): number {
|
||||
let offset = 0
|
||||
const length = input.length
|
||||
let hash: number
|
||||
|
||||
if (length >= 16) {
|
||||
const limit = length - 16
|
||||
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
|
||||
let value2 = (seed + PRIME32_2) >>> 0
|
||||
let value3 = seed >>> 0
|
||||
let value4 = (seed - PRIME32_1) >>> 0
|
||||
|
||||
while (offset <= limit) {
|
||||
value1 = round32(value1, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value2 = round32(value2, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value3 = round32(value3, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
value4 = round32(value4, readUint32LittleEndian(input, offset))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
|
||||
hash = (hash + rotateLeft32(value3, 12)) >>> 0
|
||||
hash = (hash + rotateLeft32(value4, 18)) >>> 0
|
||||
} else {
|
||||
hash = (seed + PRIME32_5) >>> 0
|
||||
}
|
||||
|
||||
hash = (hash + length) >>> 0
|
||||
|
||||
while (offset + 4 <= length) {
|
||||
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
|
||||
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
|
||||
offset += 4
|
||||
}
|
||||
|
||||
while (offset < length) {
|
||||
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
|
||||
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
|
||||
offset += 1
|
||||
}
|
||||
|
||||
hash = (hash ^ (hash >>> 15)) >>> 0
|
||||
hash = Math.imul(hash, PRIME32_2) >>> 0
|
||||
hash = (hash ^ (hash >>> 13)) >>> 0
|
||||
hash = Math.imul(hash, PRIME32_3) >>> 0
|
||||
|
||||
return (hash ^ (hash >>> 16)) >>> 0
|
||||
}
|
||||
|
||||
export function bunHashXxh32(input: string, seed: number): number {
|
||||
if (IS_BUN) return runtime.Bun!.hash.xxHash32(input, seed)
|
||||
|
||||
return xxHash32Js(encoder.encode(input), seed >>> 0)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { accessSync, constants, readFileSync } from "node:fs"
|
||||
import { delimiter, dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { bunWhich } from "./bun-which-shim"
|
||||
|
||||
type BunWhichFunction = (commandName: string) => string | null
|
||||
type BunWhichRuntime = {
|
||||
Transpiler?: new (options: { loader: "ts" }) => { transformSync(source: string): string }
|
||||
which(commandName: string): string | null
|
||||
}
|
||||
type SandboxProcess = {
|
||||
env: { PATH?: string; Path?: string }
|
||||
platform: typeof process.platform
|
||||
}
|
||||
type BunWhichSandbox = {
|
||||
accessSync: typeof accessSync
|
||||
constants: typeof constants
|
||||
console: Console
|
||||
delimiter: typeof delimiter
|
||||
join: typeof join
|
||||
process: SandboxProcess
|
||||
__bunWhichShim?: { bunWhich: BunWhichFunction }
|
||||
}
|
||||
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime }
|
||||
const PATH_TRAVERSAL_COMMAND_NAMES = [
|
||||
"../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"./tool",
|
||||
"sub/dir/tool",
|
||||
"C:\\Windows\\evil",
|
||||
"C:tool",
|
||||
".",
|
||||
"..",
|
||||
"node..evil",
|
||||
]
|
||||
const NULL_BYTE_COMMAND_NAME = "node\0evil"
|
||||
const NODE_FALLBACK_BUN_WHICH = loadNodeFallbackBunWhich()
|
||||
|
||||
function loadNodeFallbackBunWhich(): BunWhichFunction {
|
||||
const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-which-shim.ts")
|
||||
const source = readFileSync(sourcePath, "utf8")
|
||||
const fsImport = 'import { accessSync, constants } from "node:fs"\n'
|
||||
const pathImport = 'import { delimiter, join } from "node:path"\n'
|
||||
const exportSignature = "export function bunWhich(commandName: string): string | null {"
|
||||
|
||||
if (!source.includes(fsImport) || !source.includes(pathImport) || !source.includes(exportSignature)) {
|
||||
throw new Error("bunWhich source shape changed")
|
||||
}
|
||||
|
||||
const scriptSource = `${source
|
||||
.replace(fsImport, "")
|
||||
.replace(pathImport, "")
|
||||
.replace(exportSignature, "function bunWhich(commandName: string): string | null {")}\nglobalThis.__bunWhichShim = { bunWhich }\n`
|
||||
const transpilerConstructor = runtime.Bun?.Transpiler
|
||||
if (!transpilerConstructor) {
|
||||
throw new Error("Bun Transpiler unavailable")
|
||||
}
|
||||
|
||||
const transpiler = new transpilerConstructor({ loader: "ts" })
|
||||
const script = transpiler.transformSync(scriptSource)
|
||||
const sandboxProcess: SandboxProcess = {
|
||||
env: { PATH: process.env.PATH, Path: process.env.Path },
|
||||
platform: process.platform,
|
||||
}
|
||||
const sandbox: BunWhichSandbox = { accessSync, constants, console, delimiter, join, process: sandboxProcess }
|
||||
|
||||
runInNewContext(script, sandbox, { filename: sourcePath })
|
||||
|
||||
const nodeFallbackBunWhich = sandbox.__bunWhichShim?.bunWhich
|
||||
if (!nodeFallbackBunWhich) {
|
||||
throw new Error("Node fallback bunWhich loader failed")
|
||||
}
|
||||
|
||||
return nodeFallbackBunWhich
|
||||
}
|
||||
|
||||
describe("bunWhich", () => {
|
||||
test("#given 'node' command #when resolved #then returns a non-null path ending in 'node'", () => {
|
||||
const resolvedPath = bunWhich("node")
|
||||
|
||||
expect(resolvedPath).not.toBeNull()
|
||||
expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/)
|
||||
})
|
||||
|
||||
test("#given a non-existent command #when resolved #then returns null", () => {
|
||||
const resolvedPath = bunWhich("this-command-definitely-does-not-exist-abc123xyz")
|
||||
|
||||
expect(resolvedPath).toBeNull()
|
||||
})
|
||||
|
||||
test("#given an empty string #when resolved #then returns null", () => {
|
||||
const resolvedPath = bunWhich("")
|
||||
|
||||
expect(resolvedPath).toBeNull()
|
||||
})
|
||||
|
||||
test("#given the result for 'node' #when resolved #then the returned path matches Bun.which('node')", () => {
|
||||
const nativePath = runtime.Bun?.which("node")
|
||||
const shimPath = bunWhich("node")
|
||||
|
||||
expect(nativePath).not.toBeNull()
|
||||
expect(shimPath).toBe(nativePath)
|
||||
})
|
||||
|
||||
test("#given path-traversal command names #when resolved through Bun runtime #then returns null", () => {
|
||||
for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) {
|
||||
expect(bunWhich(commandName)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
test("#given a null-byte command name #when resolved through Bun runtime #then returns null", () => {
|
||||
expect(bunWhich(NULL_BYTE_COMMAND_NAME)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Node fallback bunWhich loaded without Bun global", () => {
|
||||
test("#when 'node' command is resolved #then returns a non-null path ending in 'node'", () => {
|
||||
const resolvedPath = NODE_FALLBACK_BUN_WHICH("node")
|
||||
|
||||
expect(resolvedPath).not.toBeNull()
|
||||
expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/)
|
||||
})
|
||||
|
||||
test("#when a non-existent command is resolved #then returns null", () => {
|
||||
const resolvedPath = NODE_FALLBACK_BUN_WHICH("this-does-not-exist-abc123xyz")
|
||||
|
||||
expect(resolvedPath).toBeNull()
|
||||
})
|
||||
|
||||
test("#when an empty string is resolved #then returns null", () => {
|
||||
const resolvedPath = NODE_FALLBACK_BUN_WHICH("")
|
||||
|
||||
expect(resolvedPath).toBeNull()
|
||||
})
|
||||
|
||||
test("#when path-traversal command names are resolved #then returns null", () => {
|
||||
for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) {
|
||||
expect(NODE_FALLBACK_BUN_WHICH(commandName)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
test("#when a null-byte command name is resolved #then returns null", () => {
|
||||
expect(NODE_FALLBACK_BUN_WHICH(NULL_BYTE_COMMAND_NAME)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { accessSync, constants } from "node:fs"
|
||||
import { delimiter, join } from "node:path"
|
||||
|
||||
type BunWhichRuntime = { which(commandName: string): string | null }
|
||||
const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime }
|
||||
const IS_BUN = typeof runtime.Bun !== "undefined"
|
||||
|
||||
function isUnsafeCommandName(commandName: string): boolean {
|
||||
if (commandName.includes("/") || commandName.includes("\\")) return true
|
||||
if (commandName === "." || commandName === ".." || commandName.includes("..")) return true
|
||||
if (/^[a-zA-Z]:/.test(commandName)) return true
|
||||
if (commandName.includes("\0")) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
accessSync(filePath, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathValue(): string | undefined {
|
||||
if (process.platform === "win32") return process.env.Path ?? process.env.PATH
|
||||
|
||||
return process.env.PATH
|
||||
}
|
||||
|
||||
function getWindowsCandidates(commandName: string): string[] {
|
||||
if (process.platform !== "win32") return [commandName]
|
||||
|
||||
return [commandName, `${commandName}.exe`, `${commandName}.cmd`, `${commandName}.bat`, `${commandName}.com`]
|
||||
}
|
||||
|
||||
export function bunWhich(commandName: string): string | null {
|
||||
if (!commandName) return null
|
||||
if (isUnsafeCommandName(commandName)) return null
|
||||
if (IS_BUN) return runtime.Bun?.which(commandName) ?? null
|
||||
|
||||
const pathValue = resolvePathValue()
|
||||
if (!pathValue) return null
|
||||
|
||||
const pathEntries = pathValue.split(delimiter).filter((pathEntry) => pathEntry.length > 0)
|
||||
if (pathEntries.length === 0) return null
|
||||
|
||||
const candidateNames = getWindowsCandidates(commandName)
|
||||
for (const pathEntry of pathEntries) {
|
||||
for (const candidateName of candidateNames) {
|
||||
const candidatePath = join(pathEntry, candidateName)
|
||||
if (isExecutable(candidatePath)) return candidatePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,9 +1,58 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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("
|
||||
const RAW_BUN_API_CALL = /(?<![.$\w])Bun\.[a-zA-Z_$][a-zA-Z_$0-9]*\s*[.(]/g
|
||||
const NODE_EXPORT_SMOKE_SCRIPT = [
|
||||
"const mod = await import('./dist/index.js');",
|
||||
"const keys = Object.keys(mod).join(',');",
|
||||
"console.log('SMOKE_OK:' + keys);",
|
||||
].join("\n")
|
||||
|
||||
function hasRawBunApiCall(line: string): boolean {
|
||||
RAW_BUN_API_CALL.lastIndex = 0
|
||||
return RAW_BUN_API_CALL.test(line)
|
||||
}
|
||||
|
||||
function isInsideStringLiteral(line: string, position: number): boolean {
|
||||
let quote: "'" | '"' | "`" | null = null
|
||||
let escaped = false
|
||||
|
||||
for (let index = 0; index < position; index += 1) {
|
||||
const char = line.charAt(index)
|
||||
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote !== null && char === "\\") {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
if (quote === char) {
|
||||
quote = null
|
||||
} else if (quote === null) {
|
||||
quote = char
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return quote !== null
|
||||
}
|
||||
|
||||
function formatOffendingLine(lineNumber: number, line: string): string {
|
||||
const content = line.trim()
|
||||
const truncated = content.length > 120 ? `${content.slice(0, 117)}...` : content
|
||||
|
||||
return `${lineNumber}: ${truncated}`
|
||||
}
|
||||
|
||||
describe("dist bundle Bun globals", () => {
|
||||
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => {
|
||||
@@ -62,4 +111,66 @@ describe("dist bundle Bun globals", () => {
|
||||
stderr: "",
|
||||
})
|
||||
})
|
||||
|
||||
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned for raw Bun runtime APIs #then no unshimmed Bun API calls remain", async () => {
|
||||
expect(hasRawBunApiCall("Bun.file('dist/index.js')")).toBe(true)
|
||||
expect(hasRawBunApiCall("runtime.Bun.file('dist/index.js')")).toBe(false)
|
||||
expect(hasRawBunApiCall(".Bun.file('dist/index.js')")).toBe(false)
|
||||
expect(hasRawBunApiCall("$Bun.file('dist/index.js')")).toBe(false)
|
||||
expect(hasRawBunApiCall("Bun.spawnSync.options")).toBe(true)
|
||||
expect(hasRawBunApiCall("Bun.readableStreamToText(stream)")).toBe(true)
|
||||
|
||||
const dist = await Bun.file(DIST_INDEX).text()
|
||||
const offending: string[] = []
|
||||
let insideJSDoc = false
|
||||
|
||||
for (const [index, line] of dist.split("\n").entries()) {
|
||||
const trimmed = line.trimStart()
|
||||
|
||||
if (insideJSDoc || trimmed.startsWith("/**")) {
|
||||
insideJSDoc = !trimmed.includes("*/")
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.includes("runtime.Bun") || line.includes("globalThis.Bun") || line.includes("typeof Bun")) {
|
||||
continue
|
||||
}
|
||||
|
||||
RAW_BUN_API_CALL.lastIndex = 0
|
||||
const rawMatch = [...line.matchAll(RAW_BUN_API_CALL)].find(
|
||||
(match) => match.index !== undefined && !isInsideStringLiteral(line, match.index),
|
||||
)
|
||||
|
||||
if (rawMatch) {
|
||||
offending.push(formatOffendingLine(index + 1, line))
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offending,
|
||||
`Expected zero raw Bun API calls in dist/index.js but found ${offending.length}:\n${offending.join("\n")}`,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when imported and inspected under node --input-type=module #then stderr has no Bun reference errors", async () => {
|
||||
const node = Bun.which("node")
|
||||
if (!node) return
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: [node, "--input-type=module", "-e", NODE_EXPORT_SMOKE_SCRIPT],
|
||||
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
|
||||
const stderrLower = stderr.toLowerCase()
|
||||
|
||||
expect(exitCode, stderr.trim()).toBe(0)
|
||||
expect(stdout).toContain("SMOKE_OK:")
|
||||
expect(stderrLower).not.toContain("referenceerror")
|
||||
expect(stderr).not.toContain("Bun is not defined")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "./event-session-id"
|
||||
|
||||
describe("event session id resolvers", () => {
|
||||
test("#given legacy message.part.updated properties #when resolving message session id #then part.sessionID is used", () => {
|
||||
const sessionID = resolveMessageEventSessionID({
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "ses-part-only",
|
||||
type: "text",
|
||||
text: "working",
|
||||
},
|
||||
})
|
||||
|
||||
expect(sessionID).toBe("ses-part-only")
|
||||
})
|
||||
|
||||
test("#given message.updated info id #when resolving message session id #then message id is not mistaken for session id", () => {
|
||||
const sessionID = resolveMessageEventSessionID({
|
||||
info: {
|
||||
id: "msg-not-session",
|
||||
role: "assistant",
|
||||
},
|
||||
})
|
||||
|
||||
expect(sessionID).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#given legacy session lifecycle properties #when resolving session id #then info.id is used", () => {
|
||||
const sessionID = resolveSessionEventID({
|
||||
info: {
|
||||
id: "ses-legacy-info-id",
|
||||
},
|
||||
})
|
||||
|
||||
expect(sessionID).toBe("ses-legacy-info-id")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isRecord } from "./record-type-guard"
|
||||
|
||||
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key]
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function resolveSessionEventID(properties: unknown): string | undefined {
|
||||
const props = isRecord(properties) ? properties : undefined
|
||||
const info = isRecord(props?.info) ? props.info : undefined
|
||||
return getStringField(props, "sessionID")
|
||||
?? getStringField(info, "sessionID")
|
||||
?? getStringField(info, "id")
|
||||
}
|
||||
|
||||
export function resolveMessageEventSessionID(properties: unknown): string | undefined {
|
||||
const props = isRecord(properties) ? properties : undefined
|
||||
const info = isRecord(props?.info) ? props.info : undefined
|
||||
const part = isRecord(props?.part) ? props.part : undefined
|
||||
return getStringField(props, "sessionID")
|
||||
?? getStringField(info, "sessionID")
|
||||
?? getStringField(part, "sessionID")
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export * from "./fallback-model-availability"
|
||||
export * from "./connected-providers-cache"
|
||||
export * from "./context-limit-resolver"
|
||||
export * from "./session-utils"
|
||||
export * from "./event-session-id"
|
||||
export * from "./tmux"
|
||||
export * from "./model-suggestion-retry"
|
||||
export * from "./opencode-server-auth"
|
||||
|
||||
+376
-274
@@ -1,291 +1,393 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import {
|
||||
isPortAvailable,
|
||||
findAvailablePort,
|
||||
getAvailableServerPort,
|
||||
DEFAULT_SERVER_PORT,
|
||||
} from "./port-utils"
|
||||
import { createServer, Server } from "node:net"
|
||||
import type { AddressInfo } from "node:net"
|
||||
import { networkInterfaces } from "node:os"
|
||||
|
||||
const HOSTNAME = "127.0.0.1"
|
||||
const REAL_PORT_SEARCH_WINDOW = 200
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"
|
||||
|
||||
function supportsRealSocketBinding(): boolean {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("probe"),
|
||||
import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils"
|
||||
|
||||
const DEFAULT_HOSTNAME = "127.0.0.1"
|
||||
const MAX_PORT_ATTEMPTS = 20
|
||||
const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1
|
||||
const CONTIGUOUS_SEARCH_WINDOW = 256
|
||||
const CONTIGUOUS_SEARCH_SEEDS = 8
|
||||
|
||||
const trackedServers = new Set<Server>()
|
||||
|
||||
type TimeoutProbeResult = {
|
||||
closeCallCount: number
|
||||
isAvailable: boolean
|
||||
server: Server | undefined
|
||||
}
|
||||
|
||||
function getRequiredPropertyDescriptor(target: object, propertyName: string): PropertyDescriptor {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, propertyName)
|
||||
if (!descriptor) {
|
||||
throw new Error(`Expected ${propertyName} property descriptor`)
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
|
||||
function isTcpAddress(address: ReturnType<Server["address"]>): address is AddressInfo {
|
||||
return typeof address === "object" && address !== null && "port" in address
|
||||
}
|
||||
|
||||
function getServerPort(server: Server): number {
|
||||
const address = server.address()
|
||||
if (!isTcpAddress(address)) {
|
||||
throw new Error("Expected TCP server address")
|
||||
}
|
||||
|
||||
return address.port
|
||||
}
|
||||
|
||||
function getAlternateIpv4Hostname(): string | undefined {
|
||||
for (const addresses of Object.values(networkInterfaces())) {
|
||||
if (!addresses) continue
|
||||
|
||||
for (const address of addresses) {
|
||||
if (address.family === "IPv4" && !address.internal && address.address !== DEFAULT_HOSTNAME) {
|
||||
return address.address
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function startTrackedServer(port: number, hostname: string = DEFAULT_HOSTNAME): Promise<Server> {
|
||||
return new Promise<Server>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
|
||||
const removeListeners = (): void => {
|
||||
server.removeListener("error", handleError)
|
||||
server.removeListener("listening", handleListening)
|
||||
}
|
||||
|
||||
const handleError = (error: Error): void => {
|
||||
removeListeners()
|
||||
trackedServers.delete(server)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const handleListening = (): void => {
|
||||
removeListeners()
|
||||
trackedServers.add(server)
|
||||
resolve(server)
|
||||
}
|
||||
|
||||
server.once("error", handleError)
|
||||
server.once("listening", handleListening)
|
||||
|
||||
try {
|
||||
server.listen(port, hostname)
|
||||
} catch (error) {
|
||||
removeListeners()
|
||||
trackedServers.delete(server)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeTrackedServer(server: Server): Promise<void> {
|
||||
trackedServers.delete(server)
|
||||
|
||||
if (!server.listening) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.close((error?: Error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
server.stop(true)
|
||||
})
|
||||
}
|
||||
|
||||
async function closeAllTrackedServers(): Promise<void> {
|
||||
await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server)))
|
||||
}
|
||||
|
||||
async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise<number> {
|
||||
const server = await startTrackedServer(0, hostname)
|
||||
const port = getServerPort(server)
|
||||
await closeTrackedServer(server)
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
async function canBindContiguousPorts(
|
||||
startPort: number,
|
||||
portCount: number,
|
||||
hostname: string = DEFAULT_HOSTNAME
|
||||
): Promise<boolean> {
|
||||
const servers: Server[] = []
|
||||
|
||||
try {
|
||||
for (let offset = 0; offset < portCount; offset++) {
|
||||
servers.push(await startTrackedServer(startPort + offset, hostname))
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
await Promise.all(servers.map((server) => closeTrackedServer(server)))
|
||||
}
|
||||
}
|
||||
|
||||
const canBindRealSockets = supportsRealSocketBinding()
|
||||
async function findContiguousAvailableStart(
|
||||
portCount: number,
|
||||
hostname: string = DEFAULT_HOSTNAME
|
||||
): Promise<number> {
|
||||
for (let seedAttempt = 0; seedAttempt < CONTIGUOUS_SEARCH_SEEDS; seedAttempt++) {
|
||||
const seedPort = await getReleasedPort(hostname)
|
||||
const maxStartPort = Math.min(65_535 - portCount + 1, seedPort + CONTIGUOUS_SEARCH_WINDOW)
|
||||
|
||||
describe("port-utils", () => {
|
||||
if (canBindRealSockets) {
|
||||
function startRealBlocker(port: number = 0) {
|
||||
return Bun.serve({
|
||||
port,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
}
|
||||
|
||||
async function findContiguousAvailableStart(length: number): Promise<number> {
|
||||
const probe = startRealBlocker()
|
||||
const seedPort = probe.port
|
||||
probe.stop(true)
|
||||
|
||||
for (let candidate = seedPort; candidate < seedPort + REAL_PORT_SEARCH_WINDOW; candidate++) {
|
||||
const checks = await Promise.all(
|
||||
Array.from({ length }, async (_, offset) => isPortAvailable(candidate + offset, HOSTNAME))
|
||||
)
|
||||
if (checks.every(Boolean)) {
|
||||
return candidate
|
||||
}
|
||||
for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) {
|
||||
if (await canBindContiguousPorts(candidatePort, portCount, hostname)) {
|
||||
return candidatePort
|
||||
}
|
||||
|
||||
throw new Error(`Could not find ${length} contiguous available ports`)
|
||||
}
|
||||
|
||||
describe("with real sockets", () => {
|
||||
describe("isPortAvailable", () => {
|
||||
it("#given unused port #when checking availability #then returns true", async () => {
|
||||
const blocker = startRealBlocker()
|
||||
const port = blocker.port
|
||||
blocker.stop(true)
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#given port in use #when checking availability #then returns false", async () => {
|
||||
const blocker = startRealBlocker()
|
||||
const port = blocker.port
|
||||
|
||||
try {
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(false)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("#given start port available #when finding port #then returns start port", async () => {
|
||||
const startPort = await findContiguousAvailableStart(1)
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
|
||||
it("#given start port blocked #when finding port #then returns next available", async () => {
|
||||
const startPort = await findContiguousAvailableStart(2)
|
||||
const blocker = startRealBlocker(startPort)
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 1)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
|
||||
const startPort = await findContiguousAvailableStart(4)
|
||||
const blockers = [
|
||||
startRealBlocker(startPort),
|
||||
startRealBlocker(startPort + 1),
|
||||
startRealBlocker(startPort + 2),
|
||||
]
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 3)
|
||||
} finally {
|
||||
blockers.forEach((blocker) => blocker.stop(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableServerPort", () => {
|
||||
it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
|
||||
const preferredPort = await findContiguousAvailableStart(1)
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(false)
|
||||
})
|
||||
|
||||
it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
|
||||
const preferredPort = await findContiguousAvailableStart(2)
|
||||
const blocker = startRealBlocker(preferredPort)
|
||||
|
||||
try {
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort + 1)
|
||||
expect(result.wasAutoSelected).toBe(true)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const blockedSockets = new Set<string>()
|
||||
let serveSpy: ReturnType<typeof spyOn>
|
||||
|
||||
function getSocketKey(port: number, hostname: string): string {
|
||||
return `${hostname}:${port}`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
blockedSockets.clear()
|
||||
serveSpy = spyOn(Bun, "serve").mockImplementation(({ port, hostname }) => {
|
||||
if (typeof port !== "number") {
|
||||
throw new Error("Test expected numeric port")
|
||||
}
|
||||
const resolvedHostname = typeof hostname === "string" ? hostname : HOSTNAME
|
||||
const socketKey = getSocketKey(port, resolvedHostname)
|
||||
|
||||
if (blockedSockets.has(socketKey)) {
|
||||
const error = new Error(`Failed to start server. Is port ${port} in use?`) as Error & {
|
||||
code?: string
|
||||
syscall?: string
|
||||
errno?: number
|
||||
address?: string
|
||||
port?: number
|
||||
}
|
||||
error.code = "EADDRINUSE"
|
||||
error.syscall = "listen"
|
||||
error.errno = 0
|
||||
error.address = resolvedHostname
|
||||
error.port = port
|
||||
throw error
|
||||
}
|
||||
|
||||
blockedSockets.add(socketKey)
|
||||
return {
|
||||
stop: (_force?: boolean) => {
|
||||
blockedSockets.delete(socketKey)
|
||||
},
|
||||
} as { stop: (force?: boolean) => void }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
expect(blockedSockets.size).toBe(0)
|
||||
serveSpy.mockRestore()
|
||||
blockedSockets.clear()
|
||||
})
|
||||
|
||||
describe("with mocked sockets fallback", () => {
|
||||
describe("isPortAvailable", () => {
|
||||
it("#given unused port #when checking availability #then returns true", async () => {
|
||||
const port = 59999
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(true)
|
||||
expect(blockedSockets.size).toBe(0)
|
||||
})
|
||||
|
||||
it("#given port in use #when checking availability #then returns false", async () => {
|
||||
const port = 59998
|
||||
const blocker = Bun.serve({
|
||||
port,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(false)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given custom hostname #when checking availability #then passes hostname through to Bun.serve", async () => {
|
||||
const hostname = "192.0.2.10"
|
||||
await isPortAvailable(59995, hostname)
|
||||
|
||||
expect(serveSpy.mock.calls[0]?.[0]?.hostname).toBe(hostname)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("#given start port available #when finding port #then returns start port", async () => {
|
||||
const startPort = 59997
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
|
||||
it("#given start port blocked #when finding port #then returns next available", async () => {
|
||||
const startPort = 59996
|
||||
const blocker = Bun.serve({
|
||||
port: startPort,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 1)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
|
||||
const startPort = 59993
|
||||
const blockers = [
|
||||
Bun.serve({ port: startPort, hostname: HOSTNAME, fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 1, hostname: HOSTNAME, fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 2, hostname: HOSTNAME, fetch: () => new Response() }),
|
||||
]
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 3)
|
||||
} finally {
|
||||
blockers.forEach((blocker) => blocker.stop(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableServerPort", () => {
|
||||
it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
|
||||
const preferredPort = 59990
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(false)
|
||||
})
|
||||
|
||||
it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
|
||||
const preferredPort = 59989
|
||||
const blocker = Bun.serve({
|
||||
port: preferredPort,
|
||||
hostname: HOSTNAME,
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort + 1)
|
||||
expect(result.wasAutoSelected).toBe(true)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("DEFAULT_SERVER_PORT", () => {
|
||||
it("#given constant #when accessed #then returns 4096", () => {
|
||||
throw new Error(`Could not find ${portCount} contiguous available ports`)
|
||||
}
|
||||
|
||||
async function startConsecutiveBlockers(
|
||||
startPort: number,
|
||||
portCount: number,
|
||||
hostname: string = DEFAULT_HOSTNAME
|
||||
): Promise<Server[]> {
|
||||
const servers: Server[] = []
|
||||
|
||||
try {
|
||||
for (let offset = 0; offset < portCount; offset++) {
|
||||
servers.push(await startTrackedServer(startPort + offset, hostname))
|
||||
}
|
||||
|
||||
return servers
|
||||
} catch (error) {
|
||||
await Promise.all(servers.map((server) => closeTrackedServer(server)))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function captureDefaultListenHostname(port: number): Promise<string | undefined> {
|
||||
const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen")
|
||||
const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close")
|
||||
let capturedHostname: string | undefined
|
||||
|
||||
Object.defineProperty(Server.prototype, "listen", {
|
||||
configurable: true,
|
||||
value: function listenAndCaptureHostname(this: Server, requestedPort: number, hostname?: string): Server {
|
||||
if (requestedPort === port) {
|
||||
capturedHostname = hostname
|
||||
}
|
||||
queueMicrotask(() => this.emit("listening"))
|
||||
return this
|
||||
},
|
||||
})
|
||||
Object.defineProperty(Server.prototype, "close", {
|
||||
configurable: true,
|
||||
value: function closeCapturedServer(this: Server, callback?: (error?: Error) => void): Server {
|
||||
queueMicrotask(() => callback?.())
|
||||
return this
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await isPortAvailable(port)
|
||||
return capturedHostname
|
||||
} finally {
|
||||
Object.defineProperty(Server.prototype, "listen", listenDescriptor)
|
||||
Object.defineProperty(Server.prototype, "close", closeDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
async function runTimedOutAvailabilityProbe(port: number): Promise<TimeoutProbeResult> {
|
||||
const setTimeoutDescriptor = getRequiredPropertyDescriptor(globalThis, "setTimeout")
|
||||
const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen")
|
||||
const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close")
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
let timedOutServer: Server | undefined
|
||||
let closeCallCount = 0
|
||||
|
||||
Object.defineProperty(globalThis, "setTimeout", {
|
||||
configurable: true,
|
||||
value: (callback: () => void): ReturnType<typeof setTimeout> => originalSetTimeout(callback, 0),
|
||||
})
|
||||
Object.defineProperty(Server.prototype, "listen", {
|
||||
configurable: true,
|
||||
value: function listenWithoutEmitting(this: Server): Server {
|
||||
timedOutServer = this
|
||||
return this
|
||||
},
|
||||
})
|
||||
Object.defineProperty(Server.prototype, "close", {
|
||||
configurable: true,
|
||||
value: function closeTimedOutServer(this: Server, callback?: (error?: Error) => void): Server {
|
||||
closeCallCount++
|
||||
queueMicrotask(() => callback?.())
|
||||
return this
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const isAvailable = await isPortAvailable(port)
|
||||
return { closeCallCount, isAvailable, server: timedOutServer }
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "setTimeout", setTimeoutDescriptor)
|
||||
Object.defineProperty(Server.prototype, "listen", listenDescriptor)
|
||||
Object.defineProperty(Server.prototype, "close", closeDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
describe("port-utils", () => {
|
||||
beforeAll(() => {
|
||||
trackedServers.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await closeAllTrackedServers()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await closeAllTrackedServers()
|
||||
})
|
||||
|
||||
describe("#given isPortAvailable", () => {
|
||||
test("#when a released port is checked #then returns true", async () => {
|
||||
const port = await getReleasedPort()
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("#when an already bound port is checked #then returns false", async () => {
|
||||
const blocker = await startTrackedServer(0)
|
||||
const port = getServerPort(blocker)
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("#when a timed out probe is cleaned up #then no listeners or server remain active", async () => {
|
||||
const port = await getReleasedPort()
|
||||
|
||||
const result = await runTimedOutAvailabilityProbe(port)
|
||||
|
||||
expect(result.isAvailable).toBe(false)
|
||||
expect(result.closeCallCount).toBe(1)
|
||||
expect(result.server).toBeDefined()
|
||||
if (!result.server) {
|
||||
throw new Error("Expected timed out server")
|
||||
}
|
||||
expect(result.server.listening).toBe(false)
|
||||
expect(result.server.listenerCount("error")).toBe(0)
|
||||
expect(result.server.listenerCount("listening")).toBe(0)
|
||||
})
|
||||
|
||||
test("#when a successful probe finishes #then the port can be rebound immediately", async () => {
|
||||
const port = await getReleasedPort()
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
const server = await startTrackedServer(port)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(getServerPort(server)).toBe(port)
|
||||
})
|
||||
|
||||
test("#when hostname is omitted #then 127.0.0.1 is the default target", async () => {
|
||||
const blocker = await startTrackedServer(0, DEFAULT_HOSTNAME)
|
||||
const port = getServerPort(blocker)
|
||||
|
||||
const result = await isPortAvailable(port)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("#when another interface owns the port #then default probing does not bind all interfaces", async () => {
|
||||
const alternateHostname = getAlternateIpv4Hostname()
|
||||
|
||||
if (!alternateHostname) {
|
||||
const port = await getReleasedPort()
|
||||
const capturedHostname = await captureDefaultListenHostname(port)
|
||||
expect(capturedHostname).toBe(DEFAULT_HOSTNAME)
|
||||
return
|
||||
}
|
||||
|
||||
const blocker = await startTrackedServer(0, alternateHostname)
|
||||
const port = getServerPort(blocker)
|
||||
|
||||
expect(await isPortAvailable(port)).toBe(true)
|
||||
expect(await isPortAvailable(port, alternateHostname)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given findAvailablePort", () => {
|
||||
test("#when the start port is available #then returns the start port", async () => {
|
||||
const startPort = await findContiguousAvailableStart(1)
|
||||
|
||||
const result = await findAvailablePort(startPort)
|
||||
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
|
||||
test("#when the first three ports are blocked #then returns the next free port", async () => {
|
||||
const startPort = await findContiguousAvailableStart(4)
|
||||
await startConsecutiveBlockers(startPort, 3)
|
||||
|
||||
const result = await findAvailablePort(startPort)
|
||||
|
||||
expect(result).toBe(startPort + 3)
|
||||
})
|
||||
|
||||
test("#when every attempted port is blocked #then throws", async () => {
|
||||
const startPort = await findContiguousAvailableStart(EXHAUSTED_PORT_COUNT)
|
||||
await startConsecutiveBlockers(startPort, EXHAUSTED_PORT_COUNT)
|
||||
|
||||
let errorMessage: string | undefined
|
||||
try {
|
||||
await findAvailablePort(startPort)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error
|
||||
}
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
expect(errorMessage).toBe(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given getAvailableServerPort", () => {
|
||||
test("#when the preferred port is free #then returns the preferred port without auto-selection", async () => {
|
||||
const preferredPort = await findContiguousAvailableStart(1)
|
||||
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
|
||||
expect(result).toEqual({ port: preferredPort, wasAutoSelected: false })
|
||||
})
|
||||
|
||||
test("#when the preferred port is blocked #then returns the next port with auto-selection", async () => {
|
||||
const preferredPort = await findContiguousAvailableStart(2)
|
||||
await startTrackedServer(preferredPort)
|
||||
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result).toEqual({ port: preferredPort + 1, wasAutoSelected: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given DEFAULT_SERVER_PORT", () => {
|
||||
test("#when accessed #then returns 4096", () => {
|
||||
expect(DEFAULT_SERVER_PORT).toBe(4096)
|
||||
})
|
||||
})
|
||||
|
||||
+45
-10
@@ -1,18 +1,53 @@
|
||||
import { createServer } from "node:net"
|
||||
|
||||
const DEFAULT_SERVER_PORT = 4096
|
||||
const MAX_PORT_ATTEMPTS = 20
|
||||
const PORT_CHECK_TIMEOUT_MS = 2000
|
||||
|
||||
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname,
|
||||
fetch: () => new Response(),
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const server = createServer()
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
let resolved = false
|
||||
|
||||
const finish = (isAvailable: boolean): void => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
server.removeAllListeners("error")
|
||||
server.removeAllListeners("listening")
|
||||
resolve(isAvailable)
|
||||
}
|
||||
|
||||
const closeThenFinish = (isAvailable: boolean): void => {
|
||||
try {
|
||||
server.close(() => finish(isAvailable))
|
||||
} catch {
|
||||
finish(isAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
closeThenFinish(false)
|
||||
}, PORT_CHECK_TIMEOUT_MS)
|
||||
|
||||
server.once("error", () => {
|
||||
finish(false)
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
server.once("listening", () => {
|
||||
closeThenFinish(true)
|
||||
})
|
||||
|
||||
try {
|
||||
server.listen(port, hostname)
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function findAvailablePort(
|
||||
|
||||
Reference in New Issue
Block a user