aboutsummaryrefslogtreecommitdiff
path: root/src/js/internal/repl.ts
diff options
context:
space:
mode:
authorGravatar jhmaster2000 <32803471+jhmaster2000@users.noreply.github.com> 2023-09-07 00:32:39 -0300
committerGravatar jhmaster2000 <32803471+jhmaster2000@users.noreply.github.com> 2023-09-07 00:32:39 -0300
commit7eba3229fe8ed0f7ef01adbc5435ee061ec705cd (patch)
treedaae6c21ae41f655555df2e38a8280845ea91a75 /src/js/internal/repl.ts
parent5fe13cdaac984575311e2a3192e739e5d7850b26 (diff)
downloadbun-repl.tar.gz
bun-repl.tar.zst
bun-repl.zip
initial bun repl revisionrepl
Diffstat (limited to 'src/js/internal/repl.ts')
-rw-r--r--src/js/internal/repl.ts423
1 files changed, 423 insertions, 0 deletions
diff --git a/src/js/internal/repl.ts b/src/js/internal/repl.ts
new file mode 100644
index 000000000..7cca39e48
--- /dev/null
+++ b/src/js/internal/repl.ts
@@ -0,0 +1,423 @@
+import { type JSC } from '../../../packages/bun-inspector-protocol';
+const { join } = require('node:path') as typeof import('node:path');
+const os = require('node:os') as typeof import('node:os');
+const util = require('node:util') as typeof import('node:util');
+const readline = require('node:readline/promises') as typeof import('node:readline/promises');
+const { serve } = Bun;
+const { exit } = process;
+
+const { Buffer, WebSocket, Map, EvalError } = globalThis;
+const Promise: PromiseConstructor<any> = globalThis.Promise; // TS bug?
+const { isBuffer } = Buffer;
+const JSONParse = JSON.parse;
+const JSONStringify = JSON.stringify;
+const ObjectAssign = Object.assign;
+const BufferToString = Function.prototype.call.bind(Buffer.prototype.toString) as Primordial<Buffer, 'toString'>;
+const StringTrim = Function.prototype.call.bind(String.prototype.trim) as Primordial<String, 'trim'>;
+const StringPrototypeSplit = Function.prototype.call.bind(String.prototype.split) as Primordial<String, 'split'>;
+const StringPrototypeIncludes = Function.prototype.call.bind(String.prototype.includes) as Primordial<String, 'includes'>;
+const StringPrototypeReplaceAll = Function.prototype.call.bind(String.prototype.replaceAll) as Primordial<String, 'replaceAll'>;
+const ArrayPrototypePop = Function.prototype.call.bind(Array.prototype.pop) as Primordial<Array<any>, 'pop'>;
+const ArrayPrototypeJoin = Function.prototype.call.bind(Array.prototype.join) as Primordial<Array<any>, 'join'>;
+const MapGet = Function.prototype.call.bind(Map.prototype.get) as Primordial<Map<any, any>, 'get'>;
+const MapSet = Function.prototype.call.bind(Map.prototype.set) as Primordial<Map<any, any>, 'set'>;
+const MapDelete = Function.prototype.call.bind(Map.prototype.delete) as Primordial<Map<any, any>, 'delete'>;
+const console = {
+ log: globalThis.console.log,
+ info: globalThis.console.info,
+ warn: globalThis.console.warn,
+ error: globalThis.console.error,
+};
+
+type Primordial<T, M extends keyof T> = <S extends T>(
+ self: S, ...args: Parameters<S[M] extends (...args: any) => any ? S[M] : never>
+) => ReturnType<S[M] extends (...args: any) => any ? S[M] : never>;
+type JSCResponsePromiseCallbacks = {
+ resolve: <T extends JSC.ResponseMap[keyof JSC.ResponseMap]>(value: T) => void;
+ reject: (reason: {
+ code?: string | undefined;
+ message: string;
+ }) => void;
+};
+type EvalRemoteObject = JSC.Runtime.RemoteObject & { wasAwaited?: boolean; wasThrown?: boolean; };
+type RemoteObjectType = EvalRemoteObject['type'];
+type RemoteObjectSubtype = NonNullable<EvalRemoteObject['subtype']>;
+type TypeofToValueType<T extends RemoteObjectType> =
+ T extends 'string' ? { type: T, value: string; } :
+ T extends 'number' ? { type: T, value: number, description: string; } :
+ T extends 'bigint' ? { type: T, description: string; } :
+ T extends 'boolean' ? { type: T, value: boolean; } :
+ T extends 'symbol' ? { type: T, objectId: string, className: string, description: string; } :
+ T extends 'undefined' ? { type: T; } :
+ T extends 'object' ? { type: T, subtype?: RemoteObjectSubtype, objectId: string, className: string, description: string; } :
+ T extends 'function' ? { type: T, subtype?: RemoteObjectSubtype, objectId: string, className: string, description: string; } : never;
+type SubtypeofToValueType<T extends RemoteObjectSubtype, BaseObj = { type: 'object', subtype: T, objectId: string, className: string, description: string; }> =
+ T extends 'error' ? BaseObj :
+ T extends 'array' ? BaseObj & { size: number; } :
+ T extends 'null' ? { type: 'object', subtype: T, value: null; } :
+ T extends 'regexp' ? BaseObj :
+ T extends 'date' ? BaseObj :
+ T extends 'map' ? BaseObj & { size: number; } :
+ T extends 'set' ? BaseObj & { size: number; } :
+ T extends 'weakmap' ? BaseObj & { size: number; } :
+ T extends 'weakset' ? BaseObj & { size: number; } :
+ T extends 'iterator' ? never /*//!error*/ :
+ T extends 'class' ? { type: 'function', subtype: T, objectId: string, className: string, description: string, classPrototype: JSC.Runtime.RemoteObject; } :
+ T extends 'proxy' ? BaseObj :
+ T extends 'weakref' ? BaseObj : never;
+
+/** Convert a {@link WebSocket.onmessage} `event.data` value to a string. */
+function wsDataToString(data: Parameters<NonNullable<WebSocket['onmessage']>>[0]['data']): string {
+ //if (data instanceof ArrayBuffer) return new TextDecoder('utf-8').decode(data);
+ if (data instanceof Buffer || isBuffer(data)) return BufferToString(data, 'utf-8');
+ else return data;
+}
+
+// Note: This is a custom REPLServer, not the Node.js node:repl module one.
+class REPLServer extends WebSocket {
+ constructor() {
+ const server = serve({
+ inspector: true,
+ development: true,
+ // @ts-expect-error stub
+ fetch() { },
+ });
+ super(`ws://${server.hostname}:${server.port}/bun:inspect`);
+ this.onmessage = (event) => {
+ try {
+ const data = JSONParse(wsDataToString(event.data)) as JSC.Response<keyof JSC.ResponseMap>;
+ const { id } = data;
+ const promiseRef = MapGet(this.#pendingReqs, id);
+ if (promiseRef) {
+ MapDelete(this.#pendingReqs, id);
+ if ('error' in data) promiseRef.reject(data.error);
+ else if ('result' in data) promiseRef.resolve(data.result);
+ else throw `Received response with no result or error: ${id}`;
+ } else throw `Received message for unknown request ID: ${id}`;
+ } catch (err) {
+ console.error(`[ws/message] An unexpected error occured:`, err, '\nReceived Data:', event.data);
+ }
+ };
+ this.onclose = () => console.info('[ws/close] disconnected');
+ this.onerror = (error) => console.error('[ws/error]', error);
+ }
+
+ /** Incrementing current request ID */
+ #reqID = 0;
+ /** Object ID of the global object */
+ #globalObjectID!: string;
+ /** Queue of pending requests promises to resolve, mapped by request ID */
+ readonly #pendingReqs = new Map<number, JSCResponsePromiseCallbacks>();
+ /** Must be awaited before using the REPLServer */
+ readonly ready = new Promise<void>(resolve => {
+ // It's okay to not use primordials here since this only runs once before users can use the REPL
+ this.onopen = () => void this.request('Runtime.enable', {})
+ .then(() => this.rawEval('globalThis'))
+ .then(({ result }) => {
+ this.#globalObjectID = result.objectId!;
+ globalThis._ = undefined;
+ globalThis._error = undefined;
+ Object.defineProperty(globalThis, '#Symbol.for', { value: Symbol.for });
+ Object.defineProperty(globalThis, Symbol.for('#bun.repl.internal'), {
+ value: Object.freeze(Object.defineProperties(Object.create(null), {
+ util: { value: Object.freeze(util) },
+ })),
+ });
+ Object.freeze(globalThis['#bun.repl.internal']);
+ Object.freeze(Promise); // must preserve .name property
+ Object.freeze(Promise.prototype); // too many possible pitfalls
+ //? Workarounds for bug: https://canary.discord.com/channels/876711213126520882/888839314056839309/1120394929164779570
+ const TypedArray = Object.getPrototypeOf(Uint8Array);
+ const wrapIterator = (iterable: Record<string | symbol, any>, key: string | symbol = Symbol.iterator, name = iterable.name + ' Iterator') => {
+ const original = iterable.prototype[key];
+ iterable.prototype[key] = function (...argz: any[]) {
+ const thiz = this;
+ function* wrappedIter() { yield* original.apply(thiz, argz); }
+ return Object.defineProperty(wrappedIter(), Symbol.toStringTag, { value: name, configurable: true });
+ };
+ };
+ wrapIterator(Array);
+ wrapIterator(Array, 'keys');
+ wrapIterator(Array, 'values');
+ wrapIterator(Array, 'entries');
+ wrapIterator(TypedArray, Symbol.iterator, 'Array Iterator');
+ wrapIterator(TypedArray, 'entries', 'Array Iterator');
+ wrapIterator(TypedArray, 'values', 'Array Iterator');
+ wrapIterator(TypedArray, 'keys', 'Array Iterator');
+ wrapIterator(String);
+ wrapIterator(Map);
+ wrapIterator(Map, 'keys');
+ wrapIterator(Map, 'values');
+ wrapIterator(Map, 'entries');
+ wrapIterator(Set);
+ wrapIterator(Set, 'keys');
+ wrapIterator(Set, 'values');
+ wrapIterator(Set, 'entries');
+
+ resolve();
+ });
+ });
+
+ /** Check and assert typeof for a remote object */
+ typeof<T extends RemoteObjectType>(v: JSC.Runtime.RemoteObject, expected: T):
+ v is Omit<JSC.Runtime.RemoteObject, 'value'> & TypeofToValueType<T> {
+ return v.type === expected;
+ }
+ /** Check and assert subtypeof for a remote object */
+ subtypeof<T extends RemoteObjectSubtype>(v: JSC.Runtime.RemoteObject, expected: T):
+ v is Omit<JSC.Runtime.RemoteObject, 'value'> & SubtypeofToValueType<T> {
+ return v.subtype === expected;
+ }
+ /** Send a direct request to the inspector */
+ request<T extends keyof JSC.RequestMap>(method: T, params: JSC.RequestMap[T]) {
+ const req: JSC.Request<T> = { id: ++this.#reqID, method, params };
+ const response = new Promise<JSC.ResponseMap[T]>((resolve, reject) => {
+ MapSet(this.#pendingReqs, this.#reqID, { resolve: resolve as typeof resolve extends Promise<infer P> ? P : never, reject });
+ }).catch(err => { throw ObjectAssign(new Error, err); });
+ this.send(JSONStringify(req));
+ return response;
+ }
+ /** Direct shortcut for a `Runtime.evaluate` request */
+ async rawEval(code: string): Promise<JSC.Runtime.EvaluateResponse> {
+ return this.request('Runtime.evaluate', {
+ expression: code,
+ generatePreview: true
+ });
+ }
+ /** Run a snippet of code in the REPL */
+ async eval(code: string, topLevelAwaited = false): Promise<string> {
+ const { result, wasThrown } = await this.rawEval(code);
+ let remoteObj: EvalRemoteObject = result;
+
+ switch (result.type) {
+ case 'object': {
+ if (result.subtype === 'null') break;
+ if (!result.objectId) throw new EvalError(`Received non-null object without objectId: ${JSONStringify(result)}`);
+ if (result.className === 'Promise' && topLevelAwaited) {
+ if (!result.preview) throw new EvalError(`Received Promise object without preview: ${JSONStringify(result)}}`);
+ const awaited = await this.request('Runtime.awaitPromise', { promiseObjectId: result.objectId, generatePreview: false });
+ remoteObj = awaited.result;
+ remoteObj.wasAwaited = true;
+ break;
+ }
+ break;
+ }
+ default: break;
+ }
+
+ const inspected = await this.request('Runtime.callFunctionOn', {
+ objectId: this.#globalObjectID,
+ functionDeclaration: /* js */`(v) => {
+ if (!${wasThrown}) this._ = v;
+ else this._error = v;
+ const { util } = this[this['#Symbol.for']('#bun.repl.internal')];
+ if (${remoteObj.subtype === 'error'}) return Bun.inspect(v, { colors: true });
+ return util.inspect(v, { colors: true }/*util.inspect.replDefaults*/);
+ }`,
+ arguments: [remoteObj],
+ });
+ if (inspected.wasThrown) throw new EvalError(`Failed to inspect object: ${JSONStringify(inspected)}`);
+ if (!this.typeof(inspected.result, 'string')) throw new EvalError(`Received non-string inspect result: ${JSONStringify(inspected)}`);
+ if (wasThrown && remoteObj.subtype !== 'error') return c.red + 'Uncaught ' + c.reset + inspected.result.value;
+ return inspected.result.value;
+ }
+}
+
+/** Terminal colors */
+const c = {
+ bold: '\x1B[1m',
+ dim: '\x1B[2m',
+ underline: '\x1B[4m',
+ /** Not widely supported! */
+ blink: '\x1B[5m',
+ invert: '\x1B[7m',
+ invisible: '\x1B[8m',
+
+ reset: '\x1B[0m',
+ //noBold: '\x1B[21m', (broken)
+ noDim: '\x1B[22m',
+ noUnderline: '\x1B[24m',
+ noBlink: '\x1B[25m',
+ noInvert: '\x1B[27m',
+ visible: '\x1B[28m',
+
+ black: '\x1B[30m',
+ red: '\x1B[31m',
+ green: '\x1B[32m',
+ yellow: '\x1B[33m',
+ blue: '\x1B[34m',
+ purple: '\x1B[35m',
+ cyan: '\x1B[36m',
+ white: '\x1B[37m',
+ gray: '\x1B[90m',
+ redBright: '\x1B[91m',
+ greenBright: '\x1B[92m',
+ yellowBright: '\x1B[93m',
+ blueBright: '\x1B[94m',
+ purpleBright: '\x1B[95m',
+ cyanBright: '\x1B[96m',
+ whiteBright: '\x1B[97m',
+} as const;
+/** Terminal background colors */
+const bg = {
+ black: '\x1B[40m',
+ red: '\x1B[41m',
+ green: '\x1B[42m',
+ yellow: '\x1B[43m',
+ blue: '\x1B[44m',
+ purple: '\x1B[45m',
+ cyan: '\x1B[46m',
+ white: '\x1B[47m',
+ gray: '\x1B[100m',
+ redBright: '\x1B[101m',
+ greenBright: '\x1B[102m',
+ yellowBright: '\x1B[103m',
+ blueBright: '\x1B[104m',
+ purpleBright: '\x1B[105m',
+ cyanBright: '\x1B[106m',
+ whiteBright: '\x1B[107m',
+} as const;
+if (!Bun.enableANSIColors) {
+ for (const color in c) Reflect.set(c, color, '');
+ for (const color in bg) Reflect.set(bg, color, '');
+}
+
+export default {
+ async start() {
+ try {
+ const repl = new REPLServer();
+ await repl.ready;
+ const history = await loadHistoryData();
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: true,
+ tabSize: 4,
+ prompt: '> ',
+ historySize: 1000,
+ history: history.lines,
+ // completions currently cause a panic "FilePoll.register failed: 17"
+ //completer(line: string) {
+ // const completions = ['hello', 'world'];
+ // const hits = completions.filter(c => c.startsWith(line));
+ // return [hits.length ? hits : completions, line];
+ //}
+ });
+ // TODO: How to make transpiler not dead-code-eliminate lone constants like "5"?
+ const transpiler = new Bun.Transpiler({
+ target: 'bun',
+ loader: 'ts',
+ minifyWhitespace: false,
+ trimUnusedImports: false,
+ treeShaking: false,
+ inline: false,
+ jsxOptimizationInline: false,
+ });
+ console.log(`Welcome to Bun v${Bun.version}\nType ".help" for more information.`);
+ //* Only primordials should be used beyond this point!
+ rl.on('close', () => {
+ Bun.write(history.path, history.lines.filter(l => l !== '.exit').join('\n'))
+ .catch(() => console.warn(`[!] Failed to save REPL history to ${history.path}!`));
+ console.log(''); // ensure newline
+ exit(0);
+ });
+ rl.on('history', newHistory => {
+ history.lines = newHistory;
+ });
+ rl.prompt();
+ rl.on('line', async line => {
+ line = StringTrim(line);
+ if (!line) return rl.prompt();
+ if (line[0] === '.') {
+ switch (line) {
+ case '.help': {
+ console.log(
+ `Commands & keybinds:\n` +
+ ` .help Show this help message.\n` +
+ ` .info Print extra REPL information.\n` +
+ ` .clear Clear the screen. ${c.gray}(Ctrl+L)${c.reset}\n` +
+ ` .exit Exit the REPL. ${c.gray}(Ctrl+C / Ctrl+D)${c.reset}`
+ );
+ } break;
+ case '.info': {
+ console.log(
+ `Bun v${Bun.version} ${c.gray}(${Bun.revision})${c.reset}\n` +
+ ` Color mode: ${Bun.enableANSIColors ? `${c.greenBright}Enabled` : 'Disabled'}${c.reset}`
+ );
+ } break;
+ case '.clear': {
+ rl.write(null, { ctrl: true, name: 'l' });
+ } break;
+ case '.exit': {
+ rl.close();
+ } break;
+ default: {
+ console.log(
+ `${c.red}Unknown REPL command "${c.whiteBright}${line}${c.red}", ` +
+ `type "${c.whiteBright}.help${c.red}" for more information.${c.reset}`
+ );
+ } break;
+ }
+ } else {
+ let code: string;
+ try {
+ code = transpiler.transformSync(line);
+ } catch (err) {
+ console.error(err); return;
+ }
+ let hasTLA = false;
+ if (StringPrototypeIncludes(code, 'await')) {
+ hasTLA = true;
+ code = tryProcessTopLevelAwait(code);
+ }
+ console.log(await repl.eval(/* ts */`${code}`, hasTLA));
+ }
+ rl.prompt();
+ });
+ } catch (err) {
+ console.error('Internal REPL Error:');
+ console.error(err, '\nThis should not happen! Search GitHub issues https://bun.sh/issues or ask for #help in https://bun.sh/discord');
+ exit(1);
+ }
+ }
+};
+
+async function loadHistoryData(): Promise<{ path: string, lines: string[] }> {
+ let out: { path: string; lines: string[]; } | null;
+ if (process.env.XDG_DATA_HOME && (out = await tryLoadHistory(process.env.XDG_DATA_HOME, 'bun'))) return out;
+ else if (process.env.BUN_INSTALL && (out = await tryLoadHistory(process.env.BUN_INSTALL))) return out;
+ else {
+ const homedir = os.homedir();
+ return await tryLoadHistory(homedir) ?? { path: join(homedir, '.bun_repl_history'), lines: [] };
+ }
+}
+async function tryLoadHistory(...dir: string[]) {
+ const path = join(...dir, '.bun_repl_history');
+ try {
+ const file = Bun.file(path);
+ if (!await file.exists()) await Bun.write(path, '');
+ return { path, lines: (await file.text()).split('\n') };
+ } catch (err) {
+ //console.log(path, err);
+ return null;
+ }
+}
+
+// This only supports the most basic var/let/const declarations
+const JSVarDeclRegex = /(?<keyword>var|let|const)\s+(?<varname>(?:[$_\p{ID_Start}]|\\u[\da-fA-F]{4})(?:[$\u200C\u200D\p{ID_Continue}]|\\u[\da-fA-F]{4})*)/gu;
+
+// Wrap the code in an async function if it contains top level await
+// Make sure to return the result of the last expression
+function tryProcessTopLevelAwait(src: string) {
+ const lines = StringPrototypeSplit(src, '\n' as any);
+ if (!StringTrim(lines[lines.length - 1])) ArrayPrototypePop(lines);
+ lines[lines.length - 1] = 'return ' + lines[lines.length - 1] + ';})();';
+ lines[0] = '(async()=>{' + lines[0];
+ const transformed = StringPrototypeReplaceAll(ArrayPrototypeJoin(lines, '\n'), JSVarDeclRegex, (m, _1, _2, idx, str, groups) => {
+ const { keyword, varname } = groups;
+ lines[0] = `${keyword === 'const' ? 'let' : keyword} ${varname};${lines[0]}`; // hoist
+ return varname;
+ });
+ //console.info('TLA transform executed:\n', src, '\n>>> to >>>\n', transformed);
+ return transformed;
+}