From 190475aabb325ff17a4a067bb173b6376cd98b71 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:54:47 +0000 Subject: [PATCH 1/2] prompt: type the default and the result in the schema prompt.bind.ts declares the default as `b.string.loose.optional` and the result as `b.string.nullable`. The generated code converts the message and then the default before the body runs. The body takes and returns `Option`. An empty line returns the default as a string, and the empty string when no default is given. `null` is returned only at the end of the input. Before, an empty line returned the second argument as it was, and `null` without one. The example in docs/runtime/secrets.mdx stops on an empty answer before it stores the token. --- docs/runtime/secrets.mdx | 1 + src/runtime/webcore/prompt.bind.ts | 8 ++- src/runtime/webcore/prompt.rs | 58 +++++++++------------- test/js/web/web-globals.test.js | 79 ++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 41 deletions(-) diff --git a/docs/runtime/secrets.mdx b/docs/runtime/secrets.mdx index 02d0eda3e933..cc96b1d3d68d 100644 --- a/docs/runtime/secrets.mdx +++ b/docs/runtime/secrets.mdx @@ -17,6 +17,7 @@ let githubToken: string | null = await secrets.get({ if (!githubToken) { githubToken = prompt("Please enter your GitHub token"); + if (!githubToken) throw new Error("No GitHub token entered"); await secrets.set({ service: "my-cli-tool", diff --git a/src/runtime/webcore/prompt.bind.ts b/src/runtime/webcore/prompt.bind.ts index f0c263832d8b..b3624b8bb030 100644 --- a/src/runtime/webcore/prompt.bind.ts +++ b/src/runtime/webcore/prompt.bind.ts @@ -7,10 +7,8 @@ const message = b.string.loose.utf8.ifPassed; export const alert = b.fn({ args: { message } }); export const confirm = b.fn({ args: { message }, returns: b.bool }); -/** An empty answer returns `defaultValue` as it is. */ +/** `null` is the end of the input. An empty answer is `defaultValue`, and "" without one. */ export const prompt = b.fn({ - args: { message, defaultValue: b.any.ifPassed }, - returns: b.any, - capability: "js", - throws: true, + args: { message, defaultValue: b.string.loose.optional }, + returns: b.string.nullable, }); diff --git a/src/runtime/webcore/prompt.rs b/src/runtime/webcore/prompt.rs index 8901aa958e22..f29d057084c6 100644 --- a/src/runtime/webcore/prompt.rs +++ b/src/runtime/webcore/prompt.rs @@ -1,11 +1,7 @@ //! Implements prompt, alert, and confirm Web API -use crate::webcore::jsc::{JSValue, JsResult}; use bun_collections::VecExt as _; -use bun_core::EncodedSlice; use bun_core::Output; -use bun_jsc::EncodedSliceJsc as _; -use bun_jsc::bindgen::{JsLocal, JsScope}; /// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-alert pub(crate) fn alert(message: Option<&[u8]>) { @@ -214,16 +210,11 @@ fn read_until_delimiter_array_list_infinity( } /// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-prompt -pub(crate) fn prompt<'a>( - cx: &mut JsScope<'a>, +pub(crate) fn prompt( message: Option<&[u8]>, - default_value: Option>, -) -> JsResult { - let global = cx.global(); + default_value: Option, +) -> Option { let output = Output::writer(); - // 4. Set default to the result of optionally truncating default. - // * We don't really need to do this. - let default = default_value.map(JsLocal::to_js); if let Some(message) = message { // 2. Set message to the result of normalizing newlines given message. @@ -233,11 +224,12 @@ pub(crate) fn prompt<'a>( // * Not necessary so we won't do it. if output.write_all(message).is_err() { // 1. If we cannot show simple dialogs for this, then return null. - return Ok(JSValue::NULL); + return None; } } // 4. Set default to the result of optionally truncating default. + // * We don't really need to do this. // 5. Show message to the user, treating U+000A LF as a line break, // and ask the user to either respond with a string value or @@ -251,24 +243,18 @@ pub(crate) fn prompt<'a>( }) .is_err() { - // 1. If we cannot show simple dialogs for this, then return false. - return Ok(JSValue::FALSE); + // 1. If we cannot show simple dialogs for this, then return null. + return None; } - if let Some(default) = default { - let default_string = default.to_utf8(global)?; - - if output - .print(format_args!( - "[{}] ", - bstr::BStr::new(default_string.slice()) - )) - .is_err() - { - // 1. If we cannot show simple dialogs for this, then return false. - return Ok(JSValue::FALSE); + if let Some(default) = &default_value { + if output.print(format_args!("[{default}] ")).is_err() { + // 1. If we cannot show simple dialogs for this, then return null. + return None; } } + // `optional DOMString default = ""`: the response to an empty line. + let default = default_value.unwrap_or(bun_core::String::EMPTY); // 6. Invoke WebDriver BiDi user prompt opened with this, "prompt" and message. // * Not relevant in a server context. @@ -293,20 +279,20 @@ pub(crate) fn prompt<'a>( let Ok(first_byte) = reader.read_byte() else { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - return Ok(JSValue::NULL); + return None; }; if first_byte == b'\n' { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - return Ok(default.unwrap_or(JSValue::NULL)); + return Some(default); } else if first_byte == b'\r' { let Ok(second) = reader.read_byte() else { - return Ok(JSValue::NULL); + return None; }; second_byte = Some(second); if second == b'\n' { - return Ok(default.unwrap_or(JSValue::NULL)); + return Some(default); } } @@ -330,7 +316,7 @@ pub(crate) fn prompt<'a>( if !matches!(e, ReadError::StreamTooLong) { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - return Ok(JSValue::NULL); + return None; } input.ensure_total_capacity(4096); @@ -344,13 +330,13 @@ pub(crate) fn prompt<'a>( if !matches!(e2, ReadError::StreamTooLong) { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - return Ok(JSValue::NULL); + return None; } if read_until_delimiter_array_list_infinity(&mut *reader, &mut input, b'\n').is_err() { // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - return Ok(JSValue::NULL); + return None; } } } @@ -363,12 +349,12 @@ pub(crate) fn prompt<'a>( // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - let result = EncodedSlice::utf8(&input); + let result = bun_core::String::clone_utf8(&input); // 9. Invoke WebDriver BiDi user prompt closed with this, false if // result is null or true otherwise, and result. // * Too complex for server context. // 9. Return result. - Ok(result.to_js(global)) + Some(result) } diff --git a/test/js/web/web-globals.test.js b/test/js/web/web-globals.test.js index d83ba557f3f4..ffb4d739a3d9 100644 --- a/test/js/web/web-globals.test.js +++ b/test/js/web/web-globals.test.js @@ -444,6 +444,85 @@ test.concurrent.each([ }); }); +// The dialog goes to stdout. The fixture writes what the call returns to stderr, as JSON. +async function runDialog(call, stdin) { + await using proc = spawn({ + cmd: [bunExe(), "-e", `console.error(JSON.stringify(${call}));`], + stdio: ["pipe", "pipe", "pipe"], + env: bunEnv, + }); + + proc.stdin.write(stdin); + await proc.stdin.flush(); + proc.stdin.end(); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// 5001 bytes. The read of a line grows its buffer at 2048 and at 4096 bytes, here inside a two-byte character. +const longAnswer = "a" + Buffer.alloc(5000, "\u00e9").toString(); + +// `prompt(optional DOMString message = "", optional DOMString default = "")` returns the response, a string. +// `null` is the end of the input. +test.concurrent.each([ + ["an answer", `prompt("Q?")`, "x\n", "Q? ", "x"], + [ + "a non-ASCII answer", + `prompt("Q?")`, + "h\u00e9llo \u20ac \u65e5\u672c\u8a9e \ud83d\ude00\n", + "Q? ", + "h\u00e9llo \u20ac \u65e5\u672c\u8a9e \ud83d\ude00", + ], + [ + "an answer that is not UTF-8", + `prompt("Q?")`, + Buffer.from([0x61, 0xff, 0x62, 0xe2, 0x82, 0x0a]), + "Q? ", + "a\ufffdb\ufffd", + ], + ["an answer longer than the read buffer", `prompt("Q?")`, longAnswer + "\n", "Q? ", longAnswer], + ["end of input", `prompt("Q?")`, "", "Q? ", null], + ["empty line", `prompt("Q?")`, "\n", "Q? ", ""], + ["empty line, CRLF", `prompt("Q?")`, "\r\n", "Q? ", ""], + ["empty line, no message", `prompt()`, "\n", "Prompt ", ""], + ["undefined default is no default", `prompt("Q?", undefined)`, "\n", "Q? ", ""], + ["default, empty line", `prompt("Q?", "dflt")`, "\n", "Q? [dflt] ", "dflt"], + ["default, empty line, CRLF", `prompt("Q?", "dflt")`, "\r\n", "Q? [dflt] ", "dflt"], + ["default, an answer", `prompt("Q?", "dflt")`, "hi\n", "Q? [dflt] ", "hi"], + ["default, end of input", `prompt("Q?", "dflt")`, "", "Q? [dflt] ", null], + ["empty default", `prompt("Q?", "")`, "\n", "Q? [] ", ""], + ["Latin-1 default", `prompt("Q?", "caf\\u00e9")`, "\n", "Q? [caf\u00e9] ", "caf\u00e9"], + ["number default is its string", `prompt("Q?", 5)`, "\n", "Q? [5] ", "5"], + ["null default is the string null", `prompt("Q?", null)`, "\n", "Q? [null] ", "null"], + ["object default is its string", `prompt("Q?", {})`, "\r\n", "Q? [[object Object]] ", "[object Object]"], + ["default with a lone surrogate", `prompt("Q?", "a\\ud800b")`, "\n", "Q? [a\ufffdb] ", "a\ud800b"], + ["empty line, then an answer", `[prompt("A"), prompt("B")]`, "\nz\n", "A B ", ["", "z"]], + [ + "empty line between answers, then end of input", + `[prompt(), prompt(), prompt(), prompt()]`, + "a\n\nb\n", + "Prompt Prompt Prompt Prompt ", + ["a", "", "b", null], + ], + [ + "each argument converts once, in order, before the message prints", + `prompt({ toString: () => (console.log("m"), "Q?") }, { toString: () => (console.log("d"), "D") })`, + "\n", + "m\nd\nQ? [D] ", + "D", + ], + [ + "a default that throws prints nothing and reads nothing", + `(() => { try { prompt("Q?", { toString() { throw new Error("boom"); } }); } catch (e) { return [e.message, prompt("next")]; } })()`, + "line\n", + "next ", + ["boom", "line"], + ], +])("prompt: %s", async (_, call, stdin, stdout, result) => { + expect(await runDialog(call, stdin)).toEqual({ stdout, stderr: JSON.stringify(result) + "\n", exitCode: 0 }); +}); + test("globalThis.self = 123 works", () => { expect(Object.getOwnPropertyDescriptor(globalThis, "self")).toMatchObject({ configurable: true, From 47f13d9cb19052f4868778795d6f9bc24bfaf441 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:56:12 +0000 Subject: [PATCH 2/2] prompt, confirm: an undefined message is the default message The message of `confirm()` and `prompt()` is `optional DOMString message = ""`, so the schema declares it `.optional`: `prompt(undefined)` prints `Prompt ` and `confirm(undefined)` prints `Confirm [y/N] `. Before, they printed the text `undefined`. `alert()` and `alert(message)` are overloads, so `alert` keeps `.ifPassed` and `alert(undefined)` still prints `undefined`. --- src/runtime/webcore/prompt.bind.ts | 8 +++++--- test/js/web/web-globals.test.js | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/runtime/webcore/prompt.bind.ts b/src/runtime/webcore/prompt.bind.ts index b3624b8bb030..9e54666a4195 100644 --- a/src/runtime/webcore/prompt.bind.ts +++ b/src/runtime/webcore/prompt.bind.ts @@ -2,10 +2,12 @@ import * as b from "../../codegen/bindgen-lib.ts"; export default b.module({ rustPath: "crate::webcore::prompt" }); -/** `alert()` shows a message of its own. `alert(undefined)` shows "undefined". */ -const message = b.string.loose.utf8.ifPassed; +/** `alert()` and `alert(message)` are overloads: `alert()` shows a message of its own, `alert(undefined)` shows "undefined". */ +export const alert = b.fn({ args: { message: b.string.loose.utf8.ifPassed } }); + +/** `optional DOMString message = ""`: without a message, or with `undefined`, the dialog shows a message of its own. */ +const message = b.string.loose.utf8.optional; -export const alert = b.fn({ args: { message } }); export const confirm = b.fn({ args: { message }, returns: b.bool }); /** `null` is the end of the input. An empty answer is `defaultValue`, and "" without one. */ export const prompt = b.fn({ diff --git a/test/js/web/web-globals.test.js b/test/js/web/web-globals.test.js index ffb4d739a3d9..a6e1dc28755e 100644 --- a/test/js/web/web-globals.test.js +++ b/test/js/web/web-globals.test.js @@ -523,6 +523,19 @@ test.concurrent.each([ expect(await runDialog(call, stdin)).toEqual({ stdout, stderr: JSON.stringify(result) + "\n", exitCode: 0 }); }); +// `undefined` is the default message of prompt() and confirm(). alert() and alert(message) are overloads. +test.concurrent.each([ + ["prompt(undefined) shows its own message", `prompt(undefined)`, "hi\n", "Prompt ", "hi"], + ["prompt(undefined, default) shows its own message", `prompt(undefined, "dflt")`, "\n", "Prompt [dflt] ", "dflt"], + ["prompt(null) shows null", `prompt(null)`, "hi\n", "null ", "hi"], + ["confirm(undefined) shows its own message", `confirm(undefined)`, "y\n", "Confirm [y/N] ", true], + ["confirm(null) shows null", `confirm(null)`, "y\n", "null [y/N] ", true], + ["alert() shows its own message", `alert()`, "\n", "Alert [Enter] ", undefined], + ["alert(undefined) shows undefined", `alert(undefined)`, "\n", "undefined [Enter] ", undefined], +])("%s", async (_, call, stdin, stdout, result) => { + expect(await runDialog(call, stdin)).toEqual({ stdout, stderr: JSON.stringify(result) + "\n", exitCode: 0 }); +}); + test("globalThis.self = 123 works", () => { expect(Object.getOwnPropertyDescriptor(globalThis, "self")).toMatchObject({ configurable: true,