diff --git a/src/js/node/repl.ts b/src/js/node/repl.ts index 73495f10104f..85e2c7759f7f 100644 --- a/src/js/node/repl.ts +++ b/src/js/node/repl.ts @@ -2,6 +2,7 @@ // This is a stub! None of this is actually implemented yet. // It only exists to make some packages which import this module work. const { throwNotImplemented } = require("internal/shared"); +const { inspect } = require("node:util"); const builtinModules = [ "bun", @@ -63,62 +64,40 @@ const builtinModules = [ "node:test", ]; +const REPL_MODE_SLOPPY = Symbol("repl-sloppy"); +const REPL_MODE_STRICT = Symbol("repl-strict"); + +function start() { + throwNotImplemented("node:repl", 28478); +} + +function REPLServer() { + throwNotImplemented("node:repl REPLServer", 28478); +} + +class Recoverable extends SyntaxError { + err; + constructor(err) { + super(); + this.err = err; + } +} + +function writer(obj) { + return inspect(obj, writer.options); +} +writer.options = { ...inspect.replDefaults }; + +// The module-level exports match Node's `require("node:repl")` shape. Instance +// fields like `context`/`terminal`/`useGlobal` belong to a REPLServer instance, +// not this module, so they are intentionally absent. export default { - lines: [], - context: globalThis, - historyIndex: -1, - cursor: 0, - historySize: 1000, - removeHistoryDuplicates: false, - crlfDelay: 100, - completer: () => { - throwNotImplemented("node:repl"); - }, - history: [], - _initialPrompt: "> ", - terminal: true, - input: new Proxy( - {}, - { - get() { - throwNotImplemented("node:repl"); - }, - has: () => false, - ownKeys: () => [], - getOwnPropertyDescriptor: () => undefined, - set() { - throwNotImplemented("node:repl"); - }, - }, - ), - line: "", - eval: () => { - throwNotImplemented("node:repl"); - }, - isCompletionEnabled: true, - escapeCodeTimeout: 500, - tabSize: 8, - breakEvalOnSigint: true, - useGlobal: true, - underscoreAssigned: false, - last: undefined, - _domain: undefined, - allowBlockingCompletions: false, - useColors: true, - output: new Proxy( - {}, - { - get() { - throwNotImplemented("node:repl"); - }, - has: () => false, - ownKeys: () => [], - getOwnPropertyDescriptor: () => undefined, - set() { - throwNotImplemented("node:repl"); - }, - }, - ), + start, + REPLServer, + Recoverable, + REPL_MODE_SLOPPY, + REPL_MODE_STRICT, + writer, _builtinLibs: builtinModules, builtinModules: builtinModules, }; diff --git a/test/js/node/stubs.test.js b/test/js/node/stubs.test.js index c1da1cf9104e..bdd822dfbccc 100644 --- a/test/js/node/stubs.test.js +++ b/test/js/node/stubs.test.js @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import repl from "node:repl"; +import { inspect } from "node:util"; const weirdInternalSpecifiers = [ "_http_agent", @@ -114,6 +117,87 @@ describe("v8.getHeapStatistics", () => { } }); +describe("node:repl stub", () => { + // The module export used to masquerade as a REPLServer instance with + // `context: globalThis`, so libraries that feature-detect a REPL by probing + // `repl.context` and writing to it would silently pollute the real global. + test("does not expose REPLServer instance fields on the module", () => { + const instanceFields = ["context", "terminal", "useGlobal", "lines", "history", "input", "output", "eval"]; + expect(Object.fromEntries(instanceFields.map(k => [k, { in: k in repl, value: repl[k] }]))).toEqual( + Object.fromEntries(instanceFields.map(k => [k, { in: false, value: undefined }])), + ); + }); + + test("writing through repl.context cannot pollute globalThis", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const repl = require("node:repl"); + let threw = false; + try { + repl.context.__pollutedByReplStub = 42; + } catch { + threw = true; + } + console.log(JSON.stringify({ + threw, + contextIsGlobalThis: repl.context === globalThis, + polluted: globalThis.__pollutedByReplStub, + })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: { + threw: true, + contextIsGlobalThis: false, + polluted: undefined, + }, + stderr: expect.any(String), + exitCode: 0, + }); + }); + + test("exposes Node's module-level exports", () => { + expect(typeof repl.start).toBe("function"); + expect(typeof repl.REPLServer).toBe("function"); + expect(typeof repl.Recoverable).toBe("function"); + expect(typeof repl.writer).toBe("function"); + expect(typeof repl.REPL_MODE_SLOPPY).toBe("symbol"); + expect(typeof repl.REPL_MODE_STRICT).toBe("symbol"); + expect(repl.REPL_MODE_SLOPPY).not.toBe(repl.REPL_MODE_STRICT); + expect(Array.isArray(repl._builtinLibs)).toBe(true); + expect(Array.isArray(repl.builtinModules)).toBe(true); + }); + + test("writer() forwards to util.inspect with writer.options", () => { + const value = { a: 1, b: [2, 3], c: { d: 4 } }; + expect(repl.writer.options).toEqual(inspect.replDefaults); + expect(repl.writer(value)).toBe(inspect(value, repl.writer.options)); + }); + + test("start() throws ERR_NOT_IMPLEMENTED", () => { + expect(() => repl.start()).toThrow(expect.objectContaining({ code: "ERR_NOT_IMPLEMENTED" })); + }); + + test("REPLServer() throws ERR_NOT_IMPLEMENTED", () => { + expect(() => new repl.REPLServer()).toThrow(expect.objectContaining({ code: "ERR_NOT_IMPLEMENTED" })); + }); + + test("Recoverable wraps an error", () => { + const cause = new SyntaxError("boom"); + const r = new repl.Recoverable(cause); + expect(r).toBeInstanceOf(SyntaxError); + expect(r.err).toBe(cause); + }); +}); + describe("v8.startupSnapshot", () => { // https://github.com/oven-sh/bun/issues/32501 test("isBuildingSnapshot() returns false", () => {