buninu 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +21 -0
  2. package/apps/bunmsh/CHANGELOG.md +262 -0
  3. package/apps/bunmsh/LICENSE +21 -0
  4. package/apps/bunmsh/LICENSE-MKSH +295 -0
  5. package/apps/bunmsh/PORTING.md +192 -0
  6. package/apps/bunmsh/README.md +801 -0
  7. package/apps/bunmsh/bunmsh +7 -0
  8. package/apps/bunmsh/bunshell.md +61 -0
  9. package/apps/bunmsh/help/README.md +1945 -0
  10. package/apps/bunmsh/help/alias.md +22 -0
  11. package/apps/bunmsh/help/basename.md +38 -0
  12. package/apps/bunmsh/help/builtin.md +25 -0
  13. package/apps/bunmsh/help/bun.md +27 -0
  14. package/apps/bunmsh/help/bunmsh.md +47 -0
  15. package/apps/bunmsh/help/cat.md +31 -0
  16. package/apps/bunmsh/help/catfancy.md +35 -0
  17. package/apps/bunmsh/help/cd.md +22 -0
  18. package/apps/bunmsh/help/chmod.md +27 -0
  19. package/apps/bunmsh/help/clear.md +21 -0
  20. package/apps/bunmsh/help/colon.md +22 -0
  21. package/apps/bunmsh/help/command.md +27 -0
  22. package/apps/bunmsh/help/cp.md +28 -0
  23. package/apps/bunmsh/help/cut.md +25 -0
  24. package/apps/bunmsh/help/date.md +25 -0
  25. package/apps/bunmsh/help/dirname.md +25 -0
  26. package/apps/bunmsh/help/echo.md +25 -0
  27. package/apps/bunmsh/help/env.md +27 -0
  28. package/apps/bunmsh/help/eval.md +22 -0
  29. package/apps/bunmsh/help/exec.md +22 -0
  30. package/apps/bunmsh/help/exit.md +21 -0
  31. package/apps/bunmsh/help/export.md +22 -0
  32. package/apps/bunmsh/help/false.md +22 -0
  33. package/apps/bunmsh/help/find.md +33 -0
  34. package/apps/bunmsh/help/getopts.md +28 -0
  35. package/apps/bunmsh/help/grep.md +34 -0
  36. package/apps/bunmsh/help/head.md +27 -0
  37. package/apps/bunmsh/help/kill.md +27 -0
  38. package/apps/bunmsh/help/ln.md +28 -0
  39. package/apps/bunmsh/help/ls.md +37 -0
  40. package/apps/bunmsh/help/lsbun.md +29 -0
  41. package/apps/bunmsh/help/lsfancy.md +37 -0
  42. package/apps/bunmsh/help/md5sum.md +21 -0
  43. package/apps/bunmsh/help/mkdir.md +27 -0
  44. package/apps/bunmsh/help/mktemp.md +26 -0
  45. package/apps/bunmsh/help/mv.md +26 -0
  46. package/apps/bunmsh/help/print.md +30 -0
  47. package/apps/bunmsh/help/printf.md +25 -0
  48. package/apps/bunmsh/help/pwd.md +21 -0
  49. package/apps/bunmsh/help/read.md +27 -0
  50. package/apps/bunmsh/help/readonly.md +27 -0
  51. package/apps/bunmsh/help/realpath.md +21 -0
  52. package/apps/bunmsh/help/rm.md +33 -0
  53. package/apps/bunmsh/help/rmdir.md +27 -0
  54. package/apps/bunmsh/help/sed.md +29 -0
  55. package/apps/bunmsh/help/seq.md +29 -0
  56. package/apps/bunmsh/help/serve.md +50 -0
  57. package/apps/bunmsh/help/set.md +26 -0
  58. package/apps/bunmsh/help/sha256sum.md +21 -0
  59. package/apps/bunmsh/help/shift.md +23 -0
  60. package/apps/bunmsh/help/sleep.md +25 -0
  61. package/apps/bunmsh/help/sort.md +29 -0
  62. package/apps/bunmsh/help/source.md +23 -0
  63. package/apps/bunmsh/help/tab.md +30 -0
  64. package/apps/bunmsh/help/tac.md +29 -0
  65. package/apps/bunmsh/help/tail.md +27 -0
  66. package/apps/bunmsh/help/tee.md +25 -0
  67. package/apps/bunmsh/help/test.md +30 -0
  68. package/apps/bunmsh/help/time.md +29 -0
  69. package/apps/bunmsh/help/touch.md +26 -0
  70. package/apps/bunmsh/help/tr.md +26 -0
  71. package/apps/bunmsh/help/true.md +22 -0
  72. package/apps/bunmsh/help/type.md +21 -0
  73. package/apps/bunmsh/help/umask.md +22 -0
  74. package/apps/bunmsh/help/unalias.md +28 -0
  75. package/apps/bunmsh/help/uname.md +31 -0
  76. package/apps/bunmsh/help/unset.md +28 -0
  77. package/apps/bunmsh/help/wc.md +27 -0
  78. package/apps/bunmsh/help/whence.md +26 -0
  79. package/apps/bunmsh/help/which.md +41 -0
  80. package/apps/bunmsh/help/yes.md +23 -0
  81. package/apps/bunmsh/package.json +26 -0
  82. package/apps/bunmsh/serve.js +468 -0
  83. package/apps/bunmsh/single-exe/README.md +402 -0
  84. package/apps/bunmsh/single-exe/assetsHelper.js +371 -0
  85. package/apps/bunmsh/single-exe/assetsLoader.mjs +85 -0
  86. package/apps/bunmsh/single-exe/assetsPacker.js +218 -0
  87. package/apps/bunmsh/single-exe/compiled.js +527 -0
  88. package/apps/bunmsh/single-exe/entry.mjs +17 -0
  89. package/apps/bunmsh/src/cat-fancy.js +117 -0
  90. package/apps/bunmsh/src/cat-operands.js +31 -0
  91. package/apps/bunmsh/src/completion.js +247 -0
  92. package/apps/bunmsh/src/environment.js +61 -0
  93. package/apps/bunmsh/src/fancy-ls.js +186 -0
  94. package/apps/bunmsh/src/find.js +119 -0
  95. package/apps/bunmsh/src/history.js +198 -0
  96. package/apps/bunmsh/src/main.js +776 -0
  97. package/apps/bunmsh/src/mouse.js +103 -0
  98. package/apps/bunmsh/src/shell.js +3557 -0
  99. package/apps/bunmsh/test/catfancy.test.js +102 -0
  100. package/apps/bunmsh/test/reference-source.sh +1 -0
  101. package/apps/bunmsh/test/reference.test.js +677 -0
  102. package/apps/bunmsh/test/serve/README.md +3 -0
  103. package/apps/bunmsh/test/serve/bad.json +1 -0
  104. package/apps/bunmsh/test/serve/data.json +1 -0
  105. package/apps/bunmsh/test/serve/data.json5 +1 -0
  106. package/apps/bunmsh/test/serve/data.jsonc +2 -0
  107. package/apps/bunmsh/test/serve/data.jsonl +2 -0
  108. package/apps/bunmsh/test/serve/data.ndjson +1 -0
  109. package/apps/bunmsh/test/serve/data.toml +3 -0
  110. package/apps/bunmsh/test/serve/data.ts +15 -0
  111. package/apps/bunmsh/test/serve/data.xml +1 -0
  112. package/apps/bunmsh/test/serve/data.yaml +3 -0
  113. package/apps/bunmsh/test/serve/data.yml +1 -0
  114. package/apps/bunmsh/test/serve.test.js +241 -0
  115. package/apps/bunmsh/test/shell.test.js +1778 -0
  116. package/apps/bunmsh/test/t.sh +12 -0
  117. package/bin/init.js +11 -3
  118. package/package.json +2 -1
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { lstatSync, readdirSync } from "node:fs";
4
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { iconFor } from "./src/fancy-ls.js";
7
+
8
+ let root = process.cwd();
9
+ let randomRoute = "";
10
+ const publicUrls = new WeakMap();
11
+
12
+ const ENV_FLAGS = {
13
+ autoOpen: process.env.SERVE_AUTO_OPEN,
14
+ minapkWebview: process.env.SERVE_MINAPK_WEBVIEW,
15
+ randomUrl: process.env.SERVE_RANDOM_URL,
16
+ };
17
+
18
+ const OPTION_NAMES = {
19
+ "--auto-open": "autoOpen",
20
+ "--minapk-webview": "minapkWebview",
21
+ "--random-url": "randomUrl",
22
+ };
23
+
24
+ const envEnabled = (value) => /^(?:1|true|yes|on)$/i.test(String(value ?? "").trim());
25
+ // A bare CLI flag always means "on"; `=off`/`=no`/`=false`/`=` always means an
26
+ // explicit "off" that overrides an environment default turned on inline.
27
+ const isOffValue = (value) => value === "" || /^(?:off|no|false)$/i.test(value);
28
+
29
+ // SERVE_MINAPK_WEBVIEW can hold a boolean-ish word (on/off/...) or a literal
30
+ // number string to pass through as MINAPK_WEBVIEW's default value.
31
+ function minapkWebviewEnvDefault(value) {
32
+ const trimmed = String(value ?? "").trim();
33
+ if (isOffValue(trimmed)) return null;
34
+ if (/^\d+$/.test(trimmed)) return trimmed;
35
+ return envEnabled(trimmed) ? "1" : null;
36
+ }
37
+
38
+ // autoOpen is `false` (off), `true` (on, open the server root), or a string
39
+ // starting with "/" -- a path opened relative to the served URL (after any
40
+ // --random-url prefix) instead of the root. SERVE_AUTO_OPEN can set any of
41
+ // the three as the default the same way --auto-open's value does.
42
+ function autoOpenEnvDefault(value) {
43
+ const trimmed = String(value ?? "").trim();
44
+ return trimmed.startsWith("/") ? trimmed : envEnabled(trimmed);
45
+ }
46
+
47
+ export function parseServeArguments(args, env = process.env, cwd = process.cwd()) {
48
+ const options = {
49
+ autoOpen: autoOpenEnvDefault(ENV_FLAGS.autoOpen),
50
+ minapkWebview: minapkWebviewEnvDefault(ENV_FLAGS.minapkWebview),
51
+ randomUrl: envEnabled(ENV_FLAGS.randomUrl),
52
+ };
53
+ const operands = [];
54
+ let parseOptions = true;
55
+ for (const argument of args) {
56
+ if (parseOptions && argument === "--") {
57
+ parseOptions = false;
58
+ continue;
59
+ }
60
+ if (parseOptions && argument.startsWith("--")) {
61
+ const equals = argument.indexOf("=");
62
+ const flag = equals === -1 ? argument : argument.slice(0, equals);
63
+ const value = equals === -1 ? undefined : argument.slice(equals + 1);
64
+ const name = OPTION_NAMES[flag];
65
+ if (!name) return { error: `unknown option: ${argument}` };
66
+ if (name === "minapkWebview") {
67
+ if (value === undefined) options.minapkWebview = "1";
68
+ else if (isOffValue(value)) options.minapkWebview = null;
69
+ else if (/^\d+$/.test(value)) options.minapkWebview = value;
70
+ else return { error: `invalid value for --minapk-webview: ${value}` };
71
+ } else if (name === "autoOpen") {
72
+ if (value === undefined) options.autoOpen = true;
73
+ else if (isOffValue(value)) options.autoOpen = false;
74
+ else if (value.startsWith("/")) options.autoOpen = value;
75
+ else options.autoOpen = true;
76
+ } else {
77
+ options[name] = value === undefined || !isOffValue(value);
78
+ }
79
+ continue;
80
+ }
81
+ operands.push(argument);
82
+ }
83
+ if (operands.length > 1) return { error: "too many directory operands" };
84
+ return { directory: operands[0] ?? cwd, ...options, env };
85
+ }
86
+
87
+ // Each UUIDv7 is 128 bits: a 48-bit ms timestamp (not secret), a 4-bit
88
+ // version + 2-bit variant (fixed), a 12-bit rand_a, and a 62-bit rand_b.
89
+ // Confirmed against Bun's own source (bun/src/jsc/uuid.rs UUID7::next):
90
+ // rand_a is a process-global monotonic counter seeded fresh only on the
91
+ // first call to observe a new millisecond -- every later call in that same
92
+ // millisecond just gets seed+1, seed+2, ..., not a fresh random draw. Four
93
+ // calls made back-to-back like this land in the same millisecond, so their
94
+ // timestamp and rand_a sequence are effectively one shared, low-entropy
95
+ // value. Only rand_b is unconditionally re-drawn from BoringSSL's CSPRNG
96
+ // (bun/src/jsc/rare_data.rs EntropyCache, backed by bun_boringssl::rand_bytes)
97
+ // on every single call, so the four independent 62-bit rand_b fields are
98
+ // the actual guaranteed entropy floor: >= 248 bits, not 4 * 128.
99
+ export const randomServeRoute = () =>
100
+ `${Array.from({ length: 4 }, () => Bun.randomUUIDv7("base64url")).join("")}/`;
101
+
102
+ export const serveUrl = (server) => publicUrls.get(server) ?? server.url;
103
+
104
+ const escapeHtml = (value) => Bun.escapeHTML(value);
105
+
106
+ const PARSERS = new Map([
107
+ ["json", { name: "json.parse", parse: JSON.parse }],
108
+ ["json5", { name: "bun.json5.parse", parse: Bun.JSON5.parse }],
109
+ ["jsonc", { name: "bun.jsonc.parse", parse: Bun.JSONC.parse }],
110
+ ["jsonl", { name: "bun.jsonl.parse", parse: Bun.JSONL.parse }],
111
+ ["ndjson", { name: "bun.jsonl.parse", parse: Bun.JSONL.parse }],
112
+ ["yaml", { name: "bun.yaml.parse", parse: Bun.YAML.parse }],
113
+ ["yml", { name: "bun.yaml.parse", parse: Bun.YAML.parse }],
114
+ ["toml", { name: "bun.toml.parse", parse: Bun.TOML.parse }],
115
+ ]);
116
+
117
+ if (typeof Bun.XML?.parse === "function") {
118
+ PARSERS.set("xml", { name: "bun.xml.parse", parse: Bun.XML.parse });
119
+ }
120
+
121
+ const extension = (name) => {
122
+ const dot = name.lastIndexOf(".");
123
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
124
+ };
125
+
126
+ const previewName = (name) => extension(name) === "md"
127
+ ? "bun.markdown.html"
128
+ : PARSERS.get(extension(name))?.name;
129
+
130
+ const page = (title, body) => `<!doctype html>
131
+ <meta charset="utf-8">
132
+ <meta name="viewport" content="width=device-width">
133
+ <title>${escapeHtml(title)}</title>
134
+ <style>
135
+ :root { color-scheme: light dark }
136
+ body { max-width: 72rem; margin: 2rem auto; padding: 0 1rem; font: 16px/1.7 ui-monospace, monospace }
137
+ a { color: inherit; text-decoration: none }
138
+ a:hover { text-decoration: underline }
139
+ pre { padding: 1rem; overflow: auto; white-space: pre-wrap; background: color-mix(in srgb, CanvasText 7%, Canvas) }
140
+ code { font: inherit }
141
+ .json-statement { color: #b0005a }
142
+ .json-string { color: #6b5d00 }
143
+ .json-special { color: #087f5b }
144
+ .json-number, .json-constant { color: #5f3dc4 }
145
+ @media (prefers-color-scheme: dark) {
146
+ .json-statement { color: #f92672 }
147
+ .json-string { color: #e6db74 }
148
+ .json-special { color: #a6e22e }
149
+ .json-number, .json-constant { color: #ae81ff }
150
+ }
151
+ </style>
152
+ ${body}`;
153
+
154
+ const pretty = (value) => {
155
+ try {
156
+ return JSON.stringify(value, (_key, item) =>
157
+ typeof item === "bigint" ? `${item}n` : item, 2) ?? String(value);
158
+ } catch {
159
+ return Bun.inspect(value, { colors: false, depth: Infinity });
160
+ }
161
+ };
162
+
163
+ // Mirrors the useful groups in jsmdcui/runtime/syntax/json.yaml for the
164
+ // strict JSON emitted by pretty(): statement keys, strings and escapes,
165
+ // numbers, and true/false/null constants.
166
+ export const highlightJson = (source) => {
167
+ const input = String(source);
168
+ const token = /"(?:\\(?:u[\da-fA-F]{4}|.)|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g;
169
+
170
+ //" fixes micro syntax highlighting
171
+
172
+ let output = "", cursor = 0, match;
173
+ const stringHtml = (value) => {
174
+ let html = "", offset = 0;
175
+ for (const escape of value.matchAll(/\\(?:u[\da-fA-F]{4}|.)/g)) {
176
+ html += escapeHtml(value.slice(offset, escape.index));
177
+ html += `<span class="json-special">${escapeHtml(escape[0])}</span>`;
178
+ offset = escape.index + escape[0].length;
179
+ }
180
+ return html + escapeHtml(value.slice(offset));
181
+ };
182
+ while ((match = token.exec(input))) {
183
+ output += escapeHtml(input.slice(cursor, match.index));
184
+ const value = match[0];
185
+ let end = token.lastIndex;
186
+ if (value.startsWith('"')) {
187
+ const propertyEnd = /^\s*:/.exec(input.slice(end));
188
+ if (propertyEnd) {
189
+ end += propertyEnd[0].length;
190
+ output += `<span class="json-statement">${escapeHtml(input.slice(match.index, end))}</span>`;
191
+ } else {
192
+ output += `<span class="json-string">${stringHtml(value)}</span>`;
193
+ }
194
+ } else if (value === "true" || value === "false" || value === "null") {
195
+ output += `<span class="json-constant">${value}</span>`;
196
+ } else {
197
+ output += `<span class="json-number">${value}</span>`;
198
+ }
199
+ cursor = end;
200
+ token.lastIndex = end;
201
+ }
202
+ return output + escapeHtml(input.slice(cursor));
203
+ };
204
+
205
+ const previewResponse = async (entry) => {
206
+
207
+ const renderHTMLOptions = {
208
+ headings: true
209
+ }
210
+
211
+ const kind = extension(entry.name);
212
+ try {
213
+ const source = await Bun.file(entry.path).text();
214
+ if (kind === "md") return new Response(page(entry.name,
215
+ `<p><a href="${encodeURIComponent(entry.name)}">← ${escapeHtml(entry.name)}</a></p>\n${Bun.markdown.html(source,renderHTMLOptions)}`), {
216
+ headers: { "content-type": "text/html; charset=utf-8" },
217
+ });
218
+ const parser = PARSERS.get(kind);
219
+ const output = pretty(parser.parse(source));
220
+ return new Response(page(`${entry.name} — ${parser.name}`,
221
+ `<p><a href="${encodeURIComponent(entry.name)}">← ${escapeHtml(entry.name)}</a></p>\n<h1>${escapeHtml(entry.name)}</h1>\n<pre><code>${highlightJson(output)}</code></pre>`), {
222
+ headers: { "content-type": "text/html; charset=utf-8" },
223
+ });
224
+ } catch (error) {
225
+ return new Response(page(`Cannot preview ${entry.name}`,
226
+ `<h1>Cannot preview ${escapeHtml(entry.name)}</h1>\n<pre>${escapeHtml(error?.stack ?? String(error))}</pre>`), {
227
+ status: 422,
228
+ headers: { "content-type": "text/html; charset=utf-8" },
229
+ });
230
+ }
231
+ };
232
+
233
+ const directoryHtml = (route, entries) => {
234
+ const parent = route === "/" ? "" : `<a href="../">📦 ../</a>\n`;
235
+ const links = entries
236
+ .filter((entry) => !entry.name.startsWith("."))
237
+ .sort((a, b) => a.name.localeCompare(b.name))
238
+ .map((entry) => {
239
+ const directory = entry.stats.isDirectory();
240
+ const name = `${entry.name}${directory ? "/" : ""}`;
241
+ const encoded = encodeURIComponent(entry.name);
242
+ const preview = !directory && previewName(entry.name);
243
+ return `<a href="${encoded}${directory ? "/" : ""}">${iconFor(entry.name, entry.stats)} ${escapeHtml(name)}</a>${preview ? ` <a href="${encoded}-${preview}" title="Preview with ${preview}">🔍</a>` : ""}`;
244
+ })
245
+ .join("\n");
246
+ const title = `Index of ${route}`;
247
+ return page(title, `<h1>${escapeHtml(title)}</h1>\n<pre>${parent}${links}${links ? "\n" : ""}</pre>`);
248
+ };
249
+
250
+ const resolveRequestPath = (pathname) => {
251
+ let decoded;
252
+ try {
253
+ decoded = decodeURIComponent(pathname);
254
+ } catch {
255
+ return null;
256
+ }
257
+ if (decoded.includes("\0")) return null;
258
+ const path = resolve(root, `.${decoded.replaceAll("\\", "/")}`);
259
+ const fromRoot = relative(root, path);
260
+ if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot))
261
+ return null;
262
+ return { path, decoded };
263
+ };
264
+
265
+ const previewRequest = (pathname) => {
266
+ const names = ["bun.markdown.html", ...new Set([...PARSERS.values()].map(({ name }) => name))];
267
+ for (const preview of names) {
268
+ const suffix = `-${preview}`;
269
+ if (!pathname.endsWith(suffix)) continue;
270
+ const original = pathname.slice(0, -suffix.length);
271
+ if (previewName(basename(original)) === preview) return original;
272
+ }
273
+ return null;
274
+ };
275
+
276
+ const notFound = () => new Response("Not Found", { status: 404 });
277
+
278
+ export const resolveBunfsPath = (directory, moduleDirectory = import.meta.dirname) => {
279
+ const requested = String(directory).replaceAll("\\", "/");
280
+ const moduleDir = String(moduleDirectory).replaceAll("\\", "/").replace(/\/$/, "");
281
+ const mount = /^(\/\$bunfs|[a-z]:\/~bun)(?:\/|$)/i.exec(moduleDir)?.[1];
282
+ if (!mount) return null;
283
+ const alias = /^(\/\$bunfs|[a-z]:\/~bun)(?=\/|$)/i.exec(requested)?.[1];
284
+ if (!alias) return null;
285
+ let suffix = requested.slice(alias.length);
286
+ // Accept both the logical-root spelling and Bun's displayed physical
287
+ // spelling without turning .../root/... into .../root/root/....
288
+ if (suffix.toLowerCase() === "/root") suffix = "";
289
+ else if (suffix.toLowerCase().startsWith("/root/")) suffix = suffix.slice(5);
290
+ return `${moduleDir}${suffix}`;
291
+ };
292
+
293
+ const handleRequest = async (request) => {
294
+ if (request.method !== "GET" && request.method !== "HEAD")
295
+ return new Response("Method Not Allowed", { status: 405, headers: { allow: "GET, HEAD" } });
296
+
297
+ const url = new URL(request.url);
298
+ let pathname = url.pathname;
299
+ if (randomRoute) {
300
+ const prefix = `/${randomRoute}`;
301
+ if (!pathname.startsWith(prefix)) return notFound();
302
+ pathname = `/${pathname.slice(prefix.length)}`;
303
+ }
304
+ const originalPreviewPath = previewRequest(pathname);
305
+ const resolved = resolveRequestPath(originalPreviewPath ?? pathname);
306
+ if (!resolved) return new Response("Bad Request", { status: 400 });
307
+
308
+ let stats;
309
+ try {
310
+ stats = lstatSync(resolved.path);
311
+ } catch {
312
+ return notFound();
313
+ }
314
+
315
+ if (originalPreviewPath) {
316
+ if (stats.isDirectory()) return notFound();
317
+ return previewResponse({ name: basename(resolved.path), path: resolved.path, stats });
318
+ }
319
+
320
+ if (stats.isDirectory()) {
321
+ if (!url.pathname.endsWith("/")) {
322
+ url.pathname += "/";
323
+ return Response.redirect(url, 308);
324
+ }
325
+ let entries;
326
+ try {
327
+ entries = readdirSync(resolved.path, { withFileTypes: true }).map((entry) => {
328
+ const path = resolve(resolved.path, entry.name);
329
+ return { name: entry.name, path, stats: lstatSync(path) };
330
+ });
331
+ } catch (error) {
332
+ return new Response(`Cannot list directory: ${error.message}`, { status: 403 });
333
+ }
334
+ return new Response(directoryHtml(resolved.decoded, entries), {
335
+ headers: { "content-type": "text/html; charset=utf-8" },
336
+ });
337
+ }
338
+
339
+ // Keep this as a whole, unsliced Bun.file() response. Bun's normal response
340
+ // path then provides native GET/HEAD Range handling, including 206 and 416.
341
+ return new Response(Bun.file(resolved.path));
342
+ };
343
+
344
+ const safeHandleRequest = async (request) => {
345
+ try {
346
+ return await handleRequest(request);
347
+ } catch (error) {
348
+ // Keep an unexpected renderer, filesystem, or runtime exception local to
349
+ // this request. Bun.serve stays alive and the next request is unaffected.
350
+ console.error("bunmsh: serve: request failed:", error);
351
+ return new Response(page("Internal Server Error",
352
+ "<h1>Internal Server Error</h1>\n<p>The server is still running.</p>"), {
353
+ status: 500,
354
+ headers: { "content-type": "text/html; charset=utf-8" },
355
+ });
356
+ }
357
+ };
358
+
359
+ // Prefer a real xdg-open on PATH regardless of platform -- it's common
360
+ // enough on macOS/Windows dev setups (Cygwin, MSYS2, Homebrew's xdg-utils)
361
+ // and behaves consistently. Only fall back to each OS's own "hand this to
362
+ // the default handler" command when xdg-open isn't present, same relationship
363
+ // as the npm `buninu` package's own xdg-open shim.
364
+ function openServeCommand() {
365
+ const executable = Bun.which("xdg-open");
366
+ if (executable) return [executable];
367
+ if (process.platform === "darwin") return ["open"];
368
+ // start is a cmd builtin, not a real executable, and needs an empty ""
369
+ // title argument first or it treats a quoted target as the title.
370
+ if (process.platform === "win32") return ["cmd", "/c", "start", ""];
371
+ return null;
372
+ }
373
+
374
+ // autoOpen is `true` for the served root as-is, or a "/"-led path opened
375
+ // relative to it -- resolved after any --random-url prefix baked into url,
376
+ // since url already ends in "/" (server.url does, and so does the URL built
377
+ // from randomRoute in main()).
378
+ export const openTargetUrl = (url, autoOpen) =>
379
+ typeof autoOpen === "string" ? new URL(autoOpen.slice(1), url) : url;
380
+
381
+ function openServeUrl(url, minapkWebview, env = process.env) {
382
+ const command = openServeCommand();
383
+ if (!command) {
384
+ console.error("bunmsh: serve: xdg-open not found");
385
+ return false;
386
+ }
387
+ try {
388
+ Bun.spawn([...command, String(url)], {
389
+ env: minapkWebview ? { ...env, MINAPK_WEBVIEW: String(minapkWebview) } : env,
390
+ stdin: "ignore",
391
+ stdout: "ignore",
392
+ stderr: "ignore",
393
+ }).unref();
394
+ return true;
395
+ } catch (error) {
396
+ console.error(`bunmsh: serve: ${command[0]}: ${error.message}`);
397
+ return false;
398
+ }
399
+ }
400
+
401
+ export function main(directory = process.cwd(), options = {}) {
402
+ // The virtual mount itself is not stat-able. import.meta.dirname points at
403
+ // its real root on each platform: /$bunfs/root or B:/~BUN/root.
404
+ root = resolve(resolveBunfsPath(directory) ?? directory);
405
+ randomRoute = options.randomUrl ? randomServeRoute() : "";
406
+ const serverOptions = {
407
+ port: Number((options.env ?? process.env).PORT ?? 3000),
408
+ fetch: safeHandleRequest,
409
+ };
410
+ let server;
411
+ try {
412
+ server = Bun.serve(serverOptions);
413
+ } catch (error) {
414
+ const addressInUse = error?.code === "EADDRINUSE" ||
415
+ /EADDRINUSE|address already in use/i.test(String(error?.message ?? error));
416
+ if (serverOptions.port !== 3000 || !addressInUse) throw error;
417
+ server = Bun.serve({ ...serverOptions, port: 0 });
418
+ }
419
+ const url = randomRoute ? new URL(randomRoute, server.url) : server.url;
420
+ publicUrls.set(server, url);
421
+ console.log(`Serving ${root}\n ${url.href}`);
422
+ if (options.autoOpen) openServeUrl(openTargetUrl(url, options.autoOpen), options.minapkWebview, options.env);
423
+ return server;
424
+ }
425
+
426
+ export function waitForInterrupt(server, options = {}) {
427
+ return new Promise((resolve) => {
428
+ let readline;
429
+ let finished = false;
430
+ const stop = (signal) => {
431
+ if (finished) return;
432
+ finished = true;
433
+ process.off("SIGINT", onSigint);
434
+ process.off("SIGTERM", onSigterm);
435
+ readline?.close();
436
+ server.stop(true);
437
+ resolve(signal);
438
+ };
439
+ const onSigint = () => stop("SIGINT");
440
+ const onSigterm = () => stop("SIGTERM");
441
+ process.once("SIGINT", onSigint);
442
+ process.once("SIGTERM", onSigterm);
443
+
444
+ if (!process.stdin.isTTY) return;
445
+ console.log("q/quit/exit: stop o: open in browser");
446
+ readline = createInterface({ input: process.stdin, terminal: false });
447
+ readline.on("line", (line) => {
448
+ const command = line.trim().toLowerCase();
449
+ if (["q", "quit", "exit"].includes(command)) {
450
+ stop("QUIT");
451
+ } else if (command === "o") {
452
+ openServeUrl(openTargetUrl(serveUrl(server), options.autoOpen), options.minapkWebview, options.env);
453
+ } else if (command) {
454
+ console.error(`bunmsh: serve: unknown control: ${command}`);
455
+ }
456
+ });
457
+ readline.on("close", () => stop("EOF"));
458
+ });
459
+ }
460
+
461
+ if (import.meta.main) {
462
+ const options = parseServeArguments(process.argv.slice(2));
463
+ if (options.error) {
464
+ console.error(`bunmsh: serve: ${options.error}`);
465
+ process.exit(2);
466
+ }
467
+ await waitForInterrupt(main(options.directory, options), options);
468
+ }