bun-app-window 0.6.2 → 0.8.0
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.
- package/CHANGELOG.md +302 -63
- package/README.md +265 -146
- package/README.zh-CN.md +249 -147
- package/README.zh-TW.md +252 -146
- package/dist/libshell.dll +0 -0
- package/package.json +9 -5
- package/src/shell/app.ts +255 -2
- package/src/shell/assets.d.ts +4 -4
- package/src/shell/embedded.ts +9 -10
- package/src/shell/index.ts +280 -279
- package/src/shell/ipc.ts +91 -33
- package/src/shell/native.ts +64 -62
- package/src/shell/pack.ts +247 -28
- package/src/shell/port.ts +37 -38
- package/src/shell/resolve.ts +121 -140
- package/src/shell/state.ts +8 -19
- package/src/shell/types.ts +79 -81
- package/src/ui/titlebar.ts +1 -2
package/src/shell/pack.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pack.ts —
|
|
2
|
+
* pack.ts — 把页面脚本打成浏览器可加载的 ESM
|
|
3
3
|
*
|
|
4
4
|
* ## 这一步为什么省不掉
|
|
5
5
|
*
|
|
@@ -27,25 +27,66 @@
|
|
|
27
27
|
*
|
|
28
28
|
* 第 3 条是本仓在消费者工程上实测到的:某处报 `35,085 B`,磁盘实际 `37,319 B`。
|
|
29
29
|
*
|
|
30
|
+
* ## 中立性
|
|
31
|
+
*
|
|
32
|
+
* `plugins` / `define` / `alias` / `loader` 是对 `Bun.build` 的**通用透传**,不是对某个
|
|
33
|
+
* 前端框架的支持:本模块不认识任何框架,`alias` 的默认值为空,也不内置任何插件。
|
|
34
|
+
* 编译型框架(Svelte / Vue / Solid)由消费者自己 `bun add` 插件并传入(ADR 0021)。
|
|
35
|
+
*
|
|
36
|
+
* 这条约束的理由与依赖树一致:壳层认识一个框架,就要认识第二个。
|
|
37
|
+
*
|
|
30
38
|
* ## 与 `bun-app-window/types` 的区别
|
|
31
39
|
*
|
|
32
40
|
* `./types` 是零 import 的纯常量面,浏览器可以打包它。本模块用 `Bun.build`,
|
|
33
41
|
* 属于**构建期**工具,不得出现在页面侧代码里。
|
|
34
42
|
*/
|
|
35
43
|
|
|
36
|
-
import { resolve } from "node:path";
|
|
44
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
45
|
+
import type { BunPlugin, Loader } from "bun";
|
|
46
|
+
|
|
47
|
+
/** 单个打包产物的读数 */
|
|
48
|
+
export type PageOutput = {
|
|
49
|
+
/** 产物路径,由 `Bun.build` 给出(相对于工作目录或绝对路径) */
|
|
50
|
+
path: string;
|
|
51
|
+
/**
|
|
52
|
+
* 产物类型,取 `Bun.build` 的原值。
|
|
53
|
+
*
|
|
54
|
+
* - `entry-point`:入口脚本本身
|
|
55
|
+
* - `chunk`:拆分出的公共块
|
|
56
|
+
* - `asset`:非脚本资源(样式、图片等)
|
|
57
|
+
* - `sourcemap`:源码映射
|
|
58
|
+
* - `bytecode`:预编译字节码
|
|
59
|
+
*/
|
|
60
|
+
kind: string;
|
|
61
|
+
/** 产物的文本内容 */
|
|
62
|
+
code: string;
|
|
63
|
+
/** UTF-8 字节数,不是 `code.length` */
|
|
64
|
+
bytes: number;
|
|
65
|
+
};
|
|
37
66
|
|
|
38
67
|
/** 打包结果 */
|
|
39
68
|
export type PageBundle = {
|
|
40
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* 入口脚本的 ESM 源码,可直接交给 `createApp({ page })`。
|
|
71
|
+
*
|
|
72
|
+
* 取的是 `kind === "entry-point"` 那个产物,**不是** `outputs[0]`:
|
|
73
|
+
* `outputs` 的顺序不构成契约,样式产物可能排在前面。
|
|
74
|
+
*/
|
|
41
75
|
code: string;
|
|
42
76
|
/**
|
|
43
|
-
* UTF-8 字节数。
|
|
77
|
+
* 入口脚本的 UTF-8 字节数。
|
|
44
78
|
*
|
|
45
79
|
* 不是 `code.length`:那是 UTF-16 码元数,源码含中文时两者不等,
|
|
46
80
|
* 而调用方几乎总是拿这个数当「产物多大」来报。
|
|
47
81
|
*/
|
|
48
82
|
bytes: number;
|
|
83
|
+
/**
|
|
84
|
+
* 全部产物,按 `Bun.build` 的输出顺序。
|
|
85
|
+
*
|
|
86
|
+
* 编译型框架常产生多个产物(例如脚本 + 独立的样式文件)。只取第一个会让第二个
|
|
87
|
+
* 被静默丢弃,而表现是页面渲染正常但样式全无。
|
|
88
|
+
*/
|
|
89
|
+
outputs: PageOutput[];
|
|
49
90
|
};
|
|
50
91
|
|
|
51
92
|
export type BundlePageOptions = {
|
|
@@ -59,8 +100,89 @@ export type BundlePageOptions = {
|
|
|
59
100
|
entry: string;
|
|
60
101
|
/** 是否压缩。默认 `false`(与不传时的直观预期一致,也便于排查产物) */
|
|
61
102
|
minify?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* `Bun.build` 插件。原样透传,壳层不构造也不包装。
|
|
105
|
+
*
|
|
106
|
+
* 编译型框架的接入点。传入的插件自行负责其扩展名的编译,壳层对此不做任何假设。
|
|
107
|
+
*/
|
|
108
|
+
plugins?: BunPlugin[];
|
|
109
|
+
/**
|
|
110
|
+
* 编译期常量替换。原样透传。
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* define: { __VUE_OPTIONS_API__: "false" }
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
define?: Record<string, string>;
|
|
117
|
+
/**
|
|
118
|
+
* 路径别名。**由壳层实现,不是直接透传。**
|
|
119
|
+
*
|
|
120
|
+
* 理由:Bun 1.4.4 的 `BuildConfig` 没有 `alias` 字段——实测传入会被静默忽略,
|
|
121
|
+
* 而 `bun-types` 的 `BuildConfig` 中也不存在该属性。因此由本模块用一个
|
|
122
|
+
* `onResolve` 插件实现(约十五行),对消费者而言仍是一个普通映射表。
|
|
123
|
+
*
|
|
124
|
+
* **默认值为空,不预置任何条目**:`$lib` 是 SvelteKit 的约定,`@/` 是某个工程的
|
|
125
|
+
* `tsconfig` 约定,两者都不属于壳层。预置任何别名都等于替消费者选择一个生态。
|
|
126
|
+
*
|
|
127
|
+
* 映射规则:键等于说明符,或以 `键/` 为前缀。其余说明符不干预。
|
|
128
|
+
*/
|
|
129
|
+
alias?: Record<string, string>;
|
|
130
|
+
/** 扩展名到 loader 的映射。用于自定义后缀(`.vue` / `.svelte` 之外的情形) */
|
|
131
|
+
loader?: Record<string, Loader>;
|
|
62
132
|
};
|
|
63
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 把别名表编译成一个 `onResolve` 插件。
|
|
136
|
+
*
|
|
137
|
+
* 返回 `null` 表示没有别名需要处理——此时**不注册插件**,避免一个恒空转的
|
|
138
|
+
* `onResolve` 钩子进入每条解析路径。
|
|
139
|
+
*
|
|
140
|
+
* 解析顺序:先按 `Bun.resolveSync` 求真实路径(这样 `@/thing` → `<dir>/thing.ts`
|
|
141
|
+
* 的扩展名补全由 Bun 自己完成),求不到则原样交给后续解析。不求到就放弃,
|
|
142
|
+
* 是因为「别名指向一个不存在的路径」应当由 Bun 报出原始的可读错误,而不是被
|
|
143
|
+
* 插件改写成一个更难理解的信息。
|
|
144
|
+
*/
|
|
145
|
+
function createAliasPlugin(alias: Record<string, string> | undefined): BunPlugin | null {
|
|
146
|
+
if (alias === undefined) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const keys = Object.keys(alias);
|
|
150
|
+
if (keys.length === 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
// 长键优先,保证 `@/x` 不会被更短的 `@` 先命中
|
|
154
|
+
keys.sort((a, b) => b.length - a.length);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
name: "bun-app-window:alias",
|
|
158
|
+
setup(build) {
|
|
159
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
160
|
+
// 相对路径、绝对路径与运行时内置模块不参与别名
|
|
161
|
+
if (args.path.startsWith(".") || args.path.startsWith("/") || /^[a-z]+:/.test(args.path)) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
let remainder: string;
|
|
166
|
+
if (args.path === key) {
|
|
167
|
+
remainder = "";
|
|
168
|
+
} else if (args.path.startsWith(`${key}/`)) {
|
|
169
|
+
remainder = args.path.slice(key.length);
|
|
170
|
+
} else {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const candidate = (alias[key] ?? "") + remainder;
|
|
174
|
+
try {
|
|
175
|
+
return { path: Bun.resolveSync(candidate, dirname(args.importer)) };
|
|
176
|
+
} catch {
|
|
177
|
+
return { path: candidate };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
64
186
|
/**
|
|
65
187
|
* 从 `Bun.build` 的失败里取出可读线索。
|
|
66
188
|
*
|
|
@@ -87,7 +209,7 @@ function failureDetail(err: unknown): string {
|
|
|
87
209
|
}
|
|
88
210
|
|
|
89
211
|
/**
|
|
90
|
-
* 打包页面脚本,返回 ESM
|
|
212
|
+
* 打包页面脚本,返回 ESM 源码与全部产物。
|
|
91
213
|
*
|
|
92
214
|
* 失败时抛错并附上 Bun 的完整诊断。抛而不是返回 `null`:调用方拿到 `null` 之后
|
|
93
215
|
* 唯一合理的动作也是终止构建,而返回值多一个空状态就多一处要处理的分支。
|
|
@@ -97,29 +219,76 @@ export async function bundlePage(opts: BundlePageOptions): Promise<PageBundle> {
|
|
|
97
219
|
|
|
98
220
|
let result: Awaited<ReturnType<typeof Bun.build>>;
|
|
99
221
|
try {
|
|
222
|
+
// 别名不是 Bun.build 的字段(见 BundlePageOptions.alias),由插件承担。
|
|
223
|
+
// 与消费者传入的插件合并而非替换:两者的职责正交,替换会让「用了 alias 就不能
|
|
224
|
+
// 用框架插件」变成一条隐藏规则。
|
|
225
|
+
const aliasPlugin = createAliasPlugin(opts.alias);
|
|
226
|
+
const plugins = [...(opts.plugins ?? []), ...(aliasPlugin === null ? [] : [aliasPlugin])];
|
|
227
|
+
|
|
100
228
|
result = await Bun.build({
|
|
101
229
|
entrypoints: [entry],
|
|
102
230
|
target: "browser",
|
|
103
231
|
format: "esm",
|
|
104
232
|
...(opts.minify === undefined ? {} : { minify: opts.minify }),
|
|
233
|
+
// 以下三项为原样透传:不筛选、不改写、不补默认值
|
|
234
|
+
...(plugins.length === 0 ? {} : { plugins }),
|
|
235
|
+
...(opts.define === undefined ? {} : { define: opts.define }),
|
|
236
|
+
...(opts.loader === undefined ? {} : { loader: opts.loader }),
|
|
105
237
|
});
|
|
106
238
|
} catch (err) {
|
|
107
239
|
// 入口不可达走这条:`Bun.build` 抛 AggregateError 而不是返回 success:false
|
|
108
|
-
throw new Error(
|
|
109
|
-
`页面脚本打包失败(entry=${entry})\n${failureDetail(err)}`,
|
|
110
|
-
);
|
|
240
|
+
throw new Error(`页面脚本打包失败(entry=${entry})\n${failureDetail(err)}`);
|
|
111
241
|
}
|
|
112
242
|
|
|
113
|
-
|
|
114
|
-
if (!result.success || first === undefined) {
|
|
243
|
+
if (!result.success || result.outputs.length === 0) {
|
|
115
244
|
const detail = result.logs.map((item) => String(item)).join("\n");
|
|
116
|
-
throw new Error(
|
|
117
|
-
|
|
118
|
-
|
|
245
|
+
throw new Error(`页面脚本打包失败(entry=${entry})\n${detail || "(Bun 未提供日志)"}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const outputs: PageOutput[] = [];
|
|
249
|
+
for (const artifact of result.outputs) {
|
|
250
|
+
const code = await artifact.text();
|
|
251
|
+
outputs.push({
|
|
252
|
+
path: artifact.path,
|
|
253
|
+
kind: String(artifact.kind),
|
|
254
|
+
code,
|
|
255
|
+
bytes: Buffer.byteLength(code, "utf8"),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 取 entry-point 而非 outputs[0]:输出顺序不是契约,样式产物可能排在前面
|
|
260
|
+
const entryOutput = outputs.find((o) => o.kind === "entry-point") ?? outputs[0];
|
|
261
|
+
if (entryOutput === undefined) {
|
|
262
|
+
throw new Error(`页面脚本打包失败(entry=${entry})\n产物中找不到入口脚本`);
|
|
119
263
|
}
|
|
120
264
|
|
|
121
|
-
|
|
122
|
-
|
|
265
|
+
return { code: entryOutput.code, bytes: entryOutput.bytes, outputs };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 由一个产物推导它在磁盘上的落点。
|
|
270
|
+
*
|
|
271
|
+
* 规则:入口脚本落在调用方给的 `out`;其余产物落在同目录,扩展名替换为该产物的扩展名。
|
|
272
|
+
*
|
|
273
|
+
* 用替换而非拼接:`page.bundle.txt` 的样式产物落为 `page.bundle.css`,而不是入口名
|
|
274
|
+
* (`main.css`)。后者会把入口文件名泄漏进产物命名,而产物名会被 HTML 与
|
|
275
|
+
* `createApp({ assets })` 引用,改一次入口名就会连带改所有引用点。
|
|
276
|
+
*
|
|
277
|
+
* 产物本身没有扩展名时(少见),追加 `.<kind>`,保证不覆盖入口脚本。
|
|
278
|
+
*/
|
|
279
|
+
export function outputPathFor(out: string, entryPath: string, artifact: Pick<PageOutput, "path" | "kind">): string {
|
|
280
|
+
if (artifact.path === entryPath || artifact.kind === "entry-point") {
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
// 先试产物自带的扩展名;拿不到就用 kind 兜底
|
|
284
|
+
let ext = extname(artifact.path);
|
|
285
|
+
if (ext === "") {
|
|
286
|
+
ext = `.${artifact.kind}`;
|
|
287
|
+
}
|
|
288
|
+
// stem 取 basename:dirname(out) 已经是目录,再拼上绝对路径会重复目录段
|
|
289
|
+
const current = extname(out);
|
|
290
|
+
const stem = current === "" ? basename(out) : basename(out, current);
|
|
291
|
+
return join(dirname(out), stem + ext);
|
|
123
292
|
}
|
|
124
293
|
|
|
125
294
|
/**
|
|
@@ -138,27 +307,77 @@ export async function bundlePage(opts: BundlePageOptions): Promise<PageBundle> {
|
|
|
138
307
|
* await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" });
|
|
139
308
|
* ```
|
|
140
309
|
*
|
|
310
|
+
* 多产物时,入口脚本写到 `out`,其余写到同目录(规则见 `outputPathFor`)。
|
|
311
|
+
* 返回值里的 `written` 逐项列出实际落盘路径,调用方的清理逻辑据此处理——
|
|
312
|
+
* 只删 `out` 会留下样式产物。
|
|
313
|
+
*
|
|
141
314
|
* 产物是否需要入库由调用方决定。不入库时 `bun check` 仍然通过——类型检查按
|
|
142
315
|
* `declare module "*.txt"` 解析模块,不访问文件系统;但 `bun build --compile`
|
|
143
316
|
* 需要文件真实存在,所以前置不可省。
|
|
144
317
|
*/
|
|
145
318
|
export async function writePageBundle(
|
|
146
319
|
opts: BundlePageOptions & { out: string },
|
|
147
|
-
): Promise<PageBundle & { out: string }> {
|
|
320
|
+
): Promise<PageBundle & { out: string; written: Array<{ path: string; bytes: number }> }> {
|
|
148
321
|
const out = resolve(opts.out);
|
|
149
322
|
const bundle = await bundlePage(opts);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
);
|
|
323
|
+
|
|
324
|
+
const entryArtifact = bundle.outputs.find((o) => o.kind === "entry-point") ?? bundle.outputs[0];
|
|
325
|
+
const written: Array<{ path: string; bytes: number }> = [];
|
|
326
|
+
|
|
327
|
+
for (const artifact of bundle.outputs) {
|
|
328
|
+
const dest = outputPathFor(out, entryArtifact?.path ?? "", artifact);
|
|
329
|
+
await Bun.write(dest, artifact.code);
|
|
330
|
+
|
|
331
|
+
// 复核一次实际字节数:`Bun.write` 的编码行为不由我们控制,
|
|
332
|
+
// 报出去的数字必须是磁盘上那个,而不是我们以为的那个。
|
|
333
|
+
const onDisk = Bun.file(dest).size;
|
|
334
|
+
if (onDisk !== artifact.bytes) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`页面产物写入字节数与预期不符:` +
|
|
337
|
+
`写入 ${dest},预期 ${artifact.bytes} B,实际 ${onDisk} B。` +
|
|
338
|
+
`若产物被写成了带 BOM 或 CRLF 的形态,先查写入路径。`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
written.push({ path: dest, bytes: onDisk });
|
|
161
342
|
}
|
|
162
343
|
|
|
163
|
-
return { ...bundle, out };
|
|
344
|
+
return { ...bundle, out, written };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 由扩展名推断 `content-type`。
|
|
349
|
+
*
|
|
350
|
+
* 未识别的扩展名回退到 `application/octet-stream` 而不是猜测:猜错的表现是脚本或样式
|
|
351
|
+
* **不执行**(`<script type="module">` 与 `<link rel=stylesheet>` 都会静默拒绝错误的
|
|
352
|
+
* MIME 类型),而回退的表现是「被下载」——后者立刻可见。
|
|
353
|
+
*/
|
|
354
|
+
export function contentTypeFor(path: string): string {
|
|
355
|
+
switch (extname(basename(path)).toLowerCase()) {
|
|
356
|
+
case ".js":
|
|
357
|
+
case ".mjs":
|
|
358
|
+
return "text/javascript; charset=utf-8";
|
|
359
|
+
case ".css":
|
|
360
|
+
return "text/css; charset=utf-8";
|
|
361
|
+
case ".json":
|
|
362
|
+
return "application/json; charset=utf-8";
|
|
363
|
+
case ".html":
|
|
364
|
+
return "text/html; charset=utf-8";
|
|
365
|
+
case ".svg":
|
|
366
|
+
return "image/svg+xml";
|
|
367
|
+
case ".png":
|
|
368
|
+
return "image/png";
|
|
369
|
+
case ".jpg":
|
|
370
|
+
case ".jpeg":
|
|
371
|
+
return "image/jpeg";
|
|
372
|
+
case ".webp":
|
|
373
|
+
return "image/webp";
|
|
374
|
+
case ".woff":
|
|
375
|
+
return "font/woff";
|
|
376
|
+
case ".woff2":
|
|
377
|
+
return "font/woff2";
|
|
378
|
+
case ".txt":
|
|
379
|
+
return "text/plain; charset=utf-8";
|
|
380
|
+
default:
|
|
381
|
+
return "application/octet-stream";
|
|
382
|
+
}
|
|
164
383
|
}
|
package/src/shell/port.ts
CHANGED
|
@@ -15,27 +15,26 @@ import { randomInt } from "node:crypto";
|
|
|
15
15
|
* 凡在此表内的端口,浏览器内核直接拒绝连接,不发任何网络请求。
|
|
16
16
|
*/
|
|
17
17
|
const RESTRICTED_PORTS = new Set<number>([
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
6668, 6669, 6679, 6697, 10080,
|
|
18
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110,
|
|
19
|
+
111, 113, 115, 117, 119, 123, 135, 137, 138, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531,
|
|
20
|
+
532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060,
|
|
21
|
+
5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
|
23
22
|
]);
|
|
24
23
|
|
|
25
24
|
export function isRestrictedPort(port: number): boolean {
|
|
26
|
-
|
|
25
|
+
return RESTRICTED_PORTS.has(port);
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
const RANGE_MIN = 40000;
|
|
30
29
|
const RANGE_MAX = 60000;
|
|
31
30
|
|
|
32
31
|
export type ServeOptions = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
hostname?: string;
|
|
33
|
+
/** 最多尝试几次(默认 20);超出则抛错 */
|
|
34
|
+
attempts?: number;
|
|
35
|
+
fetch: (req: Request, server: unknown) => Response | undefined | Promise<Response | undefined>;
|
|
36
|
+
websocket?: unknown;
|
|
37
|
+
error?: (err: Error) => Response;
|
|
39
38
|
};
|
|
40
39
|
|
|
41
40
|
/**
|
|
@@ -43,35 +42,35 @@ export type ServeOptions = {
|
|
|
43
42
|
* 相比 `Bun.serve({ port: 0 })`,这里保证端口不在浏览器受限表内。
|
|
44
43
|
*/
|
|
45
44
|
export function serveOnSafePort(opts: ServeOptions) {
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const attempts = opts.attempts ?? 20;
|
|
46
|
+
let lastErr: unknown = null;
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
for (let i = 0; i < attempts; i++) {
|
|
49
|
+
const port = randomInt(RANGE_MIN, RANGE_MAX);
|
|
50
|
+
if (isRestrictedPort(port)) continue;
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
try {
|
|
53
|
+
const server = Bun.serve({
|
|
54
|
+
hostname: opts.hostname ?? "127.0.0.1",
|
|
55
|
+
port,
|
|
56
|
+
fetch: opts.fetch as never,
|
|
57
|
+
...(opts.websocket === undefined ? {} : { websocket: opts.websocket as never }),
|
|
58
|
+
...(opts.error === undefined ? {} : { error: opts.error }),
|
|
59
|
+
});
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
// 双保险:即使指定了端口也再确认一次
|
|
62
|
+
const bound = server.port;
|
|
63
|
+
if (bound !== undefined && isRestrictedPort(bound)) {
|
|
64
|
+
server.stop(true);
|
|
65
|
+
lastErr = new Error(`allocated restricted port ${bound}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
return server;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
lastErr = err;
|
|
71
|
+
// 端口被占用等情况,继续尝试下一个
|
|
72
|
+
}
|
|
73
73
|
}
|
|
74
|
-
}
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
throw new Error(`could not bind a safe port after ${attempts} attempts: ${String(lastErr)}`);
|
|
77
76
|
}
|