@heybox/hb-sdk 0.7.4-alpha.5 → 0.7.4
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 +48 -0
- package/README.md +2 -0
- package/dist/cli-chunks/{build-C-ufarA6.cjs → build-CVPqGQY3.cjs} +2 -2
- package/dist/cli-chunks/{context-C839--TH.cjs → context-C1nTGr-R.cjs} +26 -1
- package/dist/cli-chunks/{create-CNcII_CB.cjs → create-C5qrLLvv.cjs} +1 -1
- package/dist/cli-chunks/dev-U5BlN4ei.cjs +15682 -0
- package/dist/cli-chunks/{doctor-bhfQwyYU.cjs → doctor-DxqHMTbm.cjs} +1 -1
- package/dist/cli-chunks/{index-B_qJzSFo.cjs → index-BfR6OZeU.cjs} +83 -83
- package/dist/cli-chunks/{index-CT94XzyO.cjs → index-DLrcC_ij.cjs} +31 -32
- package/dist/cli-chunks/index.esm-DbbyeU1z.cjs +5212 -0
- package/dist/cli-chunks/{login-hYrDJ3dR.cjs → login-DQkArK14.cjs} +2 -2
- package/dist/cli-chunks/{project-vite-zaw68V1G.cjs → project-vite-c13CKUUl.cjs} +1 -1
- package/dist/cli-chunks/{remote-DQZRAAHh.cjs → remote-816SyJyA.cjs} +4 -4
- package/dist/cli-chunks/{session-D692TU5N.cjs → session-ySUU0fVq.cjs} +2 -1
- package/dist/cli.cjs +1 -1
- package/dist/devtools/browser-dev-host/index.html +128 -151
- package/dist/devtools/browser-dev-host/main.js +1553 -339
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/vite.cjs.js +1 -1
- package/dist/vite.esm.js +1 -1
- package/package.json +14 -7
- package/skill/SKILL.md +6 -6
- package/skill/references/api-protocol.md +1 -1
- package/skill/references/api-root.md +1 -1
- package/skill/references/cli.md +16 -20
- package/skill/references/examples.md +2 -2
- package/skill/references/safety-boundaries.md +1 -1
- package/skill/references/smoke-evaluation.md +1 -1
- package/skill/scripts/sync-references.mjs +6 -6
- package/skill/skill.json +4 -4
- package/types/protocol/dev-session.d.ts +13 -0
- package/dist/cli-chunks/dev-MpESCaMm.cjs +0 -2769
|
@@ -1,2769 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var fs = require('node:fs');
|
|
4
|
-
var os = require('node:os');
|
|
5
|
-
var path = require('node:path');
|
|
6
|
-
var net = require('node:net');
|
|
7
|
-
var promises = require('node:dns/promises');
|
|
8
|
-
var node_http = require('node:http');
|
|
9
|
-
var context = require('./context-C839--TH.cjs');
|
|
10
|
-
var node_crypto = require('node:crypto');
|
|
11
|
-
var undici = require('undici');
|
|
12
|
-
var browser = require('./browser-RAy8e8cV.cjs');
|
|
13
|
-
var index = require('./index-CT94XzyO.cjs');
|
|
14
|
-
var projectVite = require('./project-vite-zaw68V1G.cjs');
|
|
15
|
-
var fs$1 = require('node:fs/promises');
|
|
16
|
-
var runtimePermissionEnv = require('./runtime-permission-env-D-8_jPG3.cjs');
|
|
17
|
-
require('./session-D692TU5N.cjs');
|
|
18
|
-
require('fs');
|
|
19
|
-
require('constants');
|
|
20
|
-
require('stream');
|
|
21
|
-
require('util');
|
|
22
|
-
require('assert');
|
|
23
|
-
require('path');
|
|
24
|
-
require('node:process');
|
|
25
|
-
require('node:buffer');
|
|
26
|
-
require('node:url');
|
|
27
|
-
require('node:util');
|
|
28
|
-
require('node:child_process');
|
|
29
|
-
require('node:module');
|
|
30
|
-
require('os');
|
|
31
|
-
require('readline');
|
|
32
|
-
require('tty');
|
|
33
|
-
require('events');
|
|
34
|
-
require('buffer');
|
|
35
|
-
require('node:async_hooks');
|
|
36
|
-
|
|
37
|
-
class Locked extends Error {
|
|
38
|
-
constructor(port) {
|
|
39
|
-
super(`${port} is locked`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const lockedPorts = {
|
|
44
|
-
old: new Set(),
|
|
45
|
-
young: new Set(),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// On this interval, the old locked ports are discarded,
|
|
49
|
-
// the young locked ports are moved to old locked ports,
|
|
50
|
-
// and a new young set for locked ports are created.
|
|
51
|
-
const releaseOldLockedPortsIntervalMs = 1000 * 15;
|
|
52
|
-
|
|
53
|
-
// Keep `reserve` deliberately process-wide by port number.
|
|
54
|
-
// It is meant to avoid in-process races, not to model every possible
|
|
55
|
-
// IPv4/IPv6 or host-specific bind combination.
|
|
56
|
-
const reservedPorts = new Set();
|
|
57
|
-
|
|
58
|
-
// Lazily create timeout on first use
|
|
59
|
-
let timeout;
|
|
60
|
-
|
|
61
|
-
const getLocalHosts = () => {
|
|
62
|
-
const interfaces = os.networkInterfaces();
|
|
63
|
-
|
|
64
|
-
// Add undefined value for createServer function to use default host,
|
|
65
|
-
// and default IPv4 host in case createServer defaults to IPv6.
|
|
66
|
-
const results = new Set([undefined, '0.0.0.0']);
|
|
67
|
-
|
|
68
|
-
for (const _interface of Object.values(interfaces)) {
|
|
69
|
-
for (const config of _interface) {
|
|
70
|
-
results.add(config.address);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return results;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const checkAvailablePort = options =>
|
|
78
|
-
new Promise((resolve, reject) => {
|
|
79
|
-
const server = net.createServer();
|
|
80
|
-
server.unref();
|
|
81
|
-
server.on('error', reject);
|
|
82
|
-
|
|
83
|
-
server.listen(options, () => {
|
|
84
|
-
const {port} = server.address();
|
|
85
|
-
server.close(() => {
|
|
86
|
-
resolve(port);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const getAvailablePort = async (options, hosts) => {
|
|
92
|
-
if (options.host || options.port === 0) {
|
|
93
|
-
return checkAvailablePort(options);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
for (const host of hosts) {
|
|
97
|
-
try {
|
|
98
|
-
await checkAvailablePort({port: options.port, host}); // eslint-disable-line no-await-in-loop
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (!['EADDRNOTAVAIL', 'EINVAL'].includes(error.code)) {
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return options.port;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const isLockedPort = port => lockedPorts.old.has(port) || lockedPorts.young.has(port) || reservedPorts.has(port);
|
|
110
|
-
|
|
111
|
-
const portCheckSequence = function * (ports) {
|
|
112
|
-
if (ports) {
|
|
113
|
-
yield * ports;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
yield 0; // Fall back to 0 if anything else failed
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
async function getPorts(options) {
|
|
120
|
-
let ports;
|
|
121
|
-
let exclude = new Set();
|
|
122
|
-
|
|
123
|
-
if (options) {
|
|
124
|
-
if (options.port) {
|
|
125
|
-
ports = typeof options.port === 'number' ? [options.port] : options.port;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (options.exclude) {
|
|
129
|
-
const excludeIterable = options.exclude;
|
|
130
|
-
|
|
131
|
-
if (typeof excludeIterable[Symbol.iterator] !== 'function') {
|
|
132
|
-
throw new TypeError('The `exclude` option must be an iterable.');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
for (const element of excludeIterable) {
|
|
136
|
-
if (typeof element !== 'number') {
|
|
137
|
-
throw new TypeError('Each item in the `exclude` option must be a number corresponding to the port you want excluded.');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (!Number.isSafeInteger(element)) {
|
|
141
|
-
throw new TypeError(`Number ${element} in the exclude option is not a safe integer and can't be used`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
exclude = new Set(excludeIterable);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const {reserve, ...netOptions} = options ?? {};
|
|
150
|
-
|
|
151
|
-
if (timeout === undefined) {
|
|
152
|
-
timeout = setTimeout(() => {
|
|
153
|
-
timeout = undefined;
|
|
154
|
-
|
|
155
|
-
lockedPorts.old = lockedPorts.young;
|
|
156
|
-
lockedPorts.young = new Set();
|
|
157
|
-
}, releaseOldLockedPortsIntervalMs);
|
|
158
|
-
|
|
159
|
-
// Does not exist in some environments (Electron, Jest jsdom env, browser, etc).
|
|
160
|
-
if (timeout.unref) {
|
|
161
|
-
timeout.unref();
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const hosts = getLocalHosts();
|
|
166
|
-
|
|
167
|
-
for (const port of portCheckSequence(ports)) {
|
|
168
|
-
try {
|
|
169
|
-
if (exclude.has(port)) {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
let availablePort = await getAvailablePort({...netOptions, port}, hosts); // eslint-disable-line no-await-in-loop
|
|
174
|
-
while (isLockedPort(availablePort)) {
|
|
175
|
-
if (port !== 0) {
|
|
176
|
-
throw new Locked(port);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
availablePort = await getAvailablePort({...netOptions, port}, hosts); // eslint-disable-line no-await-in-loop
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (reserve) {
|
|
183
|
-
reservedPorts.add(availablePort);
|
|
184
|
-
} else {
|
|
185
|
-
lockedPorts.young.add(availablePort);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return availablePort;
|
|
189
|
-
} catch (error) {
|
|
190
|
-
if (!['EADDRINUSE', 'EACCES'].includes(error.code) && !(error instanceof Locked)) {
|
|
191
|
-
throw error;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
throw new Error('No available ports found');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Dev Shell 读取局域网 Dev Session endpoint 的 query 参数。 */
|
|
200
|
-
/** Browser Dev Host 创建和读取 Dev Session 的固定路径。 */
|
|
201
|
-
const MINI_PROGRAM_DEV_CONTEXT_PATH = '/__hb_sdk__/dev-context';
|
|
202
|
-
/** Dev Session 当前使用的 schema 版本。 */
|
|
203
|
-
const MINI_PROGRAM_DEV_SESSION_SCHEMA_VERSION = 1;
|
|
204
|
-
|
|
205
|
-
const BROWSER_DEV_HOST_JSON_BODY_LIMIT = 1024 * 1024;
|
|
206
|
-
function readJsonRequestBody(request) {
|
|
207
|
-
return new Promise((resolve, reject) => {
|
|
208
|
-
const chunks = [];
|
|
209
|
-
let size = 0;
|
|
210
|
-
request.on('data', (chunk) => {
|
|
211
|
-
const buffer = Buffer.from(chunk);
|
|
212
|
-
size += buffer.byteLength;
|
|
213
|
-
if (size > BROWSER_DEV_HOST_JSON_BODY_LIMIT) {
|
|
214
|
-
reject(createBrowserDevHostError(413, 'REQUEST_ENTITY_TOO_LARGE', 'Browser Dev Host network proxy request body too large'));
|
|
215
|
-
request.destroy();
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
chunks.push(buffer);
|
|
219
|
-
});
|
|
220
|
-
request.on('error', reject);
|
|
221
|
-
request.on('aborted', () => {
|
|
222
|
-
reject('Browser Dev Host network proxy request aborted');
|
|
223
|
-
});
|
|
224
|
-
request.on('end', () => {
|
|
225
|
-
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
226
|
-
if (!rawBody.trim()) {
|
|
227
|
-
resolve({});
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
resolve(JSON.parse(rawBody));
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
reject(createBrowserDevHostError(400, 'INVALID_JSON', 'Browser Dev Host network proxy received invalid JSON'));
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
function writeJsonResponse(response, status, body) {
|
|
240
|
-
response.writeHead(status, {
|
|
241
|
-
'content-type': 'application/json; charset=utf-8',
|
|
242
|
-
'cache-control': 'no-store',
|
|
243
|
-
});
|
|
244
|
-
response.end(body === undefined ? '' : JSON.stringify(body));
|
|
245
|
-
}
|
|
246
|
-
function writeTokenNetworkJsonResponse(response, status, body) {
|
|
247
|
-
response.writeHead(status, {
|
|
248
|
-
'access-control-allow-headers': 'content-type',
|
|
249
|
-
'access-control-allow-methods': 'POST, OPTIONS',
|
|
250
|
-
'access-control-allow-origin': '*',
|
|
251
|
-
'access-control-allow-private-network': 'true',
|
|
252
|
-
'cache-control': 'no-store',
|
|
253
|
-
'content-type': 'application/json; charset=utf-8',
|
|
254
|
-
});
|
|
255
|
-
response.end(body === undefined ? '' : JSON.stringify(body));
|
|
256
|
-
}
|
|
257
|
-
function writeSerializedJsonResponse(response, status, body) {
|
|
258
|
-
response.writeHead(status, {
|
|
259
|
-
'content-type': 'application/json; charset=utf-8',
|
|
260
|
-
'cache-control': 'no-store',
|
|
261
|
-
});
|
|
262
|
-
response.end(body);
|
|
263
|
-
}
|
|
264
|
-
function isRecord$1(value) {
|
|
265
|
-
return Object.prototype.toString.call(value) === '[object Object]';
|
|
266
|
-
}
|
|
267
|
-
function isObjectLike(value) {
|
|
268
|
-
return (typeof value === 'object' || typeof value === 'function') && value !== null;
|
|
269
|
-
}
|
|
270
|
-
function createBrowserDevHostError(status, code, message) {
|
|
271
|
-
const error = new Error(message);
|
|
272
|
-
error.code = code;
|
|
273
|
-
error.status = status;
|
|
274
|
-
return error;
|
|
275
|
-
}
|
|
276
|
-
function readBrowserDevHostErrorStatus(error) {
|
|
277
|
-
return isObjectLike(error) && typeof error.status === 'number' ? error.status : 500;
|
|
278
|
-
}
|
|
279
|
-
function toBrowserDevHostErrorPayload(error) {
|
|
280
|
-
if (isObjectLike(error) && typeof error.code === 'string' && typeof error.message === 'string') {
|
|
281
|
-
return {
|
|
282
|
-
code: error.code,
|
|
283
|
-
message: error.message,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
return {
|
|
287
|
-
code: 'BROWSER_DEV_HOST_NETWORK_ERROR',
|
|
288
|
-
message: readErrorMessage(error),
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
function readErrorMessage(error) {
|
|
292
|
-
return error instanceof Error ? error.message : String(error);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* network.request 诊断日志的纯脱敏工具。
|
|
297
|
-
*
|
|
298
|
-
* @remarks
|
|
299
|
-
* 放在 `core` 而非 `modules/network`:无错误类型依赖,供 SDK 错误文案、
|
|
300
|
-
* modules observability、devtools Browser Dev Host / proxy 共用,并满足 boundary
|
|
301
|
-
*(devtools 不得直接 import runtime capability modules)。
|
|
302
|
-
*/
|
|
303
|
-
/** 敏感字段名:header / query / body key 共用。 */
|
|
304
|
-
const SENSITIVE_KEY_PATTERN = /(authorization|cookie|password|passwd|secret|token|pkey|private[_-]?key|access[_-]?key|client[_-]?secret|api[_-]?key|apikey|session|jwt|refresh[_-]?token|csrf)/i;
|
|
305
|
-
/**
|
|
306
|
-
* 脱敏请求 URL:去掉 userinfo,并将 query / fragment 中的敏感参数值替换为 `[redacted]`。
|
|
307
|
-
*/
|
|
308
|
-
function sanitizeNetworkRequestUrl(url) {
|
|
309
|
-
if (!url) {
|
|
310
|
-
return url;
|
|
311
|
-
}
|
|
312
|
-
try {
|
|
313
|
-
const parsed = new URL(url);
|
|
314
|
-
parsed.username = '';
|
|
315
|
-
parsed.password = '';
|
|
316
|
-
redactUrlSearchParams(parsed.searchParams);
|
|
317
|
-
if (parsed.hash && parsed.hash.length > 1) {
|
|
318
|
-
const hashBody = parsed.hash.slice(1);
|
|
319
|
-
if (hashBody.includes('=')) {
|
|
320
|
-
const hashParams = new URLSearchParams(hashBody);
|
|
321
|
-
if (redactUrlSearchParams(hashParams)) {
|
|
322
|
-
parsed.hash = hashParams.toString();
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return parsed.toString();
|
|
327
|
-
}
|
|
328
|
-
catch {
|
|
329
|
-
return redactSensitivePairsInRawText(url);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
/**
|
|
333
|
-
* 诊断 message 中的 URL 片段做脱敏,避免 error.message 二次泄漏 query secrets。
|
|
334
|
-
*/
|
|
335
|
-
function sanitizeDiagnosticMessage(message) {
|
|
336
|
-
if (!message) {
|
|
337
|
-
return message;
|
|
338
|
-
}
|
|
339
|
-
return message.replace(/https?:\/\/[^\s]+/gi, matched => sanitizeNetworkRequestUrl(matched.replace(/[),.;]+$/g, '')));
|
|
340
|
-
}
|
|
341
|
-
function isSensitiveNetworkKey(key) {
|
|
342
|
-
return SENSITIVE_KEY_PATTERN.test(key.trim());
|
|
343
|
-
}
|
|
344
|
-
function redactUrlSearchParams(params) {
|
|
345
|
-
let changed = false;
|
|
346
|
-
for (const key of [...params.keys()]) {
|
|
347
|
-
if (isSensitiveNetworkKey(key)) {
|
|
348
|
-
params.set(key, '[redacted]');
|
|
349
|
-
changed = true;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return changed;
|
|
353
|
-
}
|
|
354
|
-
function redactSensitivePairsInRawText(text) {
|
|
355
|
-
return text.replace(/([?&#]|^|[?&])([^=&#\s]+)=([^&#\s]*)/g, (full, prefix, key, value) => {
|
|
356
|
-
if (!isSensitiveNetworkKey(key)) {
|
|
357
|
-
return full;
|
|
358
|
-
}
|
|
359
|
-
return `${prefix}${key}=[redacted]`;
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const MAX_BROWSER_DEV_HOST_NETWORK_REDIRECTS = 5;
|
|
364
|
-
const MAX_BROWSER_DEV_HOST_NETWORK_REQUEST_BODY = 2 * 1024 * 1024;
|
|
365
|
-
const MAX_BROWSER_DEV_HOST_NETWORK_RESPONSE_BODY = 5 * 1024 * 1024;
|
|
366
|
-
const DENIED_NETWORK_TARGETS = createDeniedNetworkTargets();
|
|
367
|
-
const CREDENTIAL_HEADER_RE = /^(?:authentication-info|authorization|cookie|cookie2|proxy-authenticate|proxy-authentication-info|proxy-authorization|set-cookie|set-cookie2|www-authenticate|pkey|x-pkey|x-access-token|x-auth-token|x-api-key)$/i;
|
|
368
|
-
async function handleBrowserDevHostNetworkProxy(request, response, fetchImpl, lookupHostname, options) {
|
|
369
|
-
const cors = options.cors === true;
|
|
370
|
-
if (request.method === 'OPTIONS') {
|
|
371
|
-
writeNetworkJsonResponse(response, 204, undefined, cors);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (request.method !== 'POST') {
|
|
375
|
-
writeNetworkJsonResponse(response, 405, {
|
|
376
|
-
code: 'METHOD_NOT_ALLOWED',
|
|
377
|
-
message: 'Browser Dev Host network proxy only supports POST',
|
|
378
|
-
}, cors);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
try {
|
|
382
|
-
const payload = await readJsonRequestBody(request);
|
|
383
|
-
const requestAbort = new AbortController();
|
|
384
|
-
if (request.aborted)
|
|
385
|
-
requestAbort.abort();
|
|
386
|
-
else
|
|
387
|
-
request.once('aborted', () => requestAbort.abort());
|
|
388
|
-
const result = await requestBrowserDevHostNetwork(payload, fetchImpl, lookupHostname, options.allowedHttpOrigins, requestAbort.signal);
|
|
389
|
-
if (typeof result.status === 'number' && (result.status < 200 || result.status >= 300)) {
|
|
390
|
-
logBrowserDevHostNetworkProxyDiagnostic('http_status', payload, result);
|
|
391
|
-
}
|
|
392
|
-
writeNetworkJsonResponse(response, 200, result, cors);
|
|
393
|
-
}
|
|
394
|
-
catch (error) {
|
|
395
|
-
logBrowserDevHostNetworkProxyDiagnostic('proxy_error', undefined, undefined, error);
|
|
396
|
-
writeNetworkJsonResponse(response, readBrowserDevHostErrorStatus(error), toBrowserDevHostErrorPayload(error), cors);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
function logBrowserDevHostNetworkProxyDiagnostic(phase, payload, result, error) {
|
|
400
|
-
try {
|
|
401
|
-
const consoleRef = typeof console === 'undefined' ? undefined : console;
|
|
402
|
-
const logger = consoleRef?.warn || consoleRef?.error || consoleRef?.log;
|
|
403
|
-
if (!logger) {
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const detail = { phase };
|
|
407
|
-
if (isRecord$1(payload) && typeof payload.url === 'string') {
|
|
408
|
-
detail.url = sanitizeNetworkRequestUrl(payload.url);
|
|
409
|
-
}
|
|
410
|
-
if (isRecord$1(payload) && typeof payload.method === 'string') {
|
|
411
|
-
detail.method = payload.method;
|
|
412
|
-
}
|
|
413
|
-
if (typeof result?.status === 'number') {
|
|
414
|
-
detail.status = result.status;
|
|
415
|
-
}
|
|
416
|
-
if (result?.statusText) {
|
|
417
|
-
detail.statusText = result.statusText;
|
|
418
|
-
}
|
|
419
|
-
if (error instanceof Error) {
|
|
420
|
-
detail.message = sanitizeDiagnosticMessage(error.message);
|
|
421
|
-
}
|
|
422
|
-
else if (error !== undefined) {
|
|
423
|
-
detail.message = sanitizeDiagnosticMessage(readErrorMessage(error));
|
|
424
|
-
}
|
|
425
|
-
const summaryUrl = typeof detail.url === 'string' ? detail.url : '';
|
|
426
|
-
logger.call(consoleRef, '[hb-sdk][browser-dev-host][network]', `${phase}${summaryUrl ? ` ${summaryUrl}` : ''}`, detail);
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
// ignore diagnostic failures
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
function writeNetworkJsonResponse(response, status, body, cors) {
|
|
433
|
-
if (cors) {
|
|
434
|
-
writeTokenNetworkJsonResponse(response, status, body);
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
writeJsonResponse(response, status, body);
|
|
438
|
-
}
|
|
439
|
-
const BROWSER_DEV_HOST_NETWORK_MULTIPART_UNSUPPORTED_MESSAGE = 'network.request 不支持 multipart/form-data。App Host 客户端代发仅支持 application/x-www-form-urlencoded(form)与 application/json;请将 data 编码为 form 字符串(例如 new URLSearchParams({...}).toString())并设置 Content-Type: application/x-www-form-urlencoded。文件上传请使用专用上传能力,勿手写 multipart boundary。';
|
|
440
|
-
async function requestBrowserDevHostNetwork(payload, fetchImpl, lookupHostname, allowedHttpOrigins, requestSignal) {
|
|
441
|
-
if (!isRecord$1(payload) || typeof payload.url !== 'string' || !payload.url.trim()) {
|
|
442
|
-
throw createBrowserDevHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是非空字符串');
|
|
443
|
-
}
|
|
444
|
-
const url = createBrowserDevHostNetworkUrl(payload.url, payload.params);
|
|
445
|
-
const method = typeof payload.method === 'string' && payload.method.trim() ? payload.method.trim().toUpperCase() : 'GET';
|
|
446
|
-
const headers = normalizeBrowserDevHostNetworkHeaders(payload.headers);
|
|
447
|
-
assertSupportedBrowserDevHostNetworkRequestShape(headers, payload.data);
|
|
448
|
-
const body = createBrowserDevHostNetworkBody(method, payload.data, headers);
|
|
449
|
-
const controller = new AbortController();
|
|
450
|
-
const onRequestAbort = () => controller.abort(requestSignal.reason);
|
|
451
|
-
requestSignal.addEventListener('abort', onRequestAbort, { once: true });
|
|
452
|
-
if (requestSignal.aborted)
|
|
453
|
-
onRequestAbort();
|
|
454
|
-
const timeout = Number(payload.timeout) > 0 ? Number(payload.timeout) : 10000;
|
|
455
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
456
|
-
const pinnedTargets = new Map();
|
|
457
|
-
const dispatcher = createPinnedNetworkDispatcher(pinnedTargets);
|
|
458
|
-
try {
|
|
459
|
-
const response = await fetchBrowserDevHostNetworkWithRedirects({
|
|
460
|
-
body,
|
|
461
|
-
dispatcher,
|
|
462
|
-
fetchImpl,
|
|
463
|
-
headers,
|
|
464
|
-
lookupHostname,
|
|
465
|
-
allowedHttpOrigins,
|
|
466
|
-
method,
|
|
467
|
-
pinnedTargets,
|
|
468
|
-
signal: controller.signal,
|
|
469
|
-
url,
|
|
470
|
-
});
|
|
471
|
-
const contentType = response.headers.get('content-type') || '';
|
|
472
|
-
const text = await readLimitedBrowserDevHostNetworkResponse(response);
|
|
473
|
-
return {
|
|
474
|
-
data: contentType.includes('application/json') ? safeJsonParse(text) : text,
|
|
475
|
-
status: response.status,
|
|
476
|
-
statusText: response.statusText,
|
|
477
|
-
headers: Object.fromEntries([...response.headers.entries()].filter(([name]) => !isCredentialHeader(name))),
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
catch (error) {
|
|
481
|
-
if (isObjectLike(error) && typeof error.status === 'number') {
|
|
482
|
-
throw error;
|
|
483
|
-
}
|
|
484
|
-
throw createBrowserDevHostError(502, 'BROWSER_DEV_HOST_NETWORK_REQUEST_FAILED', error instanceof Error && error.name === 'AbortError' ? `network.request timeout after ${timeout}ms` : readErrorMessage(error));
|
|
485
|
-
}
|
|
486
|
-
finally {
|
|
487
|
-
requestSignal.removeEventListener('abort', onRequestAbort);
|
|
488
|
-
clearTimeout(timer);
|
|
489
|
-
await dispatcher.close();
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
async function fetchBrowserDevHostNetworkWithRedirects(options) {
|
|
493
|
-
let url = options.url;
|
|
494
|
-
let method = options.method;
|
|
495
|
-
let body = options.body;
|
|
496
|
-
for (let redirectCount = 0; redirectCount <= MAX_BROWSER_DEV_HOST_NETWORK_REDIRECTS; redirectCount += 1) {
|
|
497
|
-
const pinnedTarget = await resolveBrowserDevHostNetworkTarget(url, options.lookupHostname, options.allowedHttpOrigins);
|
|
498
|
-
options.pinnedTargets.set(createPinnedNetworkTargetKey(url), pinnedTarget);
|
|
499
|
-
const response = await options.fetchImpl(url.toString(), {
|
|
500
|
-
body,
|
|
501
|
-
dispatcher: options.dispatcher,
|
|
502
|
-
headers: options.headers,
|
|
503
|
-
method,
|
|
504
|
-
redirect: 'manual',
|
|
505
|
-
signal: options.signal,
|
|
506
|
-
});
|
|
507
|
-
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
|
508
|
-
return response;
|
|
509
|
-
}
|
|
510
|
-
const location = response.headers.get('location');
|
|
511
|
-
if (!location) {
|
|
512
|
-
return response;
|
|
513
|
-
}
|
|
514
|
-
if (redirectCount === MAX_BROWSER_DEV_HOST_NETWORK_REDIRECTS) {
|
|
515
|
-
throw createBrowserDevHostError(502, 'TOO_MANY_REDIRECTS', 'network.request redirect limit exceeded');
|
|
516
|
-
}
|
|
517
|
-
await response.body?.cancel();
|
|
518
|
-
const redirectedUrl = new URL(location, url);
|
|
519
|
-
if (redirectedUrl.origin !== url.origin) {
|
|
520
|
-
throw createBrowserDevHostError(403, 'NETWORK_REDIRECT_DENIED', 'network.request 不允许跨 origin redirect');
|
|
521
|
-
}
|
|
522
|
-
url = redirectedUrl;
|
|
523
|
-
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
|
|
524
|
-
method = 'GET';
|
|
525
|
-
body = undefined;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
throw createBrowserDevHostError(502, 'TOO_MANY_REDIRECTS', 'network.request redirect limit exceeded');
|
|
529
|
-
}
|
|
530
|
-
async function resolveBrowserDevHostNetworkTarget(url, lookupHostname, allowedHttpOrigins) {
|
|
531
|
-
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
532
|
-
throw createBrowserDevHostError(403, 'NETWORK_TARGET_DENIED', 'network.request redirect 仅支持 HTTP(S) URL');
|
|
533
|
-
}
|
|
534
|
-
if (url.username || url.password) {
|
|
535
|
-
throw createBrowserDevHostError(403, 'NETWORK_TARGET_DENIED', 'network.request URL 不允许包含凭据');
|
|
536
|
-
}
|
|
537
|
-
if (url.protocol === 'http:' && !allowedHttpOrigins.has(url.origin)) {
|
|
538
|
-
throw createBrowserDevHostError(403, 'NETWORK_TARGET_DENIED', 'network.request HTTP 仅允许已声明的本地开发 origin');
|
|
539
|
-
}
|
|
540
|
-
const hostname = normalizeNetworkHostname(url.hostname);
|
|
541
|
-
const addressFamily = net.isIP(hostname);
|
|
542
|
-
const addresses = addressFamily ? [{ address: hostname, family: addressFamily }] : await lookupHostname(hostname);
|
|
543
|
-
if (!addresses.length) {
|
|
544
|
-
throw new Error('network.request target DNS lookup returned no addresses');
|
|
545
|
-
}
|
|
546
|
-
if (url.protocol === 'https:' && addresses.some(({ address, family }) => isDeniedNetworkAddress(address, family))) {
|
|
547
|
-
throw createBrowserDevHostError(403, 'NETWORK_TARGET_DENIED', 'network.request 目标地址不允许访问');
|
|
548
|
-
}
|
|
549
|
-
return addresses[0];
|
|
550
|
-
}
|
|
551
|
-
function createDeniedNetworkTargets() {
|
|
552
|
-
const list = new net.BlockList();
|
|
553
|
-
for (const [network, prefix] of [
|
|
554
|
-
['0.0.0.0', 8],
|
|
555
|
-
['10.0.0.0', 8],
|
|
556
|
-
['100.64.0.0', 10],
|
|
557
|
-
['127.0.0.0', 8],
|
|
558
|
-
['169.254.0.0', 16],
|
|
559
|
-
['172.16.0.0', 12],
|
|
560
|
-
['192.0.0.0', 24],
|
|
561
|
-
['192.0.2.0', 24],
|
|
562
|
-
['192.168.0.0', 16],
|
|
563
|
-
['198.18.0.0', 15],
|
|
564
|
-
['198.51.100.0', 24],
|
|
565
|
-
['203.0.113.0', 24],
|
|
566
|
-
['224.0.0.0', 4],
|
|
567
|
-
['240.0.0.0', 4],
|
|
568
|
-
]) {
|
|
569
|
-
list.addSubnet(network, prefix, 'ipv4');
|
|
570
|
-
}
|
|
571
|
-
for (const [network, prefix] of [
|
|
572
|
-
['::', 128],
|
|
573
|
-
['::1', 128],
|
|
574
|
-
['64:ff9b::', 96],
|
|
575
|
-
['64:ff9b:1::', 48],
|
|
576
|
-
['2001::', 32],
|
|
577
|
-
['2001:10::', 28],
|
|
578
|
-
['2001:20::', 28],
|
|
579
|
-
['2001:db8::', 32],
|
|
580
|
-
['2002::', 16],
|
|
581
|
-
['fc00::', 7],
|
|
582
|
-
['fe80::', 10],
|
|
583
|
-
['ff00::', 8],
|
|
584
|
-
]) {
|
|
585
|
-
list.addSubnet(network, prefix, 'ipv6');
|
|
586
|
-
}
|
|
587
|
-
return list;
|
|
588
|
-
}
|
|
589
|
-
function isDeniedNetworkAddress(address, family) {
|
|
590
|
-
const normalized = normalizeNetworkHostname(address);
|
|
591
|
-
if (family === 4 || net.isIP(normalized) === 4)
|
|
592
|
-
return DENIED_NETWORK_TARGETS.check(normalized, 'ipv4');
|
|
593
|
-
if (family === 6 || net.isIP(normalized) === 6) {
|
|
594
|
-
if (normalized.toLowerCase().startsWith('::ffff:'))
|
|
595
|
-
return true;
|
|
596
|
-
return DENIED_NETWORK_TARGETS.check(normalized, 'ipv6');
|
|
597
|
-
}
|
|
598
|
-
return true;
|
|
599
|
-
}
|
|
600
|
-
function isCredentialHeader(name) {
|
|
601
|
-
const normalized = name.trim().toLowerCase();
|
|
602
|
-
return CREDENTIAL_HEADER_RE.test(normalized) || normalized.startsWith('x-heybox-') || normalized.startsWith('x-xhh-');
|
|
603
|
-
}
|
|
604
|
-
function createPinnedNetworkDispatcher(pinnedTargets) {
|
|
605
|
-
const connect = undici.buildConnector({});
|
|
606
|
-
return new undici.Agent({
|
|
607
|
-
connect(options, callback) {
|
|
608
|
-
const hostname = normalizeNetworkHostname(options.hostname);
|
|
609
|
-
const target = pinnedTargets.get(`${options.protocol}//${hostname}:${options.port || (options.protocol === 'https:' ? '443' : '80')}`);
|
|
610
|
-
if (!target) {
|
|
611
|
-
callback(new Error('network.request target was not DNS-pinned'), null);
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
connect({
|
|
615
|
-
...options,
|
|
616
|
-
hostname: target.address,
|
|
617
|
-
servername: options.servername || hostname,
|
|
618
|
-
}, callback);
|
|
619
|
-
},
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
function createPinnedNetworkTargetKey(url) {
|
|
623
|
-
return `${url.protocol}//${normalizeNetworkHostname(url.hostname)}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`;
|
|
624
|
-
}
|
|
625
|
-
function normalizeNetworkHostname(hostname) {
|
|
626
|
-
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
627
|
-
}
|
|
628
|
-
async function readLimitedBrowserDevHostNetworkResponse(response) {
|
|
629
|
-
if (!response.body) {
|
|
630
|
-
return '';
|
|
631
|
-
}
|
|
632
|
-
const reader = response.body.getReader();
|
|
633
|
-
const chunks = [];
|
|
634
|
-
let size = 0;
|
|
635
|
-
while (true) {
|
|
636
|
-
const { done, value } = await reader.read();
|
|
637
|
-
if (done) {
|
|
638
|
-
break;
|
|
639
|
-
}
|
|
640
|
-
size += value.byteLength;
|
|
641
|
-
if (size > MAX_BROWSER_DEV_HOST_NETWORK_RESPONSE_BODY) {
|
|
642
|
-
await reader.cancel();
|
|
643
|
-
throw createBrowserDevHostError(502, 'NETWORK_RESPONSE_TOO_LARGE', 'network.request response body too large');
|
|
644
|
-
}
|
|
645
|
-
chunks.push(value);
|
|
646
|
-
}
|
|
647
|
-
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
|
|
648
|
-
}
|
|
649
|
-
function createBrowserDevHostNetworkUrl(input, params) {
|
|
650
|
-
let url;
|
|
651
|
-
try {
|
|
652
|
-
url = new URL(input);
|
|
653
|
-
}
|
|
654
|
-
catch {
|
|
655
|
-
throw createBrowserDevHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是合法 URL');
|
|
656
|
-
}
|
|
657
|
-
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
658
|
-
throw createBrowserDevHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request 仅支持 HTTP(S) URL');
|
|
659
|
-
}
|
|
660
|
-
if (isRecord$1(params)) {
|
|
661
|
-
Object.entries(params).forEach(([key, value]) => {
|
|
662
|
-
if (value !== undefined && value !== null) {
|
|
663
|
-
url.searchParams.set(key, String(value));
|
|
664
|
-
}
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
return url;
|
|
668
|
-
}
|
|
669
|
-
function normalizeBrowserDevHostNetworkHeaders(headers) {
|
|
670
|
-
const normalized = new Headers();
|
|
671
|
-
if (!isRecord$1(headers)) {
|
|
672
|
-
return normalized;
|
|
673
|
-
}
|
|
674
|
-
Object.entries(headers).forEach(([key, value]) => {
|
|
675
|
-
if (isSensitiveBrowserDevHostNetworkHeader(key)) {
|
|
676
|
-
throw createBrowserDevHostError(400, 'INVALID_NETWORK_REQUEST', `network.request 不允许设置敏感请求头: ${key}`);
|
|
677
|
-
}
|
|
678
|
-
if (typeof value === 'string' && !isBlockedBrowserDevHostNetworkHeader(key)) {
|
|
679
|
-
normalized.set(key, value);
|
|
680
|
-
}
|
|
681
|
-
});
|
|
682
|
-
return normalized;
|
|
683
|
-
}
|
|
684
|
-
function isSensitiveBrowserDevHostNetworkHeader(key) {
|
|
685
|
-
return ['authorization', 'cookie', 'proxy-authorization'].includes(key.toLowerCase());
|
|
686
|
-
}
|
|
687
|
-
function isBlockedBrowserDevHostNetworkHeader(key) {
|
|
688
|
-
return [
|
|
689
|
-
'connection',
|
|
690
|
-
'content-length',
|
|
691
|
-
'host',
|
|
692
|
-
'keep-alive',
|
|
693
|
-
'proxy-authenticate',
|
|
694
|
-
'proxy-authorization',
|
|
695
|
-
'te',
|
|
696
|
-
'trailer',
|
|
697
|
-
'transfer-encoding',
|
|
698
|
-
'upgrade',
|
|
699
|
-
].includes(key.toLowerCase());
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* 与 App Host 对齐:Mock 不再放行 multipart,避免“本地成功、上线失败”。
|
|
703
|
-
*/
|
|
704
|
-
function assertSupportedBrowserDevHostNetworkRequestShape(headers, data) {
|
|
705
|
-
const contentType = headers.get('content-type') || '';
|
|
706
|
-
if (contentType.toLowerCase().includes('multipart/form-data') || looksLikeMultipartBody(data)) {
|
|
707
|
-
throw createBrowserDevHostError(400, 'INVALID_NETWORK_REQUEST', BROWSER_DEV_HOST_NETWORK_MULTIPART_UNSUPPORTED_MESSAGE);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
function looksLikeMultipartBody(data) {
|
|
711
|
-
if (typeof data !== 'string') {
|
|
712
|
-
return false;
|
|
713
|
-
}
|
|
714
|
-
const trimmed = data.trimStart();
|
|
715
|
-
return trimmed.startsWith('--') && /content-disposition\s*:\s*form-data/i.test(data);
|
|
716
|
-
}
|
|
717
|
-
function createBrowserDevHostNetworkBody(method, data, headers) {
|
|
718
|
-
if (data === undefined || method === 'GET' || method === 'HEAD') {
|
|
719
|
-
return undefined;
|
|
720
|
-
}
|
|
721
|
-
const body = typeof data === 'string' ? data : JSON.stringify(data);
|
|
722
|
-
if (Buffer.byteLength(body) > MAX_BROWSER_DEV_HOST_NETWORK_REQUEST_BODY) {
|
|
723
|
-
throw createBrowserDevHostError(413, 'REQUEST_ENTITY_TOO_LARGE', 'network.request data exceeds 2097152 bytes');
|
|
724
|
-
}
|
|
725
|
-
if (typeof data === 'string') {
|
|
726
|
-
return body;
|
|
727
|
-
}
|
|
728
|
-
if (!headers.has('content-type')) {
|
|
729
|
-
headers.set('content-type', 'application/json');
|
|
730
|
-
}
|
|
731
|
-
return body;
|
|
732
|
-
}
|
|
733
|
-
function safeJsonParse(text) {
|
|
734
|
-
try {
|
|
735
|
-
return JSON.parse(text);
|
|
736
|
-
}
|
|
737
|
-
catch {
|
|
738
|
-
return text;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
const DEV_SESSION_TTL_MS = 5 * 60 * 1000;
|
|
743
|
-
const MAX_ACTIVE_DEV_SESSIONS = 64;
|
|
744
|
-
const MAX_EXPIRED_DEV_SESSION_TOMBSTONES = 128;
|
|
745
|
-
function createDevSessionRoutes(options) {
|
|
746
|
-
const devSessions = new Map();
|
|
747
|
-
const expiredDevSessions = new Set();
|
|
748
|
-
let revision = 0;
|
|
749
|
-
return {
|
|
750
|
-
match(pathname) {
|
|
751
|
-
const match = pathname.match(/^\/__hb_sdk__\/dev-context\/([a-f0-9]{64})(\/network)?$/);
|
|
752
|
-
if (!match) {
|
|
753
|
-
return undefined;
|
|
754
|
-
}
|
|
755
|
-
return {
|
|
756
|
-
kind: match[2] ? 'network' : 'context',
|
|
757
|
-
token: match[1],
|
|
758
|
-
};
|
|
759
|
-
},
|
|
760
|
-
async create(request, response) {
|
|
761
|
-
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
762
|
-
writeJsonResponse(response, 400, { code: 'INVALID_DEV_SESSION', message: 'Dev Session body must be JSON' });
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
try {
|
|
766
|
-
const payload = await readJsonRequestBody(request);
|
|
767
|
-
const validated = validateDevSessionCreatePayload(payload, options.allowedMiniUrlOrigins);
|
|
768
|
-
reapExpiredDevSessions(devSessions, expiredDevSessions, options.now());
|
|
769
|
-
revokeDevSessionForOrigin(devSessions, validated.miniUrlOrigin);
|
|
770
|
-
if (devSessions.size >= MAX_ACTIVE_DEV_SESSIONS) {
|
|
771
|
-
throw createBrowserDevHostError(429, 'DEV_SESSION_LIMIT_REACHED', 'active Dev Session limit reached');
|
|
772
|
-
}
|
|
773
|
-
const expiresAt = options.now() + DEV_SESSION_TTL_MS;
|
|
774
|
-
const token = createDevSessionToken(devSessions);
|
|
775
|
-
const snapshot = {
|
|
776
|
-
schema_version: MINI_PROGRAM_DEV_SESSION_SCHEMA_VERSION,
|
|
777
|
-
revision: ++revision,
|
|
778
|
-
expires_at: expiresAt,
|
|
779
|
-
mini_url_origin: validated.miniUrlOrigin,
|
|
780
|
-
runtime_permissions: validated.runtimePermissions,
|
|
781
|
-
};
|
|
782
|
-
devSessions.set(token, {
|
|
783
|
-
expiresAt,
|
|
784
|
-
miniUrlOrigin: validated.miniUrlOrigin,
|
|
785
|
-
networkRequestEnabled: validated.networkRequestEnabled,
|
|
786
|
-
serialized: JSON.stringify(snapshot),
|
|
787
|
-
});
|
|
788
|
-
const result = { expires_at: expiresAt, token };
|
|
789
|
-
writeJsonResponse(response, 201, result);
|
|
790
|
-
}
|
|
791
|
-
catch (error) {
|
|
792
|
-
const status = readBrowserDevHostErrorStatus(error);
|
|
793
|
-
writeJsonResponse(response, status === 500 ? 400 : status, {
|
|
794
|
-
code: 'INVALID_DEV_SESSION',
|
|
795
|
-
message: readErrorMessage(error),
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
},
|
|
799
|
-
async handle(route, request, response) {
|
|
800
|
-
if (route.kind === 'network') {
|
|
801
|
-
await handleTokenNetworkProxy(request, response, route.token, {
|
|
802
|
-
devSessions,
|
|
803
|
-
expiredDevSessions,
|
|
804
|
-
fetchImpl: options.fetchImpl,
|
|
805
|
-
lookupHostname: options.lookupHostname,
|
|
806
|
-
now: options.now,
|
|
807
|
-
});
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
if (request.method !== 'OPTIONS' && request.method !== 'GET') {
|
|
811
|
-
response.writeHead(405, { allow: 'GET', 'cache-control': 'no-store' });
|
|
812
|
-
response.end();
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
const resolved = resolveDevSession(route.token, devSessions, expiredDevSessions, options.now());
|
|
816
|
-
if (resolved.status !== 200) {
|
|
817
|
-
writeDevSessionCorsResponse(response, resolved.status);
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
writeDevSessionCorsResponse(response, request.method === 'OPTIONS' ? 204 : 200, request.method === 'GET' ? resolved.session.serialized : undefined);
|
|
821
|
-
},
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
function validateDevSessionCreatePayload(payload, allowedMiniUrlOrigins) {
|
|
825
|
-
if (!isRecord$1(payload) || Object.keys(payload).some((key) => !['mini_url_origin', 'runtime_permissions'].includes(key))) {
|
|
826
|
-
throw new TypeError('Dev Session body is invalid');
|
|
827
|
-
}
|
|
828
|
-
if (typeof payload.mini_url_origin !== 'string') {
|
|
829
|
-
throw new TypeError('mini_url_origin is invalid');
|
|
830
|
-
}
|
|
831
|
-
let miniUrlOrigin;
|
|
832
|
-
try {
|
|
833
|
-
miniUrlOrigin = new URL(payload.mini_url_origin);
|
|
834
|
-
}
|
|
835
|
-
catch {
|
|
836
|
-
throw new TypeError('mini_url_origin is invalid');
|
|
837
|
-
}
|
|
838
|
-
if (miniUrlOrigin.protocol !== 'http:' ||
|
|
839
|
-
payload.mini_url_origin !== miniUrlOrigin.origin ||
|
|
840
|
-
!isPrivateLanIpv4(miniUrlOrigin.hostname) ||
|
|
841
|
-
!allowedMiniUrlOrigins.has(miniUrlOrigin.origin)) {
|
|
842
|
-
throw new TypeError('mini_url_origin must be an allowed LAN origin');
|
|
843
|
-
}
|
|
844
|
-
const parsedPermissions = context.parseMiniProgramRuntimePermissions(payload.runtime_permissions);
|
|
845
|
-
if (!parsedPermissions.valid) {
|
|
846
|
-
throw new TypeError('runtime_permissions is invalid');
|
|
847
|
-
}
|
|
848
|
-
const networkPermission = parsedPermissions.permissions['network.request'];
|
|
849
|
-
if (networkPermission?.config.useOfficialDomain !== false) {
|
|
850
|
-
throw new TypeError('Dev Session does not allow official domains');
|
|
851
|
-
}
|
|
852
|
-
return {
|
|
853
|
-
miniUrlOrigin: miniUrlOrigin.origin,
|
|
854
|
-
networkRequestEnabled: networkPermission.status === 'enabled',
|
|
855
|
-
runtimePermissions: JSON.parse(JSON.stringify(payload.runtime_permissions)),
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
async function handleTokenNetworkProxy(request, response, token, runtime) {
|
|
859
|
-
const resolved = resolveDevSession(token, runtime.devSessions, runtime.expiredDevSessions, runtime.now());
|
|
860
|
-
if (resolved.status !== 200) {
|
|
861
|
-
writeTokenNetworkJsonResponse(response, resolved.status, undefined);
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
if (!resolved.session.networkRequestEnabled) {
|
|
865
|
-
writeTokenNetworkJsonResponse(response, 403, {
|
|
866
|
-
code: 'PERMISSION_DENIED',
|
|
867
|
-
message: 'network.request is disabled for this Dev Session',
|
|
868
|
-
});
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (request.method === 'OPTIONS') {
|
|
872
|
-
writeTokenNetworkJsonResponse(response, 204, undefined);
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
if (request.method !== 'POST') {
|
|
876
|
-
writeTokenNetworkJsonResponse(response, 405, {
|
|
877
|
-
code: 'METHOD_NOT_ALLOWED',
|
|
878
|
-
message: 'Dev Session network proxy only supports POST',
|
|
879
|
-
});
|
|
880
|
-
return;
|
|
881
|
-
}
|
|
882
|
-
await handleBrowserDevHostNetworkProxy(request, response, runtime.fetchImpl, runtime.lookupHostname, {
|
|
883
|
-
allowedHttpOrigins: new Set([resolved.session.miniUrlOrigin]),
|
|
884
|
-
cors: true,
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
function resolveDevSession(token, devSessions, expiredDevSessions, now) {
|
|
888
|
-
const session = devSessions.get(token);
|
|
889
|
-
if (!session) {
|
|
890
|
-
return { status: expiredDevSessions.has(token) ? 410 : 404 };
|
|
891
|
-
}
|
|
892
|
-
if (now >= session.expiresAt) {
|
|
893
|
-
devSessions.delete(token);
|
|
894
|
-
addExpiredDevSessionTombstone(expiredDevSessions, token);
|
|
895
|
-
return { status: 410 };
|
|
896
|
-
}
|
|
897
|
-
return { session, status: 200 };
|
|
898
|
-
}
|
|
899
|
-
function reapExpiredDevSessions(devSessions, expiredDevSessions, now) {
|
|
900
|
-
for (const [token, session] of devSessions) {
|
|
901
|
-
if (now >= session.expiresAt) {
|
|
902
|
-
devSessions.delete(token);
|
|
903
|
-
addExpiredDevSessionTombstone(expiredDevSessions, token);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
function addExpiredDevSessionTombstone(expiredDevSessions, token) {
|
|
908
|
-
expiredDevSessions.add(token);
|
|
909
|
-
while (expiredDevSessions.size > MAX_EXPIRED_DEV_SESSION_TOMBSTONES) {
|
|
910
|
-
const oldest = expiredDevSessions.values().next().value;
|
|
911
|
-
if (!oldest) {
|
|
912
|
-
break;
|
|
913
|
-
}
|
|
914
|
-
expiredDevSessions.delete(oldest);
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
function revokeDevSessionForOrigin(devSessions, miniUrlOrigin) {
|
|
918
|
-
for (const [token, session] of devSessions) {
|
|
919
|
-
if (session.miniUrlOrigin === miniUrlOrigin) {
|
|
920
|
-
devSessions.delete(token);
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
function createDevSessionToken(devSessions) {
|
|
925
|
-
let token;
|
|
926
|
-
do {
|
|
927
|
-
token = node_crypto.randomBytes(32).toString('hex');
|
|
928
|
-
} while (devSessions.has(token));
|
|
929
|
-
return token;
|
|
930
|
-
}
|
|
931
|
-
function isPrivateLanIpv4(hostname) {
|
|
932
|
-
const octets = hostname.split('.').map(Number);
|
|
933
|
-
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
934
|
-
return false;
|
|
935
|
-
}
|
|
936
|
-
return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
|
937
|
-
}
|
|
938
|
-
function writeDevSessionCorsResponse(response, status, serializedBody) {
|
|
939
|
-
response.writeHead(status, {
|
|
940
|
-
'access-control-allow-methods': 'GET',
|
|
941
|
-
'access-control-allow-origin': '*',
|
|
942
|
-
'access-control-allow-private-network': 'true',
|
|
943
|
-
'cache-control': 'no-store',
|
|
944
|
-
...(serializedBody === undefined ? {} : { 'content-type': 'application/json; charset=utf-8' }),
|
|
945
|
-
});
|
|
946
|
-
response.end(serializedBody ?? '');
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
const LOGIN_CLIENT = 'hb-sdk-browser-dev-host';
|
|
950
|
-
const LOGIN_ENVIRONMENT = 'development';
|
|
951
|
-
const LOGIN_CALLBACK_PATH = '/session/callback';
|
|
952
|
-
const SESSION_COOKIE_NAME = 'hb_sdk_browser_dev_session';
|
|
953
|
-
const LOGIN_INTENT_TTL_MS = 5 * 60 * 1000;
|
|
954
|
-
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|
955
|
-
class BrowserDevSessionInvalidatedError extends Error {
|
|
956
|
-
code = 'BROWSER_DEV_SESSION_INVALIDATED';
|
|
957
|
-
constructor() {
|
|
958
|
-
super('Browser Dev Host session is no longer valid');
|
|
959
|
-
this.name = 'BrowserDevSessionInvalidatedError';
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
class BrowserDevSessionError extends Error {
|
|
963
|
-
status;
|
|
964
|
-
code;
|
|
965
|
-
constructor(status, code, message) {
|
|
966
|
-
super(message);
|
|
967
|
-
this.status = status;
|
|
968
|
-
this.code = code;
|
|
969
|
-
this.name = 'BrowserDevSessionError';
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
function createBrowserDevHostSessionController(options = {}) {
|
|
973
|
-
const now = options.now ?? Date.now;
|
|
974
|
-
const randomBytes = options.randomBytes ?? node_crypto.randomBytes;
|
|
975
|
-
const loginIntents = new Map();
|
|
976
|
-
const sessions = new Map();
|
|
977
|
-
const revisionListeners = new Set();
|
|
978
|
-
let revision = 0;
|
|
979
|
-
return {
|
|
980
|
-
csrfToken: createOpaqueValue(randomBytes, 32),
|
|
981
|
-
async createLoginIntent(input) {
|
|
982
|
-
const service = options.loginService;
|
|
983
|
-
if (!service) {
|
|
984
|
-
throw new BrowserDevSessionError(503, 'LOGIN_SERVICE_UNAVAILABLE', 'Browser Dev Host login service is unavailable');
|
|
985
|
-
}
|
|
986
|
-
reapExpiredState();
|
|
987
|
-
const state = createUniqueValue(loginIntents, randomBytes);
|
|
988
|
-
const verifier = createOpaqueValue(randomBytes, 32);
|
|
989
|
-
const redirectUri = `${normalizeOrigin(input.origin)}${LOGIN_CALLBACK_PATH}`;
|
|
990
|
-
const returnPath = normalizeReturnPath(input.returnPath);
|
|
991
|
-
const codeChallenge = node_crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
992
|
-
loginIntents.set(state, {
|
|
993
|
-
expiresAt: now() + LOGIN_INTENT_TTL_MS,
|
|
994
|
-
redirectUri,
|
|
995
|
-
returnPath,
|
|
996
|
-
verifier,
|
|
997
|
-
});
|
|
998
|
-
try {
|
|
999
|
-
const result = await service.createAuthorizationUrl({
|
|
1000
|
-
client: LOGIN_CLIENT,
|
|
1001
|
-
codeChallenge,
|
|
1002
|
-
codeChallengeMethod: 'S256',
|
|
1003
|
-
environment: LOGIN_ENVIRONMENT,
|
|
1004
|
-
redirectUri,
|
|
1005
|
-
state,
|
|
1006
|
-
});
|
|
1007
|
-
const authorizationUrl = normalizeAuthorizationUrl(result.authorizationUrl, {
|
|
1008
|
-
codeChallenge,
|
|
1009
|
-
redirectUri,
|
|
1010
|
-
state,
|
|
1011
|
-
});
|
|
1012
|
-
return { authorizationUrl, state };
|
|
1013
|
-
}
|
|
1014
|
-
catch (error) {
|
|
1015
|
-
loginIntents.delete(state);
|
|
1016
|
-
if (error instanceof BrowserDevSessionError)
|
|
1017
|
-
throw error;
|
|
1018
|
-
throw new BrowserDevSessionError(502, 'LOGIN_SERVICE_ERROR', 'Browser Dev Host login service failed');
|
|
1019
|
-
}
|
|
1020
|
-
},
|
|
1021
|
-
async completeLogin(input) {
|
|
1022
|
-
const service = options.loginService;
|
|
1023
|
-
if (!service) {
|
|
1024
|
-
throw new BrowserDevSessionError(503, 'LOGIN_SERVICE_UNAVAILABLE', 'Browser Dev Host login service is unavailable');
|
|
1025
|
-
}
|
|
1026
|
-
reapExpiredState();
|
|
1027
|
-
const intent = loginIntents.get(input.state);
|
|
1028
|
-
if (!intent || intent.redirectUri !== `${normalizeOrigin(input.origin)}${LOGIN_CALLBACK_PATH}`) {
|
|
1029
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_STATE', 'Browser Dev Host login state is invalid');
|
|
1030
|
-
}
|
|
1031
|
-
loginIntents.delete(input.state);
|
|
1032
|
-
if (!input.code || input.code.length > 4096) {
|
|
1033
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_CODE', 'Browser Dev Host login code is invalid');
|
|
1034
|
-
}
|
|
1035
|
-
let exchanged;
|
|
1036
|
-
try {
|
|
1037
|
-
exchanged = await service.exchangeCode({
|
|
1038
|
-
client: LOGIN_CLIENT,
|
|
1039
|
-
code: input.code,
|
|
1040
|
-
codeVerifier: intent.verifier,
|
|
1041
|
-
environment: LOGIN_ENVIRONMENT,
|
|
1042
|
-
redirectUri: intent.redirectUri,
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
catch {
|
|
1046
|
-
throw new BrowserDevSessionError(502, 'LOGIN_EXCHANGE_FAILED', 'Browser Dev Host login exchange failed');
|
|
1047
|
-
}
|
|
1048
|
-
const userId = normalizeUserId(exchanged.userId);
|
|
1049
|
-
const credential = normalizeCredential(exchanged.credential);
|
|
1050
|
-
const sessionId = createUniqueValue(sessions, randomBytes);
|
|
1051
|
-
const expiresAt = now() + SESSION_TTL_MS;
|
|
1052
|
-
const expiryTimer = setTimeout(() => {
|
|
1053
|
-
if (sessions.delete(sessionId))
|
|
1054
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1055
|
-
}, Math.max(0, expiresAt - now()));
|
|
1056
|
-
expiryTimer.unref?.();
|
|
1057
|
-
sessions.set(sessionId, {
|
|
1058
|
-
credential,
|
|
1059
|
-
expiresAt,
|
|
1060
|
-
expiryTimer,
|
|
1061
|
-
userId,
|
|
1062
|
-
});
|
|
1063
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1064
|
-
return {
|
|
1065
|
-
redirectPath: intent.returnPath,
|
|
1066
|
-
setCookie: createSessionCookie(sessionId),
|
|
1067
|
-
};
|
|
1068
|
-
},
|
|
1069
|
-
readStatus(cookieHeader) {
|
|
1070
|
-
reapExpiredState();
|
|
1071
|
-
const session = readSession(cookieHeader, sessions);
|
|
1072
|
-
return session ? { authenticated: true, revision, userId: session.userId } : { authenticated: false, revision };
|
|
1073
|
-
},
|
|
1074
|
-
logout(cookieHeader) {
|
|
1075
|
-
const sessionId = readSessionId(cookieHeader);
|
|
1076
|
-
const session = sessionId ? sessions.get(sessionId) : undefined;
|
|
1077
|
-
if (sessionId && session) {
|
|
1078
|
-
clearTimeout(session.expiryTimer);
|
|
1079
|
-
sessions.delete(sessionId);
|
|
1080
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1081
|
-
}
|
|
1082
|
-
return { clearCookie: clearSessionCookie(), revision };
|
|
1083
|
-
},
|
|
1084
|
-
async request(cookieHeader, request, signal) {
|
|
1085
|
-
const service = options.loginService;
|
|
1086
|
-
const sessionId = readSessionId(cookieHeader);
|
|
1087
|
-
const session = sessionId ? sessions.get(sessionId) : undefined;
|
|
1088
|
-
if (!service || !session || now() >= session.expiresAt) {
|
|
1089
|
-
if (sessionId && sessions.delete(sessionId))
|
|
1090
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1091
|
-
throw new BrowserDevSessionError(401, 'SESSION_UNAUTHENTICATED', 'Browser Dev Host session is unauthenticated');
|
|
1092
|
-
}
|
|
1093
|
-
assertAuthenticatedRequest(request);
|
|
1094
|
-
try {
|
|
1095
|
-
const result = await service.request({ credential: session.credential, request, signal: signal ?? new AbortController().signal });
|
|
1096
|
-
if (containsCredentialOrSensitiveResponse(result, session.credential)) {
|
|
1097
|
-
clearTimeout(session.expiryTimer);
|
|
1098
|
-
sessions.delete(sessionId);
|
|
1099
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1100
|
-
throw new BrowserDevSessionError(502, 'CREDENTIAL_EGRESS_BLOCKED', 'Browser Dev Host blocked a sensitive response');
|
|
1101
|
-
}
|
|
1102
|
-
return result;
|
|
1103
|
-
}
|
|
1104
|
-
catch (error) {
|
|
1105
|
-
if (error instanceof BrowserDevSessionError)
|
|
1106
|
-
throw error;
|
|
1107
|
-
if (error instanceof BrowserDevSessionInvalidatedError || readErrorCode(error) === 'BROWSER_DEV_SESSION_INVALIDATED') {
|
|
1108
|
-
clearTimeout(session.expiryTimer);
|
|
1109
|
-
sessions.delete(sessionId);
|
|
1110
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1111
|
-
throw new BrowserDevSessionError(401, 'SESSION_INVALIDATED', 'Browser Dev Host session was invalidated');
|
|
1112
|
-
}
|
|
1113
|
-
throw new BrowserDevSessionError(502, 'AUTHENTICATED_REQUEST_FAILED', 'Browser Dev Host authenticated request failed');
|
|
1114
|
-
}
|
|
1115
|
-
},
|
|
1116
|
-
onAccountRevision(listener) {
|
|
1117
|
-
revisionListeners.add(listener);
|
|
1118
|
-
return () => revisionListeners.delete(listener);
|
|
1119
|
-
},
|
|
1120
|
-
dispose() {
|
|
1121
|
-
loginIntents.clear();
|
|
1122
|
-
for (const session of sessions.values())
|
|
1123
|
-
clearTimeout(session.expiryTimer);
|
|
1124
|
-
sessions.clear();
|
|
1125
|
-
revisionListeners.clear();
|
|
1126
|
-
},
|
|
1127
|
-
};
|
|
1128
|
-
function reapExpiredState() {
|
|
1129
|
-
const removedSession = reapExpired(loginIntents, sessions, now());
|
|
1130
|
-
if (removedSession)
|
|
1131
|
-
broadcastRevision(revisionListeners, ++revision);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
function createOpaqueValue(randomBytes, size) {
|
|
1135
|
-
return randomBytes(size).toString('base64url');
|
|
1136
|
-
}
|
|
1137
|
-
function createUniqueValue(map, randomBytes) {
|
|
1138
|
-
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1139
|
-
const value = createOpaqueValue(randomBytes, 32);
|
|
1140
|
-
if (!map.has(value))
|
|
1141
|
-
return value;
|
|
1142
|
-
}
|
|
1143
|
-
throw new BrowserDevSessionError(500, 'RANDOM_VALUE_COLLISION', 'Browser Dev Host could not allocate a random value');
|
|
1144
|
-
}
|
|
1145
|
-
function normalizeOrigin(value) {
|
|
1146
|
-
const url = new URL(value);
|
|
1147
|
-
if (url.origin !== value || url.protocol !== 'http:') {
|
|
1148
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_ORIGIN', 'Browser Dev Host login origin is invalid');
|
|
1149
|
-
}
|
|
1150
|
-
return url.origin;
|
|
1151
|
-
}
|
|
1152
|
-
function normalizeReturnPath(value) {
|
|
1153
|
-
if (!value.startsWith('/') || value.startsWith('//') || value.includes('#') || /[\r\n]/.test(value)) {
|
|
1154
|
-
throw new BrowserDevSessionError(400, 'INVALID_RETURN_PATH', 'Browser Dev Host login return path is invalid');
|
|
1155
|
-
}
|
|
1156
|
-
const url = new URL(value, 'http://127.0.0.1');
|
|
1157
|
-
if (url.origin !== 'http://127.0.0.1' || url.pathname === LOGIN_CALLBACK_PATH) {
|
|
1158
|
-
throw new BrowserDevSessionError(400, 'INVALID_RETURN_PATH', 'Browser Dev Host login return path is invalid');
|
|
1159
|
-
}
|
|
1160
|
-
return `${url.pathname}${url.search}`;
|
|
1161
|
-
}
|
|
1162
|
-
function normalizeAuthorizationUrl(value, expected) {
|
|
1163
|
-
const url = new URL(value);
|
|
1164
|
-
if (url.protocol !== 'https:' ||
|
|
1165
|
-
[...url.searchParams.keys()].some(isSensitiveQueryKey) ||
|
|
1166
|
-
url.searchParams.get('state') !== expected.state ||
|
|
1167
|
-
url.searchParams.get('code_challenge') !== expected.codeChallenge ||
|
|
1168
|
-
url.searchParams.get('code_challenge_method') !== 'S256' ||
|
|
1169
|
-
url.searchParams.get('redirect_uri') !== expected.redirectUri ||
|
|
1170
|
-
url.searchParams.get('client_id') !== LOGIN_CLIENT ||
|
|
1171
|
-
url.searchParams.get('environment') !== LOGIN_ENVIRONMENT) {
|
|
1172
|
-
throw new BrowserDevSessionError(502, 'INVALID_AUTHORIZATION_URL', 'Browser Dev Host authorization URL is invalid');
|
|
1173
|
-
}
|
|
1174
|
-
return url.toString();
|
|
1175
|
-
}
|
|
1176
|
-
function isSensitiveQueryKey(key) {
|
|
1177
|
-
const normalized = key.toLowerCase().replaceAll('_', '-');
|
|
1178
|
-
return (normalized === 'credential' ||
|
|
1179
|
-
normalized === 'cookie' ||
|
|
1180
|
-
normalized === 'pkey' ||
|
|
1181
|
-
normalized === 'token' ||
|
|
1182
|
-
normalized.endsWith('-token') ||
|
|
1183
|
-
normalized === 'authorization');
|
|
1184
|
-
}
|
|
1185
|
-
function normalizeUserId(value) {
|
|
1186
|
-
const userId = typeof value === 'string' ? value.trim() : '';
|
|
1187
|
-
if (!userId || userId.length > 256) {
|
|
1188
|
-
throw new BrowserDevSessionError(502, 'INVALID_LOGIN_USER', 'Browser Dev Host login user is invalid');
|
|
1189
|
-
}
|
|
1190
|
-
return userId;
|
|
1191
|
-
}
|
|
1192
|
-
function createSessionCookie(sessionId) {
|
|
1193
|
-
return `${SESSION_COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
|
|
1194
|
-
}
|
|
1195
|
-
function clearSessionCookie() {
|
|
1196
|
-
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
1197
|
-
}
|
|
1198
|
-
function readSessionId(cookieHeader) {
|
|
1199
|
-
if (!cookieHeader)
|
|
1200
|
-
return undefined;
|
|
1201
|
-
for (const item of cookieHeader.split(';')) {
|
|
1202
|
-
const separator = item.indexOf('=');
|
|
1203
|
-
if (separator < 0 || item.slice(0, separator).trim() !== SESSION_COOKIE_NAME)
|
|
1204
|
-
continue;
|
|
1205
|
-
const value = item.slice(separator + 1).trim();
|
|
1206
|
-
return /^[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined;
|
|
1207
|
-
}
|
|
1208
|
-
return undefined;
|
|
1209
|
-
}
|
|
1210
|
-
function readSession(cookieHeader, sessions) {
|
|
1211
|
-
const sessionId = readSessionId(cookieHeader);
|
|
1212
|
-
return sessionId ? sessions.get(sessionId) : undefined;
|
|
1213
|
-
}
|
|
1214
|
-
function reapExpired(loginIntents, sessions, now) {
|
|
1215
|
-
let removedSession = false;
|
|
1216
|
-
for (const [state, intent] of loginIntents)
|
|
1217
|
-
if (now >= intent.expiresAt)
|
|
1218
|
-
loginIntents.delete(state);
|
|
1219
|
-
for (const [sessionId, session] of sessions) {
|
|
1220
|
-
if (now >= session.expiresAt) {
|
|
1221
|
-
clearTimeout(session.expiryTimer);
|
|
1222
|
-
sessions.delete(sessionId);
|
|
1223
|
-
removedSession = true;
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
return removedSession;
|
|
1227
|
-
}
|
|
1228
|
-
function assertAuthenticatedRequest(request) {
|
|
1229
|
-
if (!request || typeof request !== 'object' || !request.target || typeof request.target !== 'object') {
|
|
1230
|
-
throw new BrowserDevSessionError(400, 'INVALID_AUTHENTICATED_REQUEST', 'Browser Dev Host authenticated request is invalid');
|
|
1231
|
-
}
|
|
1232
|
-
const serialized = JSON.stringify(request);
|
|
1233
|
-
if (serialized === undefined || serialized.length > 2 * 1024 * 1024) {
|
|
1234
|
-
throw new BrowserDevSessionError(413, 'AUTHENTICATED_REQUEST_TOO_LARGE', 'Browser Dev Host authenticated request is too large');
|
|
1235
|
-
}
|
|
1236
|
-
if (containsSensitiveKey(request, new Set(), 0)) {
|
|
1237
|
-
throw new BrowserDevSessionError(400, 'INVALID_AUTHENTICATED_REQUEST', 'Browser Dev Host authenticated request is invalid');
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
function containsCredentialOrSensitiveResponse(value, credential) {
|
|
1241
|
-
if (value === credential)
|
|
1242
|
-
return true;
|
|
1243
|
-
if (typeof credential === 'string' && credential.length >= 8) {
|
|
1244
|
-
try {
|
|
1245
|
-
if (JSON.stringify(value)?.includes(credential))
|
|
1246
|
-
return true;
|
|
1247
|
-
}
|
|
1248
|
-
catch {
|
|
1249
|
-
return true;
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
return containsSensitiveKey(value, new Set(), 0);
|
|
1253
|
-
}
|
|
1254
|
-
function normalizeCredential(value) {
|
|
1255
|
-
if (typeof value !== 'string' || value.length < 16 || value.length > 16 * 1024) {
|
|
1256
|
-
throw new BrowserDevSessionError(502, 'INVALID_LOGIN_CREDENTIAL', 'Browser Dev Host login credential is invalid');
|
|
1257
|
-
}
|
|
1258
|
-
return value;
|
|
1259
|
-
}
|
|
1260
|
-
function containsSensitiveKey(value, seen, depth) {
|
|
1261
|
-
if (depth > 16 || value === null || typeof value !== 'object')
|
|
1262
|
-
return false;
|
|
1263
|
-
if (seen.has(value))
|
|
1264
|
-
return false;
|
|
1265
|
-
seen.add(value);
|
|
1266
|
-
if (Array.isArray(value))
|
|
1267
|
-
return value.some((item) => containsSensitiveKey(item, seen, depth + 1));
|
|
1268
|
-
for (const [key, child] of Object.entries(value)) {
|
|
1269
|
-
const normalized = key.toLowerCase().replaceAll('_', '-');
|
|
1270
|
-
if (normalized === 'credential' ||
|
|
1271
|
-
normalized === 'cookie' ||
|
|
1272
|
-
normalized === 'set-cookie' ||
|
|
1273
|
-
normalized === 'pkey' ||
|
|
1274
|
-
normalized === 'token' ||
|
|
1275
|
-
normalized.endsWith('-token') ||
|
|
1276
|
-
normalized === 'authorization') {
|
|
1277
|
-
return true;
|
|
1278
|
-
}
|
|
1279
|
-
if (containsSensitiveKey(child, seen, depth + 1))
|
|
1280
|
-
return true;
|
|
1281
|
-
}
|
|
1282
|
-
return false;
|
|
1283
|
-
}
|
|
1284
|
-
function readErrorCode(error) {
|
|
1285
|
-
return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
1286
|
-
}
|
|
1287
|
-
function broadcastRevision(listeners, revision) {
|
|
1288
|
-
for (const listener of listeners) {
|
|
1289
|
-
try {
|
|
1290
|
-
listener(revision);
|
|
1291
|
-
}
|
|
1292
|
-
catch {
|
|
1293
|
-
// 观察者失败不能阻断 vault 失效。
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
async function selectAvailablePort(options) {
|
|
1299
|
-
const maxAttempts = options.maxAttempts ?? 20;
|
|
1300
|
-
const port = await options.getPort({
|
|
1301
|
-
host: options.host,
|
|
1302
|
-
port: createPortCandidates(options.startPort, maxAttempts),
|
|
1303
|
-
});
|
|
1304
|
-
if (port < options.startPort || port >= options.startPort + maxAttempts) {
|
|
1305
|
-
throw new Error(`无法找到可用 ${options.label} 端口,起始端口: ${options.startPort}`);
|
|
1306
|
-
}
|
|
1307
|
-
return port;
|
|
1308
|
-
}
|
|
1309
|
-
function createPortCandidates(startPort, count) {
|
|
1310
|
-
return Array.from({ length: count }, (_, index) => startPort + index);
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
const MINI_PROGRAM_URL_QUERY_PARAM$1 = 'mini_url';
|
|
1314
|
-
const BROWSER_DEV_HOST_BOOTSTRAP_PATH = '/__hb_sdk_bootstrap__';
|
|
1315
|
-
const BROWSER_DEV_HOST_NETWORK_PATH = '/__hb_sdk__/network';
|
|
1316
|
-
const DEV_PERMISSIONS_PATH = '/__hb_sdk__/dev-permissions';
|
|
1317
|
-
const DEV_LISTEN_HOST$1 = '0.0.0.0';
|
|
1318
|
-
const LOCAL_DEV_URL_HOST$1 = '127.0.0.1';
|
|
1319
|
-
const BROWSER_DEV_HOST_ROOT_CANDIDATES = [
|
|
1320
|
-
path.resolve(__dirname, 'devtools/browser-dev-host'),
|
|
1321
|
-
path.resolve(__dirname, '../devtools/browser-dev-host'),
|
|
1322
|
-
path.resolve(__dirname, '../../devtools/browser-dev-host'),
|
|
1323
|
-
path.resolve(__dirname, '../../../devtools/browser-dev-host'),
|
|
1324
|
-
];
|
|
1325
|
-
async function startBrowserDevHostServer(options) {
|
|
1326
|
-
const root = options.root ?? resolveBrowserDevHostRoot(options.rootCandidates);
|
|
1327
|
-
assertCompleteMiniProgramBrowserDevHostRoot(root);
|
|
1328
|
-
const sessionController = createBrowserDevHostSessionController({
|
|
1329
|
-
loginService: options.loginService,
|
|
1330
|
-
now: options.now,
|
|
1331
|
-
});
|
|
1332
|
-
const bootstrap = {
|
|
1333
|
-
csrfToken: sessionController.csrfToken,
|
|
1334
|
-
defaultLanAddressId: options.defaultLanAddressId,
|
|
1335
|
-
devContext: options.devContext,
|
|
1336
|
-
lanAddresses: options.lanAddresses ?? [],
|
|
1337
|
-
loginAvailable: options.loginService !== undefined,
|
|
1338
|
-
macAppProtocol: options.macAppProtocol,
|
|
1339
|
-
nativeAppLaunchUnavailableReason: options.nativeAppLaunchUnavailableReason,
|
|
1340
|
-
runtimePermissions: options.runtimePermissions,
|
|
1341
|
-
};
|
|
1342
|
-
let bootstrapJson = serializeBrowserDevHostBootstrap(bootstrap);
|
|
1343
|
-
const serverRuntime = createBrowserDevHostServer(root, {
|
|
1344
|
-
appUrl: options.appUrl,
|
|
1345
|
-
allowedMiniUrlOrigins: new Set((options.lanAddresses ?? []).map(({ appUrl }) => new URL(appUrl).origin)),
|
|
1346
|
-
readBootstrapJson: () => bootstrapJson,
|
|
1347
|
-
fetchImpl: options.fetchImpl ?? fetch,
|
|
1348
|
-
isLoopbackPeer: options.isLoopbackPeer ?? isLoopbackAddress,
|
|
1349
|
-
lookupHostname: options.lookupHostname ?? (async (hostname) => promises.lookup(hostname, { all: true, verbatim: true })),
|
|
1350
|
-
now: options.now ?? Date.now,
|
|
1351
|
-
permissionController: options.permissionController,
|
|
1352
|
-
sessionController,
|
|
1353
|
-
});
|
|
1354
|
-
const server = serverRuntime.server;
|
|
1355
|
-
const port = await listenHttpServer(server, {
|
|
1356
|
-
port: options.port,
|
|
1357
|
-
}, options.getPort ?? getPorts);
|
|
1358
|
-
return {
|
|
1359
|
-
close: () => new Promise((resolve) => {
|
|
1360
|
-
serverRuntime.dispose();
|
|
1361
|
-
server.close(() => resolve());
|
|
1362
|
-
}),
|
|
1363
|
-
updateBootstrap: (next) => {
|
|
1364
|
-
if (next.devContext !== undefined)
|
|
1365
|
-
bootstrap.devContext = next.devContext;
|
|
1366
|
-
if (next.lanAddresses !== undefined)
|
|
1367
|
-
bootstrap.lanAddresses = next.lanAddresses;
|
|
1368
|
-
if (next.macAppProtocol === null)
|
|
1369
|
-
delete bootstrap.macAppProtocol;
|
|
1370
|
-
else if (next.macAppProtocol !== undefined)
|
|
1371
|
-
bootstrap.macAppProtocol = next.macAppProtocol;
|
|
1372
|
-
bootstrapJson = serializeBrowserDevHostBootstrap(bootstrap);
|
|
1373
|
-
},
|
|
1374
|
-
networkUrls: createBrowserDevHostNetworkUrls({
|
|
1375
|
-
appUrl: options.appUrl,
|
|
1376
|
-
lanAddresses: options.lanAddresses,
|
|
1377
|
-
port,
|
|
1378
|
-
}),
|
|
1379
|
-
port,
|
|
1380
|
-
root,
|
|
1381
|
-
server,
|
|
1382
|
-
url: createBrowserDevHostUrlWithHost({
|
|
1383
|
-
appUrl: options.appUrl,
|
|
1384
|
-
host: LOCAL_DEV_URL_HOST$1,
|
|
1385
|
-
port,
|
|
1386
|
-
}),
|
|
1387
|
-
};
|
|
1388
|
-
}
|
|
1389
|
-
function resolveBrowserDevHostRoot(candidates = BROWSER_DEV_HOST_ROOT_CANDIDATES) {
|
|
1390
|
-
const found = candidates.find(isCompleteMiniProgramBrowserDevHostRoot);
|
|
1391
|
-
if (!found) {
|
|
1392
|
-
throw createMissingBrowserDevHostError();
|
|
1393
|
-
}
|
|
1394
|
-
return found;
|
|
1395
|
-
}
|
|
1396
|
-
function assertCompleteMiniProgramBrowserDevHostRoot(root) {
|
|
1397
|
-
if (!isCompleteMiniProgramBrowserDevHostRoot(root)) {
|
|
1398
|
-
throw createMissingBrowserDevHostError();
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
function isCompleteMiniProgramBrowserDevHostRoot(root) {
|
|
1402
|
-
return fs.existsSync(path.join(root, 'index.html')) && fs.existsSync(path.join(root, 'main.js'));
|
|
1403
|
-
}
|
|
1404
|
-
function createMissingBrowserDevHostError() {
|
|
1405
|
-
return new Error('未找到完整的 hb-sdk browser dev host 静态产物。请先执行 @heybox/hb-sdk 的 build:browser-dev-host。');
|
|
1406
|
-
}
|
|
1407
|
-
function createBrowserDevHostNetworkUrls(options) {
|
|
1408
|
-
return (options.lanAddresses ?? []).map((address) => createBrowserDevHostUrlWithHost({
|
|
1409
|
-
appUrl: address.appUrl,
|
|
1410
|
-
host: address.address,
|
|
1411
|
-
port: options.port,
|
|
1412
|
-
}));
|
|
1413
|
-
}
|
|
1414
|
-
function createBrowserDevHostUrlWithHost(options) {
|
|
1415
|
-
const url = new URL(`http://${options.host}:${options.port}/`);
|
|
1416
|
-
url.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM$1, options.appUrl);
|
|
1417
|
-
return url.toString();
|
|
1418
|
-
}
|
|
1419
|
-
function serializeBrowserDevHostBootstrap(bootstrap) {
|
|
1420
|
-
const serialized = JSON.stringify(bootstrap);
|
|
1421
|
-
if (serialized === undefined) {
|
|
1422
|
-
throw new TypeError('Browser Dev Host bootstrap must be JSON serializable');
|
|
1423
|
-
}
|
|
1424
|
-
const serializedBootstrap = JSON.parse(serialized);
|
|
1425
|
-
if (bootstrap.runtimePermissions !== undefined && !Object.prototype.hasOwnProperty.call(serializedBootstrap, 'runtimePermissions')) {
|
|
1426
|
-
throw new TypeError('Runtime permission snapshot must be JSON serializable');
|
|
1427
|
-
}
|
|
1428
|
-
return serialized;
|
|
1429
|
-
}
|
|
1430
|
-
function createBrowserDevHostServer(root, runtime) {
|
|
1431
|
-
const devSessionRoutes = createDevSessionRoutes({
|
|
1432
|
-
allowedMiniUrlOrigins: runtime.allowedMiniUrlOrigins,
|
|
1433
|
-
fetchImpl: runtime.fetchImpl,
|
|
1434
|
-
lookupHostname: runtime.lookupHostname,
|
|
1435
|
-
now: runtime.now,
|
|
1436
|
-
});
|
|
1437
|
-
const eventStreams = new Set();
|
|
1438
|
-
const unsubscribeRevision = runtime.sessionController.onAccountRevision((revision) => {
|
|
1439
|
-
const data = JSON.stringify({ revision });
|
|
1440
|
-
for (const stream of eventStreams)
|
|
1441
|
-
stream.write(`event: account-invalidated\ndata: ${data}\n\n`);
|
|
1442
|
-
});
|
|
1443
|
-
const server = node_http.createServer(async (request, response) => {
|
|
1444
|
-
const loopbackPeer = runtime.isLoopbackPeer(request.socket.remoteAddress);
|
|
1445
|
-
let requestUrl;
|
|
1446
|
-
try {
|
|
1447
|
-
requestUrl = new URL(request.url, 'http://localhost');
|
|
1448
|
-
}
|
|
1449
|
-
catch {
|
|
1450
|
-
writeBadRequest(response);
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
// LAN 只允许固定 token 路由;在任何 URI decode 前先完成窄匹配。
|
|
1454
|
-
if (!loopbackPeer && !devSessionRoutes.match(requestUrl.pathname)) {
|
|
1455
|
-
writeForbidden(response);
|
|
1456
|
-
return;
|
|
1457
|
-
}
|
|
1458
|
-
let pathname;
|
|
1459
|
-
try {
|
|
1460
|
-
pathname = decodeURIComponent(requestUrl.pathname);
|
|
1461
|
-
}
|
|
1462
|
-
catch {
|
|
1463
|
-
writeBadRequest(response);
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
const devSessionRoute = devSessionRoutes.match(pathname);
|
|
1467
|
-
// Dev Session 需要允许手机局域网访问;其余 Browser Dev Host 页面仍限制本机。
|
|
1468
|
-
if (!loopbackPeer && !devSessionRoute) {
|
|
1469
|
-
writeForbidden(response);
|
|
1470
|
-
return;
|
|
1471
|
-
}
|
|
1472
|
-
if (isBrowserDevHostDocumentPath(pathname) && !requestUrl.searchParams.has(MINI_PROGRAM_URL_QUERY_PARAM$1)) {
|
|
1473
|
-
const targetUrl = new URL(requestUrl);
|
|
1474
|
-
targetUrl.pathname = '/';
|
|
1475
|
-
targetUrl.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM$1, runtime.appUrl);
|
|
1476
|
-
response.writeHead(302, {
|
|
1477
|
-
location: `${targetUrl.pathname}${targetUrl.search}`,
|
|
1478
|
-
'cache-control': 'no-store',
|
|
1479
|
-
});
|
|
1480
|
-
response.end();
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
if (pathname === BROWSER_DEV_HOST_BOOTSTRAP_PATH) {
|
|
1484
|
-
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
1485
|
-
writeForbidden(response);
|
|
1486
|
-
return;
|
|
1487
|
-
}
|
|
1488
|
-
if (request.method !== 'GET') {
|
|
1489
|
-
writeMethodNotAllowed(response, 'GET');
|
|
1490
|
-
return;
|
|
1491
|
-
}
|
|
1492
|
-
writeSerializedJsonResponse(response, 200, runtime.readBootstrapJson());
|
|
1493
|
-
return;
|
|
1494
|
-
}
|
|
1495
|
-
if (pathname === '/session/status') {
|
|
1496
|
-
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
1497
|
-
writeForbidden(response);
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
if (request.method !== 'GET') {
|
|
1501
|
-
writeMethodNotAllowed(response, 'GET');
|
|
1502
|
-
return;
|
|
1503
|
-
}
|
|
1504
|
-
const status = runtime.sessionController.readStatus(request.headers.cookie);
|
|
1505
|
-
writeJsonResponse(response, 200, {
|
|
1506
|
-
authenticated: status.authenticated,
|
|
1507
|
-
revision: status.revision,
|
|
1508
|
-
...(status.authenticated ? { user_id: status.userId } : {}),
|
|
1509
|
-
});
|
|
1510
|
-
return;
|
|
1511
|
-
}
|
|
1512
|
-
if (pathname === '/session/login-intent') {
|
|
1513
|
-
if (!isValidSensitiveRequest(request, loopbackPeer, runtime.sessionController.csrfToken) || request.method !== 'POST') {
|
|
1514
|
-
if (request.method !== 'POST')
|
|
1515
|
-
writeMethodNotAllowed(response, 'POST');
|
|
1516
|
-
else
|
|
1517
|
-
writeForbidden(response);
|
|
1518
|
-
return;
|
|
1519
|
-
}
|
|
1520
|
-
await handleLoginIntent(request, response, runtime.sessionController);
|
|
1521
|
-
return;
|
|
1522
|
-
}
|
|
1523
|
-
if (pathname === '/session/callback') {
|
|
1524
|
-
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
1525
|
-
writeForbidden(response);
|
|
1526
|
-
return;
|
|
1527
|
-
}
|
|
1528
|
-
if (request.method !== 'GET') {
|
|
1529
|
-
writeMethodNotAllowed(response, 'GET');
|
|
1530
|
-
return;
|
|
1531
|
-
}
|
|
1532
|
-
await handleLoginCallback(request, response, requestUrl, runtime.sessionController);
|
|
1533
|
-
return;
|
|
1534
|
-
}
|
|
1535
|
-
if (pathname === '/session/logout') {
|
|
1536
|
-
if (!isValidSensitiveRequest(request, loopbackPeer, runtime.sessionController.csrfToken) || request.method !== 'POST') {
|
|
1537
|
-
if (request.method !== 'POST')
|
|
1538
|
-
writeMethodNotAllowed(response, 'POST');
|
|
1539
|
-
else
|
|
1540
|
-
writeForbidden(response);
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
const result = runtime.sessionController.logout(request.headers.cookie);
|
|
1544
|
-
response.setHeader('set-cookie', result.clearCookie);
|
|
1545
|
-
writeJsonResponse(response, 200, { revision: result.revision });
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
if (pathname === '/session/request') {
|
|
1549
|
-
if (!isValidSensitiveRequest(request, loopbackPeer, runtime.sessionController.csrfToken) || request.method !== 'POST') {
|
|
1550
|
-
if (request.method !== 'POST')
|
|
1551
|
-
writeMethodNotAllowed(response, 'POST');
|
|
1552
|
-
else
|
|
1553
|
-
writeForbidden(response);
|
|
1554
|
-
return;
|
|
1555
|
-
}
|
|
1556
|
-
await handleAuthenticatedRequest(request, response, runtime.sessionController);
|
|
1557
|
-
return;
|
|
1558
|
-
}
|
|
1559
|
-
if (pathname === '/session/events') {
|
|
1560
|
-
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
1561
|
-
writeForbidden(response);
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
if (request.method !== 'GET') {
|
|
1565
|
-
writeMethodNotAllowed(response, 'GET');
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
const status = runtime.sessionController.readStatus(request.headers.cookie);
|
|
1569
|
-
if (!status.authenticated) {
|
|
1570
|
-
writeJsonResponse(response, 401, { code: 'SESSION_UNAUTHENTICATED' });
|
|
1571
|
-
return;
|
|
1572
|
-
}
|
|
1573
|
-
response.writeHead(200, {
|
|
1574
|
-
'cache-control': 'no-store',
|
|
1575
|
-
connection: 'keep-alive',
|
|
1576
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
1577
|
-
'x-accel-buffering': 'no',
|
|
1578
|
-
});
|
|
1579
|
-
response.write(`event: account-revision\ndata: ${JSON.stringify({ revision: status.revision })}\n\n`);
|
|
1580
|
-
eventStreams.add(response);
|
|
1581
|
-
request.once('close', () => eventStreams.delete(response));
|
|
1582
|
-
return;
|
|
1583
|
-
}
|
|
1584
|
-
if (pathname === DEV_PERMISSIONS_PATH && runtime.permissionController) {
|
|
1585
|
-
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
1586
|
-
writeForbidden(response);
|
|
1587
|
-
return;
|
|
1588
|
-
}
|
|
1589
|
-
if (request.method === 'GET') {
|
|
1590
|
-
writeJsonResponse(response, 200, await runtime.permissionController.get());
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
if ((request.method !== 'PUT' && request.method !== 'DELETE') ||
|
|
1594
|
-
!hasValidMutationOrigin(request) ||
|
|
1595
|
-
!hasValidCsrf(request, runtime.sessionController.csrfToken)) {
|
|
1596
|
-
if (request.method !== 'PUT' && request.method !== 'DELETE') {
|
|
1597
|
-
writeMethodNotAllowed(response, 'GET, PUT, DELETE');
|
|
1598
|
-
}
|
|
1599
|
-
else {
|
|
1600
|
-
writeForbidden(response);
|
|
1601
|
-
}
|
|
1602
|
-
return;
|
|
1603
|
-
}
|
|
1604
|
-
await handleDevPermissionMutation(request, response, runtime.permissionController);
|
|
1605
|
-
return;
|
|
1606
|
-
}
|
|
1607
|
-
if (pathname === MINI_PROGRAM_DEV_CONTEXT_PATH) {
|
|
1608
|
-
if (request.method !== 'POST') {
|
|
1609
|
-
writeMethodNotAllowed(response, 'POST');
|
|
1610
|
-
return;
|
|
1611
|
-
}
|
|
1612
|
-
if (!loopbackPeer ||
|
|
1613
|
-
!hasNumericLoopbackHost(request) ||
|
|
1614
|
-
!hasValidMutationOrigin(request) ||
|
|
1615
|
-
!hasValidCsrf(request, runtime.sessionController.csrfToken)) {
|
|
1616
|
-
writeForbidden(response);
|
|
1617
|
-
return;
|
|
1618
|
-
}
|
|
1619
|
-
await devSessionRoutes.create(request, response);
|
|
1620
|
-
return;
|
|
1621
|
-
}
|
|
1622
|
-
if (devSessionRoute) {
|
|
1623
|
-
await devSessionRoutes.handle(devSessionRoute, request, response);
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
1626
|
-
if (pathname === BROWSER_DEV_HOST_NETWORK_PATH) {
|
|
1627
|
-
const csrfValid = request.method !== 'POST' || hasValidCsrf(request, runtime.sessionController.csrfToken);
|
|
1628
|
-
if (!loopbackPeer || !hasNumericLoopbackHost(request) || !hasValidMutationOrigin(request) || !csrfValid) {
|
|
1629
|
-
writeForbidden(response);
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
await handleBrowserDevHostNetworkProxy(request, response, runtime.fetchImpl, runtime.lookupHostname, {
|
|
1633
|
-
allowedHttpOrigins: new Set([runtime.appUrl, ...runtime.allowedMiniUrlOrigins].map((value) => new URL(value).origin)),
|
|
1634
|
-
});
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
const targetPath = resolveStaticPath(root, pathname);
|
|
1638
|
-
if (!targetPath) {
|
|
1639
|
-
response.writeHead(404);
|
|
1640
|
-
response.end('Not found');
|
|
1641
|
-
return;
|
|
1642
|
-
}
|
|
1643
|
-
try {
|
|
1644
|
-
const stat = fs.statSync(targetPath);
|
|
1645
|
-
if (stat.isDirectory()) {
|
|
1646
|
-
response.writeHead(404);
|
|
1647
|
-
response.end('Not found');
|
|
1648
|
-
return;
|
|
1649
|
-
}
|
|
1650
|
-
response.writeHead(200, {
|
|
1651
|
-
'content-type': readContentType(targetPath),
|
|
1652
|
-
'cache-control': 'no-store',
|
|
1653
|
-
});
|
|
1654
|
-
fs.createReadStream(targetPath).pipe(response);
|
|
1655
|
-
}
|
|
1656
|
-
catch {
|
|
1657
|
-
response.writeHead(404);
|
|
1658
|
-
response.end('Not found');
|
|
1659
|
-
}
|
|
1660
|
-
});
|
|
1661
|
-
return {
|
|
1662
|
-
server,
|
|
1663
|
-
dispose() {
|
|
1664
|
-
unsubscribeRevision();
|
|
1665
|
-
for (const stream of eventStreams)
|
|
1666
|
-
stream.end();
|
|
1667
|
-
eventStreams.clear();
|
|
1668
|
-
runtime.sessionController.dispose();
|
|
1669
|
-
},
|
|
1670
|
-
};
|
|
1671
|
-
}
|
|
1672
|
-
async function handleLoginIntent(request, response, controller) {
|
|
1673
|
-
try {
|
|
1674
|
-
const payload = await readJsonRequestBody(request);
|
|
1675
|
-
if (!isRecord$1(payload) || Object.keys(payload).some((key) => key !== 'return_path') || typeof payload.return_path !== 'string') {
|
|
1676
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_INTENT', 'Browser Dev Host login intent is invalid');
|
|
1677
|
-
}
|
|
1678
|
-
const result = await controller.createLoginIntent({
|
|
1679
|
-
origin: readRequestOrigin(request),
|
|
1680
|
-
returnPath: payload.return_path,
|
|
1681
|
-
});
|
|
1682
|
-
writeJsonResponse(response, 201, { authorization_url: result.authorizationUrl });
|
|
1683
|
-
}
|
|
1684
|
-
catch (error) {
|
|
1685
|
-
writeSessionError(response, error);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
async function handleLoginCallback(request, response, requestUrl, controller) {
|
|
1689
|
-
try {
|
|
1690
|
-
const keys = [...requestUrl.searchParams.keys()];
|
|
1691
|
-
if (keys.some((key) => key !== 'code' && key !== 'state')) {
|
|
1692
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_CALLBACK', 'Browser Dev Host login callback is invalid');
|
|
1693
|
-
}
|
|
1694
|
-
const code = requestUrl.searchParams.get('code');
|
|
1695
|
-
const state = requestUrl.searchParams.get('state');
|
|
1696
|
-
if (!code || !state) {
|
|
1697
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_CALLBACK', 'Browser Dev Host login callback is invalid');
|
|
1698
|
-
}
|
|
1699
|
-
const result = await controller.completeLogin({
|
|
1700
|
-
code,
|
|
1701
|
-
origin: readRequestOrigin(request),
|
|
1702
|
-
state,
|
|
1703
|
-
});
|
|
1704
|
-
response.writeHead(302, {
|
|
1705
|
-
'cache-control': 'no-store',
|
|
1706
|
-
location: result.redirectPath,
|
|
1707
|
-
'referrer-policy': 'no-referrer',
|
|
1708
|
-
'set-cookie': result.setCookie,
|
|
1709
|
-
});
|
|
1710
|
-
response.end();
|
|
1711
|
-
}
|
|
1712
|
-
catch (error) {
|
|
1713
|
-
response.writeHead(302, {
|
|
1714
|
-
'cache-control': 'no-store',
|
|
1715
|
-
location: '/',
|
|
1716
|
-
'referrer-policy': 'no-referrer',
|
|
1717
|
-
});
|
|
1718
|
-
response.end();
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
async function handleAuthenticatedRequest(request, response, controller) {
|
|
1722
|
-
const abort = new AbortController();
|
|
1723
|
-
request.once('aborted', () => abort.abort());
|
|
1724
|
-
try {
|
|
1725
|
-
const payload = await readJsonRequestBody(request);
|
|
1726
|
-
const result = await controller.request(request.headers.cookie, payload, abort.signal);
|
|
1727
|
-
writeJsonResponse(response, 200, result);
|
|
1728
|
-
}
|
|
1729
|
-
catch (error) {
|
|
1730
|
-
writeSessionError(response, error);
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
function writeSessionError(response, error) {
|
|
1734
|
-
const sessionError = error instanceof BrowserDevSessionError
|
|
1735
|
-
? error
|
|
1736
|
-
: new BrowserDevSessionError(500, 'BROWSER_DEV_SESSION_ERROR', 'Browser Dev Host session request failed');
|
|
1737
|
-
writeJsonResponse(response, sessionError.status, {
|
|
1738
|
-
code: sessionError.code,
|
|
1739
|
-
message: sessionError.message,
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
function readRequestOrigin(request) {
|
|
1743
|
-
const host = request.headers.host;
|
|
1744
|
-
if (!host)
|
|
1745
|
-
throw new BrowserDevSessionError(400, 'INVALID_LOGIN_ORIGIN', 'Browser Dev Host login origin is invalid');
|
|
1746
|
-
return `http://${host}`;
|
|
1747
|
-
}
|
|
1748
|
-
function isValidSensitiveRequest(request, loopbackPeer, csrfToken) {
|
|
1749
|
-
return loopbackPeer && hasNumericLoopbackHost(request) && hasValidMutationOrigin(request) && hasValidCsrf(request, csrfToken);
|
|
1750
|
-
}
|
|
1751
|
-
function hasValidCsrf(request, csrfToken) {
|
|
1752
|
-
const value = request.headers['x-hb-sdk-csrf'];
|
|
1753
|
-
return typeof value === 'string' && value === csrfToken;
|
|
1754
|
-
}
|
|
1755
|
-
async function handleDevPermissionMutation(request, response, controller) {
|
|
1756
|
-
try {
|
|
1757
|
-
if (request.method === 'DELETE') {
|
|
1758
|
-
if (hasRequestBody(request)) {
|
|
1759
|
-
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
1760
|
-
throw createBrowserDevHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions body must be JSON');
|
|
1761
|
-
}
|
|
1762
|
-
const payload = await readJsonRequestBody(request);
|
|
1763
|
-
if (!isRecord$1(payload) || Object.keys(payload).length > 0) {
|
|
1764
|
-
throw createBrowserDevHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions reset body is invalid');
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
writeJsonResponse(response, 200, await controller.reset());
|
|
1768
|
-
return;
|
|
1769
|
-
}
|
|
1770
|
-
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
1771
|
-
throw createBrowserDevHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions body must be JSON');
|
|
1772
|
-
}
|
|
1773
|
-
const payload = await readJsonRequestBody(request);
|
|
1774
|
-
if (!isRecord$1(payload) || Object.keys(payload).length !== 1 || !Object.hasOwn(payload, 'runtime_permissions')) {
|
|
1775
|
-
throw createBrowserDevHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions update body is invalid');
|
|
1776
|
-
}
|
|
1777
|
-
const parsed = context.parseMiniProgramRuntimePermissions(payload.runtime_permissions);
|
|
1778
|
-
if (!parsed.valid) {
|
|
1779
|
-
throw createBrowserDevHostError(400, 'INVALID_DEV_PERMISSIONS', 'runtime_permissions is invalid');
|
|
1780
|
-
}
|
|
1781
|
-
const runtimePermissions = JSON.parse(JSON.stringify(payload.runtime_permissions));
|
|
1782
|
-
writeJsonResponse(response, 200, await controller.save(runtimePermissions));
|
|
1783
|
-
}
|
|
1784
|
-
catch (error) {
|
|
1785
|
-
const errorStatus = readBrowserDevHostErrorStatus(error);
|
|
1786
|
-
const status = request.method === 'DELETE' && errorStatus === 500 ? 502 : errorStatus;
|
|
1787
|
-
writeJsonResponse(response, status, {
|
|
1788
|
-
code: request.method === 'DELETE' ? 'DEV_PERMISSIONS_RESET_FAILED' : 'DEV_PERMISSIONS_UPDATE_FAILED',
|
|
1789
|
-
message: readErrorMessage(error),
|
|
1790
|
-
});
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
function hasRequestBody(request) {
|
|
1794
|
-
return request.headers['transfer-encoding'] !== undefined || Number(request.headers['content-length'] ?? 0) > 0;
|
|
1795
|
-
}
|
|
1796
|
-
function writeMethodNotAllowed(response, allowedMethod) {
|
|
1797
|
-
response.writeHead(405, {
|
|
1798
|
-
allow: allowedMethod,
|
|
1799
|
-
'cache-control': 'no-store',
|
|
1800
|
-
});
|
|
1801
|
-
response.end();
|
|
1802
|
-
}
|
|
1803
|
-
function writeForbidden(response) {
|
|
1804
|
-
response.writeHead(403, { 'cache-control': 'no-store' });
|
|
1805
|
-
response.end();
|
|
1806
|
-
}
|
|
1807
|
-
function writeBadRequest(response) {
|
|
1808
|
-
response.writeHead(400, { 'cache-control': 'no-store' });
|
|
1809
|
-
response.end();
|
|
1810
|
-
}
|
|
1811
|
-
function hasValidMutationOrigin(request) {
|
|
1812
|
-
const origin = request.headers.origin;
|
|
1813
|
-
const host = request.headers.host;
|
|
1814
|
-
if (!host || !origin || origin === 'null') {
|
|
1815
|
-
return false;
|
|
1816
|
-
}
|
|
1817
|
-
try {
|
|
1818
|
-
return new URL(origin).origin === `http://${host}`;
|
|
1819
|
-
}
|
|
1820
|
-
catch {
|
|
1821
|
-
return false;
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
function hasNumericLoopbackHost(request) {
|
|
1825
|
-
const host = request.headers.host;
|
|
1826
|
-
if (!host) {
|
|
1827
|
-
return false;
|
|
1828
|
-
}
|
|
1829
|
-
try {
|
|
1830
|
-
const hostname = new URL(`http://${host}`).hostname;
|
|
1831
|
-
const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
1832
|
-
return net.isIP(normalized) !== 0 && isLoopbackAddress(normalized);
|
|
1833
|
-
}
|
|
1834
|
-
catch {
|
|
1835
|
-
return false;
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
function isLoopbackAddress(address) {
|
|
1839
|
-
return address === '::1' || address === '127.0.0.1' || address?.startsWith('127.') === true || address?.startsWith('::ffff:127.') === true;
|
|
1840
|
-
}
|
|
1841
|
-
function isBrowserDevHostDocumentPath(pathname) {
|
|
1842
|
-
return pathname === '/' || pathname === '/index.html';
|
|
1843
|
-
}
|
|
1844
|
-
function resolveStaticPath(root, pathname) {
|
|
1845
|
-
const safePathname = pathname === '/' ? '/index.html' : pathname;
|
|
1846
|
-
const normalizedRoot = path.normalize(root);
|
|
1847
|
-
const targetPath = path.normalize(path.join(normalizedRoot, safePathname));
|
|
1848
|
-
if (targetPath !== normalizedRoot && !targetPath.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
1849
|
-
return undefined;
|
|
1850
|
-
}
|
|
1851
|
-
return targetPath;
|
|
1852
|
-
}
|
|
1853
|
-
function readContentType(filePath) {
|
|
1854
|
-
if (filePath.endsWith('.html')) {
|
|
1855
|
-
return 'text/html; charset=utf-8';
|
|
1856
|
-
}
|
|
1857
|
-
if (filePath.endsWith('.js')) {
|
|
1858
|
-
return 'text/javascript; charset=utf-8';
|
|
1859
|
-
}
|
|
1860
|
-
if (filePath.endsWith('.css')) {
|
|
1861
|
-
return 'text/css; charset=utf-8';
|
|
1862
|
-
}
|
|
1863
|
-
if (filePath.endsWith('.json')) {
|
|
1864
|
-
return 'application/json; charset=utf-8';
|
|
1865
|
-
}
|
|
1866
|
-
if (filePath.endsWith('.svg')) {
|
|
1867
|
-
return 'image/svg+xml';
|
|
1868
|
-
}
|
|
1869
|
-
return 'application/octet-stream';
|
|
1870
|
-
}
|
|
1871
|
-
async function listenHttpServer(server, options, getPortImpl) {
|
|
1872
|
-
const port = await selectAvailablePort({
|
|
1873
|
-
getPort: getPortImpl,
|
|
1874
|
-
host: DEV_LISTEN_HOST$1,
|
|
1875
|
-
label: 'browser dev host',
|
|
1876
|
-
startPort: options.port,
|
|
1877
|
-
});
|
|
1878
|
-
await new Promise((resolve, reject) => {
|
|
1879
|
-
const onError = (error) => {
|
|
1880
|
-
server.off('listening', onListening);
|
|
1881
|
-
reject(error);
|
|
1882
|
-
};
|
|
1883
|
-
const onListening = () => {
|
|
1884
|
-
server.off('error', onError);
|
|
1885
|
-
resolve();
|
|
1886
|
-
};
|
|
1887
|
-
server.once('error', onError);
|
|
1888
|
-
server.once('listening', onListening);
|
|
1889
|
-
server.listen(port, DEV_LISTEN_HOST$1);
|
|
1890
|
-
});
|
|
1891
|
-
const address = server.address();
|
|
1892
|
-
return address.port;
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
const MINI_PROGRAM_URL_QUERY_PARAM = 'mini_url';
|
|
1896
|
-
const MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM = 'runtime_url';
|
|
1897
|
-
const MINI_PROGRAM_SDK_VERSION_QUERY_PARAM = 'sdk_version';
|
|
1898
|
-
const MINI_PROGRAM_LOCAL_DEV_LAUNCH_TOKEN_QUERY_PARAM = 'local_dev_launch_token';
|
|
1899
|
-
const MINI_PROGRAM_DEV_SHELL_URL = 'heybox-mini-dev://sandbox';
|
|
1900
|
-
const OPEN_IN_APP_URL = 'https://api.xiaoheihe.cn/open_inapp/';
|
|
1901
|
-
function createMacAppProtocol(appUrl, options = {}) {
|
|
1902
|
-
return createHeyboxProtocol(createMiniProgramDevShellOpenWindowPayload(appUrl, options));
|
|
1903
|
-
}
|
|
1904
|
-
function createMobileAppQrPayload(appUrl, options = {}) {
|
|
1905
|
-
const protocol = createHeyboxProtocol(createMiniProgramDevShellOpenWindowPayload(appUrl, { ...options, encodeMiniUrl: false }));
|
|
1906
|
-
return options.bareProtocol ? protocol : `${OPEN_IN_APP_URL}#${protocol}`;
|
|
1907
|
-
}
|
|
1908
|
-
function createMiniProgramDevShellOpenWindowPayload(appUrl, options = {}) {
|
|
1909
|
-
const devShellUrl = options.encodeMiniUrl === false
|
|
1910
|
-
? createPartiallyEncodedMiniProgramDevShellUrl(appUrl, options)
|
|
1911
|
-
: createEncodedMiniProgramDevShellUrl(appUrl, options);
|
|
1912
|
-
const miniProgramId = options.miniProgramId?.trim();
|
|
1913
|
-
return {
|
|
1914
|
-
protocol_type: 'openWindow',
|
|
1915
|
-
full_screen: true,
|
|
1916
|
-
mini_program: '1',
|
|
1917
|
-
...(miniProgramId ? { mini_program_id: miniProgramId } : {}),
|
|
1918
|
-
navigation_bar: {
|
|
1919
|
-
title: '',
|
|
1920
|
-
},
|
|
1921
|
-
webview: {
|
|
1922
|
-
url: devShellUrl,
|
|
1923
|
-
pull: false,
|
|
1924
|
-
refresh: false,
|
|
1925
|
-
},
|
|
1926
|
-
};
|
|
1927
|
-
}
|
|
1928
|
-
function createPartiallyEncodedMiniProgramDevShellUrl(appUrl, options) {
|
|
1929
|
-
let devShellUrl = `${MINI_PROGRAM_DEV_SHELL_URL}?${MINI_PROGRAM_URL_QUERY_PARAM}=${appUrl}`;
|
|
1930
|
-
const miniProgramId = options.miniProgramId?.trim();
|
|
1931
|
-
if (miniProgramId) {
|
|
1932
|
-
devShellUrl += `&mini_program_id=${encodeURIComponent(miniProgramId)}`;
|
|
1933
|
-
}
|
|
1934
|
-
const localDevLaunchToken = options.localDevLaunchToken?.trim();
|
|
1935
|
-
if (localDevLaunchToken) {
|
|
1936
|
-
devShellUrl += `&${MINI_PROGRAM_LOCAL_DEV_LAUNCH_TOKEN_QUERY_PARAM}=${encodeURIComponent(localDevLaunchToken)}`;
|
|
1937
|
-
}
|
|
1938
|
-
if (hasRuntimeUrl(options.runtimeUrl)) {
|
|
1939
|
-
const runtimeUrl = buildRuntimeUrlWithLaunchParams(options.runtimeUrl, appUrl, options);
|
|
1940
|
-
devShellUrl += `&${MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM}=${encodeURIComponent(runtimeUrl)}`;
|
|
1941
|
-
}
|
|
1942
|
-
const sdkVersion = options.sdkVersion?.trim();
|
|
1943
|
-
if (sdkVersion) {
|
|
1944
|
-
devShellUrl += `&${MINI_PROGRAM_SDK_VERSION_QUERY_PARAM}=${encodeURIComponent(sdkVersion)}`;
|
|
1945
|
-
}
|
|
1946
|
-
return devShellUrl;
|
|
1947
|
-
}
|
|
1948
|
-
function createEncodedMiniProgramDevShellUrl(appUrl, options) {
|
|
1949
|
-
const devShellUrl = new URL(MINI_PROGRAM_DEV_SHELL_URL);
|
|
1950
|
-
devShellUrl.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM, appUrl);
|
|
1951
|
-
const miniProgramId = options.miniProgramId?.trim();
|
|
1952
|
-
if (miniProgramId) {
|
|
1953
|
-
devShellUrl.searchParams.set('mini_program_id', miniProgramId);
|
|
1954
|
-
}
|
|
1955
|
-
const localDevLaunchToken = options.localDevLaunchToken?.trim();
|
|
1956
|
-
if (localDevLaunchToken) {
|
|
1957
|
-
devShellUrl.searchParams.set(MINI_PROGRAM_LOCAL_DEV_LAUNCH_TOKEN_QUERY_PARAM, localDevLaunchToken);
|
|
1958
|
-
}
|
|
1959
|
-
if (hasRuntimeUrl(options.runtimeUrl)) {
|
|
1960
|
-
devShellUrl.searchParams.set(MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM, buildRuntimeUrlWithLaunchParams(options.runtimeUrl, appUrl, options));
|
|
1961
|
-
}
|
|
1962
|
-
const sdkVersion = options.sdkVersion?.trim();
|
|
1963
|
-
if (sdkVersion) {
|
|
1964
|
-
devShellUrl.searchParams.set(MINI_PROGRAM_SDK_VERSION_QUERY_PARAM, sdkVersion);
|
|
1965
|
-
}
|
|
1966
|
-
return devShellUrl.toString();
|
|
1967
|
-
}
|
|
1968
|
-
function hasRuntimeUrl(runtimeUrl) {
|
|
1969
|
-
return runtimeUrl !== undefined && runtimeUrl !== '';
|
|
1970
|
-
}
|
|
1971
|
-
/**
|
|
1972
|
-
* 客户端加载 runtime_url 时,常只打开该 URL 本身,不会把 sandbox 上的 mini_url 再拼进去。
|
|
1973
|
-
* 电脑浏览器手开 detail 能成功,是因为 URL 上已经带了 mini_url。
|
|
1974
|
-
* 因此 HTTP(S) runtime host 需要在 runtime_url 内预置启动参数。
|
|
1975
|
-
*/
|
|
1976
|
-
function buildRuntimeUrlWithLaunchParams(runtimeUrl, appUrl, options) {
|
|
1977
|
-
try {
|
|
1978
|
-
const url = new URL(runtimeUrl);
|
|
1979
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
1980
|
-
return runtimeUrl;
|
|
1981
|
-
}
|
|
1982
|
-
url.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM, appUrl);
|
|
1983
|
-
const miniProgramId = options.miniProgramId?.trim();
|
|
1984
|
-
if (miniProgramId) {
|
|
1985
|
-
url.searchParams.set('mini_program_id', miniProgramId);
|
|
1986
|
-
}
|
|
1987
|
-
const localDevLaunchToken = options.localDevLaunchToken?.trim();
|
|
1988
|
-
if (localDevLaunchToken) {
|
|
1989
|
-
url.searchParams.set(MINI_PROGRAM_LOCAL_DEV_LAUNCH_TOKEN_QUERY_PARAM, localDevLaunchToken);
|
|
1990
|
-
}
|
|
1991
|
-
const sdkVersion = options.sdkVersion?.trim();
|
|
1992
|
-
if (sdkVersion) {
|
|
1993
|
-
url.searchParams.set(MINI_PROGRAM_SDK_VERSION_QUERY_PARAM, sdkVersion);
|
|
1994
|
-
}
|
|
1995
|
-
return url.toString();
|
|
1996
|
-
}
|
|
1997
|
-
catch {
|
|
1998
|
-
return runtimeUrl;
|
|
1999
|
-
}
|
|
2000
|
-
}
|
|
2001
|
-
function createHeyboxProtocol(payload) {
|
|
2002
|
-
return `heybox://${encodeURIComponent(JSON.stringify(payload))}`;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
/** 构建时替换为当前发布包的实际版本。 */
|
|
2006
|
-
const HB_SDK_VERSION = typeof undefined === 'string'
|
|
2007
|
-
? undefined
|
|
2008
|
-
: '0.7.4-alpha.5';
|
|
2009
|
-
|
|
2010
|
-
class DevPermissionOverrideStoreError extends Error {
|
|
2011
|
-
code;
|
|
2012
|
-
constructor(message, code, options = {}) {
|
|
2013
|
-
super(message, { cause: options.cause });
|
|
2014
|
-
this.code = code;
|
|
2015
|
-
this.name = 'DevPermissionOverrideStoreError';
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
const CACHE_VERSION = 1;
|
|
2019
|
-
const CACHE_DIRECTORY_NAME = 'dev-permission-overrides';
|
|
2020
|
-
const defaultPaths = index.envPaths('hb-sdk', { suffix: '' });
|
|
2021
|
-
function createDevPermissionOverrideStore(options = {}) {
|
|
2022
|
-
const cacheDirectory = path.join(options.cacheRoot ?? defaultPaths.cache, CACHE_DIRECTORY_NAME);
|
|
2023
|
-
return {
|
|
2024
|
-
async read(scope) {
|
|
2025
|
-
return readScopeFile(await getScopeFile(cacheDirectory, scope));
|
|
2026
|
-
},
|
|
2027
|
-
async remove(scope) {
|
|
2028
|
-
const scopeFile = await getScopeFile(cacheDirectory, scope);
|
|
2029
|
-
try {
|
|
2030
|
-
await fs$1.unlink(scopeFile);
|
|
2031
|
-
}
|
|
2032
|
-
catch (error) {
|
|
2033
|
-
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
2034
|
-
return;
|
|
2035
|
-
}
|
|
2036
|
-
throw createWriteError(error);
|
|
2037
|
-
}
|
|
2038
|
-
},
|
|
2039
|
-
async set(scope, overrides) {
|
|
2040
|
-
const scopeFile = await getScopeFile(cacheDirectory, scope);
|
|
2041
|
-
try {
|
|
2042
|
-
await fs$1.mkdir(cacheDirectory, { recursive: true, mode: 0o700 });
|
|
2043
|
-
await fs$1.chmod(cacheDirectory, 0o700);
|
|
2044
|
-
await readScopeFile(scopeFile);
|
|
2045
|
-
await writeScopeFileAtomically(scopeFile, {
|
|
2046
|
-
version: CACHE_VERSION,
|
|
2047
|
-
overrides: cloneOverrides(overrides),
|
|
2048
|
-
});
|
|
2049
|
-
}
|
|
2050
|
-
catch (error) {
|
|
2051
|
-
if (error instanceof DevPermissionOverrideStoreError) {
|
|
2052
|
-
throw error;
|
|
2053
|
-
}
|
|
2054
|
-
throw createWriteError(error);
|
|
2055
|
-
}
|
|
2056
|
-
},
|
|
2057
|
-
};
|
|
2058
|
-
}
|
|
2059
|
-
async function getScopeFile(cacheDirectory, scope) {
|
|
2060
|
-
const projectRoot = await fs$1.realpath(scope.projectRoot);
|
|
2061
|
-
const miniProgramId = scope.miniProgramId ?? 'anonymous';
|
|
2062
|
-
const scopeHash = node_crypto.createHash('sha256').update(`${projectRoot}\0${miniProgramId}`).digest('hex');
|
|
2063
|
-
return path.join(cacheDirectory, `${scopeHash}.json`);
|
|
2064
|
-
}
|
|
2065
|
-
async function readScopeFile(scopeFile) {
|
|
2066
|
-
let contents;
|
|
2067
|
-
try {
|
|
2068
|
-
contents = await fs$1.readFile(scopeFile, 'utf8');
|
|
2069
|
-
}
|
|
2070
|
-
catch (error) {
|
|
2071
|
-
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
2072
|
-
return undefined;
|
|
2073
|
-
}
|
|
2074
|
-
throw error;
|
|
2075
|
-
}
|
|
2076
|
-
try {
|
|
2077
|
-
const parsed = JSON.parse(contents);
|
|
2078
|
-
if (!isPersistedOverride(parsed)) {
|
|
2079
|
-
throw new TypeError('invalid cache schema');
|
|
2080
|
-
}
|
|
2081
|
-
return cloneOverrides(parsed.overrides);
|
|
2082
|
-
}
|
|
2083
|
-
catch (error) {
|
|
2084
|
-
throw new DevPermissionOverrideStoreError('hb-sdk 本地权限覆盖缓存已损坏', 'DEV_PERMISSION_OVERRIDE_CACHE_CORRUPT', {
|
|
2085
|
-
cause: error,
|
|
2086
|
-
});
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
async function writeScopeFileAtomically(scopeFile, value) {
|
|
2090
|
-
const tempFile = `${scopeFile}.${process.pid}.${node_crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
2091
|
-
let handle;
|
|
2092
|
-
try {
|
|
2093
|
-
handle = await fs$1.open(tempFile, 'wx', 0o600);
|
|
2094
|
-
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
2095
|
-
await handle.sync();
|
|
2096
|
-
await handle.close();
|
|
2097
|
-
handle = undefined;
|
|
2098
|
-
await fs$1.rename(tempFile, scopeFile);
|
|
2099
|
-
}
|
|
2100
|
-
finally {
|
|
2101
|
-
await handle?.close().catch(() => undefined);
|
|
2102
|
-
await fs$1.unlink(tempFile).catch(() => undefined);
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
function createWriteError(cause) {
|
|
2106
|
-
return new DevPermissionOverrideStoreError('无法写入 hb-sdk 本地权限覆盖缓存', 'DEV_PERMISSION_OVERRIDE_WRITE_FAILED', { cause });
|
|
2107
|
-
}
|
|
2108
|
-
function cloneOverrides(overrides) {
|
|
2109
|
-
return {
|
|
2110
|
-
networkRequest: { ...overrides.networkRequest },
|
|
2111
|
-
};
|
|
2112
|
-
}
|
|
2113
|
-
function isPersistedOverride(value) {
|
|
2114
|
-
return isRecord(value) && value.version === CACHE_VERSION && Object.keys(value).length === 2 && isPermissionState(value.overrides);
|
|
2115
|
-
}
|
|
2116
|
-
function isPermissionState(value) {
|
|
2117
|
-
return (isRecord(value) &&
|
|
2118
|
-
Object.keys(value).length === 1 &&
|
|
2119
|
-
isRecord(value.networkRequest) &&
|
|
2120
|
-
(value.networkRequest.status === 'enabled' || value.networkRequest.status === 'disabled') &&
|
|
2121
|
-
typeof value.networkRequest.useOfficialDomain === 'boolean');
|
|
2122
|
-
}
|
|
2123
|
-
function isRecord(value) {
|
|
2124
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2125
|
-
}
|
|
2126
|
-
function isNodeError(error) {
|
|
2127
|
-
return error instanceof Error && 'code' in error;
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
const DEFAULT_APP_PORT = 5173;
|
|
2131
|
-
const DEFAULT_BROWSER_DEV_HOST_PORT = 5174;
|
|
2132
|
-
const DEFAULT_DEV_CONTEXT_TIMEOUT_MS = 3000;
|
|
2133
|
-
const DEV_LISTEN_HOST = '0.0.0.0';
|
|
2134
|
-
const LOCAL_DEV_URL_HOST = '127.0.0.1';
|
|
2135
|
-
const DEV_CONTEXT_UNAVAILABLE_WARNING = '远端 dev context 不可用,已继续匿名调试。';
|
|
2136
|
-
// Shared with Heybox H5 Vite plugins so hb-sdk can own the launch output.
|
|
2137
|
-
const MANAGED_DEV_OUTPUT_ENV = 'HEYBOX_DEV_SERVER_MANAGED_OUTPUT';
|
|
2138
|
-
const VITE_LOG_LEVEL = 'warn';
|
|
2139
|
-
async function runDevCommand(options, runtime = {}) {
|
|
2140
|
-
const logger = runtime.logger ?? index.createCliLogger();
|
|
2141
|
-
const fetchImpl = runtime.fetchImpl ?? fetch;
|
|
2142
|
-
const openUrl = runtime.openExternalUrl ?? browser.openExternalUrl;
|
|
2143
|
-
const networkInterfaceSnapshot = (runtime.networkInterfaces ?? os.networkInterfaces)();
|
|
2144
|
-
const getPortImpl = runtime.getPort ?? getPorts;
|
|
2145
|
-
const appPort = await selectAvailablePort({
|
|
2146
|
-
getPort: getPortImpl,
|
|
2147
|
-
host: DEV_LISTEN_HOST,
|
|
2148
|
-
label: 'Vite dev server',
|
|
2149
|
-
startPort: options.port ?? DEFAULT_APP_PORT,
|
|
2150
|
-
});
|
|
2151
|
-
const resolvedOptions = {
|
|
2152
|
-
...options,
|
|
2153
|
-
hmrHost: createLanInterfaceCandidates(networkInterfaceSnapshot)[0]?.address,
|
|
2154
|
-
port: appPort,
|
|
2155
|
-
};
|
|
2156
|
-
const projectRoot = projectVite.findProjectRoot(runtime.cwd ?? process.cwd());
|
|
2157
|
-
const devLaunchContext = await loadDevLaunchContext({
|
|
2158
|
-
env: runtime.env,
|
|
2159
|
-
fetchImpl,
|
|
2160
|
-
logger,
|
|
2161
|
-
projectRoot,
|
|
2162
|
-
timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
|
|
2163
|
-
});
|
|
2164
|
-
const permissionController = createDevPermissionController({
|
|
2165
|
-
boundMiniProgramId: await context.readBoundMiniProgramId(projectRoot),
|
|
2166
|
-
cacheRoot: runtime.devPermissionCacheRoot,
|
|
2167
|
-
devContext: devLaunchContext,
|
|
2168
|
-
loadRemoteContext: () => loadDevLaunchContext({
|
|
2169
|
-
env: runtime.env,
|
|
2170
|
-
fetchImpl,
|
|
2171
|
-
logger,
|
|
2172
|
-
projectRoot,
|
|
2173
|
-
requireRemote: true,
|
|
2174
|
-
timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
|
|
2175
|
-
}),
|
|
2176
|
-
projectRoot,
|
|
2177
|
-
});
|
|
2178
|
-
const vite = await logger.task('正在加载项目 Vite', () => projectVite.loadProjectVite(projectRoot), { successText: '已加载项目 Vite' });
|
|
2179
|
-
const appServer = await logger.task('正在创建 Vite dev server', () => runtimePermissionEnv.withMiniappPlatformCspEnv(context.shouldSkipMiniappPlatformCsp(devLaunchContext.runtimePermissions), () => withManagedDevOutputEnv(() => vite.createServer(createViteServerOptions(projectRoot, resolvedOptions)), runtime.env), runtime.env), { successText: '已创建 Vite dev server' });
|
|
2180
|
-
await logger.task('正在启动 Vite dev server', () => appServer.listen(appPort), {
|
|
2181
|
-
successText: 'Vite dev server 已启动',
|
|
2182
|
-
});
|
|
2183
|
-
const appUrl = await logger.task('正在探测小程序入口', () => resolveViteAppUrl(appServer, resolvedOptions, fetchImpl), {
|
|
2184
|
-
successText: '已确定小程序入口',
|
|
2185
|
-
});
|
|
2186
|
-
const lanAddresses = createLanAddressCandidates({
|
|
2187
|
-
appUrl,
|
|
2188
|
-
hmrHost: resolvedOptions.hmrHost,
|
|
2189
|
-
interfaces: networkInterfaceSnapshot,
|
|
2190
|
-
miniProgramId: devLaunchContext.miniProgramId,
|
|
2191
|
-
localDevLaunchToken: devLaunchContext.localDevLaunchToken,
|
|
2192
|
-
rawProtocol: options.rawProtocol,
|
|
2193
|
-
runtimeUrl: options.runtimeUrl,
|
|
2194
|
-
sdkVersion: HB_SDK_VERSION,
|
|
2195
|
-
viteNetworkUrls: appServer.resolvedUrls?.network ?? [],
|
|
2196
|
-
});
|
|
2197
|
-
const closers = [() => appServer.close()];
|
|
2198
|
-
let browserDevHost;
|
|
2199
|
-
try {
|
|
2200
|
-
browserDevHost = await logger.task('正在启动 Browser Dev Host', () => startBrowserDevHostServer({
|
|
2201
|
-
appUrl,
|
|
2202
|
-
devContext: devLaunchContext,
|
|
2203
|
-
fetchImpl,
|
|
2204
|
-
getPort: getPortImpl,
|
|
2205
|
-
defaultLanAddressId: lanAddresses[0]?.id,
|
|
2206
|
-
lanAddresses,
|
|
2207
|
-
lookupHostname: runtime.lookupHostname,
|
|
2208
|
-
loginService: runtime.loginService,
|
|
2209
|
-
macAppProtocol: createMacAppProtocol(appUrl, {
|
|
2210
|
-
miniProgramId: devLaunchContext.miniProgramId,
|
|
2211
|
-
localDevLaunchToken: devLaunchContext.localDevLaunchToken,
|
|
2212
|
-
runtimeUrl: options.runtimeUrl,
|
|
2213
|
-
sdkVersion: HB_SDK_VERSION,
|
|
2214
|
-
}),
|
|
2215
|
-
permissionController,
|
|
2216
|
-
port: options.browserDevHostPort ?? DEFAULT_BROWSER_DEV_HOST_PORT,
|
|
2217
|
-
root: runtime.browserDevHostRoot,
|
|
2218
|
-
rootCandidates: runtime.browserDevHostRootCandidates,
|
|
2219
|
-
runtimePermissions: devLaunchContext.runtimePermissions,
|
|
2220
|
-
}), { successText: 'Browser Dev Host 已启动' });
|
|
2221
|
-
}
|
|
2222
|
-
catch (error) {
|
|
2223
|
-
await closeAll(closers);
|
|
2224
|
-
throw error;
|
|
2225
|
-
}
|
|
2226
|
-
closers.push(() => browserDevHost.close());
|
|
2227
|
-
if (devLaunchContext.source === 'remote' && devLaunchContext.miniProgramId) {
|
|
2228
|
-
const tokenSession = startLocalDevLaunchTokenSession({
|
|
2229
|
-
env: runtime.env,
|
|
2230
|
-
fetchImpl,
|
|
2231
|
-
logger,
|
|
2232
|
-
miniProgramId: devLaunchContext.miniProgramId,
|
|
2233
|
-
onToken: (localDevLaunchToken) => {
|
|
2234
|
-
const tokenContext = {
|
|
2235
|
-
source: 'remote',
|
|
2236
|
-
miniProgramId: devLaunchContext.miniProgramId,
|
|
2237
|
-
...(localDevLaunchToken ? { localDevLaunchToken } : {}),
|
|
2238
|
-
runtimePermissions: devLaunchContext.runtimePermissions,
|
|
2239
|
-
};
|
|
2240
|
-
browserDevHost.updateBootstrap({
|
|
2241
|
-
devContext: tokenContext,
|
|
2242
|
-
lanAddresses: createLanAddressCandidates({
|
|
2243
|
-
appUrl,
|
|
2244
|
-
hmrHost: resolvedOptions.hmrHost,
|
|
2245
|
-
interfaces: networkInterfaceSnapshot,
|
|
2246
|
-
miniProgramId: devLaunchContext.miniProgramId,
|
|
2247
|
-
localDevLaunchToken,
|
|
2248
|
-
rawProtocol: options.rawProtocol,
|
|
2249
|
-
runtimeUrl: options.runtimeUrl,
|
|
2250
|
-
sdkVersion: HB_SDK_VERSION,
|
|
2251
|
-
viteNetworkUrls: appServer.resolvedUrls?.network ?? [],
|
|
2252
|
-
}),
|
|
2253
|
-
macAppProtocol: createMacAppProtocol(appUrl, {
|
|
2254
|
-
miniProgramId: devLaunchContext.miniProgramId,
|
|
2255
|
-
localDevLaunchToken,
|
|
2256
|
-
runtimeUrl: options.runtimeUrl,
|
|
2257
|
-
sdkVersion: HB_SDK_VERSION,
|
|
2258
|
-
}),
|
|
2259
|
-
});
|
|
2260
|
-
},
|
|
2261
|
-
projectRoot,
|
|
2262
|
-
timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
|
|
2263
|
-
});
|
|
2264
|
-
closers.push(tokenSession.close);
|
|
2265
|
-
}
|
|
2266
|
-
logger.success('Browser Dev Host 已就绪');
|
|
2267
|
-
logger.info(`Browser Dev Host: ${browserDevHost.url}`);
|
|
2268
|
-
logger.info(`Mini program URL: ${appUrl}`);
|
|
2269
|
-
logger.info('Mac APP: use the button in Browser Dev Host');
|
|
2270
|
-
logger.info('Mobile APP: scan the QR code in Browser Dev Host');
|
|
2271
|
-
if (devLaunchContext.source === 'anonymous') {
|
|
2272
|
-
logger.warn('当前使用匿名本地沙箱;真机调试可用,但不会携带远端小程序身份或权限。');
|
|
2273
|
-
}
|
|
2274
|
-
if (options.open !== false) {
|
|
2275
|
-
logger.debug('正在打开浏览器调试页');
|
|
2276
|
-
void openUrl(browserDevHost.url);
|
|
2277
|
-
}
|
|
2278
|
-
installShutdownHandlers(closers, runtime.process ?? process);
|
|
2279
|
-
}
|
|
2280
|
-
async function loadDevLaunchContext(options) {
|
|
2281
|
-
const miniProgramId = await context.readBoundMiniProgramId(options.projectRoot);
|
|
2282
|
-
if (!miniProgramId) {
|
|
2283
|
-
const anonymousReason = '当前项目未绑定小程序。请先运行 hb-sdk remote create 或 hb-sdk remote bind <mini-program-id>。';
|
|
2284
|
-
if (options.requireRemote) {
|
|
2285
|
-
throw new Error(anonymousReason);
|
|
2286
|
-
}
|
|
2287
|
-
options.logger.warn(`未读取到远端 Runtime 权限,Browser Dev Host 将默认拒绝受管能力:${anonymousReason}`);
|
|
2288
|
-
return {
|
|
2289
|
-
source: 'anonymous',
|
|
2290
|
-
warning: anonymousReason,
|
|
2291
|
-
};
|
|
2292
|
-
}
|
|
2293
|
-
try {
|
|
2294
|
-
const { detail, miniProgramId: verifiedMiniProgramId } = await options.logger.task('正在读取远端小程序权限', () => loadRemoteDevContextWithTimeout({
|
|
2295
|
-
fetchImpl: options.fetchImpl,
|
|
2296
|
-
load: (fetchImpl) => context.getBoundMiniProgram({
|
|
2297
|
-
cwd: options.projectRoot,
|
|
2298
|
-
env: options.env,
|
|
2299
|
-
fetchImpl,
|
|
2300
|
-
}),
|
|
2301
|
-
timeoutMs: options.timeoutMs,
|
|
2302
|
-
}), { successText: '已读取远端小程序权限' });
|
|
2303
|
-
return {
|
|
2304
|
-
source: 'remote',
|
|
2305
|
-
miniProgramId: verifiedMiniProgramId,
|
|
2306
|
-
runtimePermissions: detail.runtime_permissions,
|
|
2307
|
-
};
|
|
2308
|
-
}
|
|
2309
|
-
catch (error) {
|
|
2310
|
-
if (error instanceof context.MiniProgramProjectBindingError && error.code !== 'MINI_PROGRAM_UNBOUND') {
|
|
2311
|
-
throw error;
|
|
2312
|
-
}
|
|
2313
|
-
if (options.requireRemote) {
|
|
2314
|
-
throw error;
|
|
2315
|
-
}
|
|
2316
|
-
options.logger.debug(`远端 dev context 失败详情:${index.readRedactedErrorMessage(error, { verbose: true })}`);
|
|
2317
|
-
options.logger.warn(`未读取到远端 Runtime 权限,Browser Dev Host 将默认拒绝受管能力:${DEV_CONTEXT_UNAVAILABLE_WARNING}`);
|
|
2318
|
-
return {
|
|
2319
|
-
source: 'anonymous',
|
|
2320
|
-
warning: DEV_CONTEXT_UNAVAILABLE_WARNING,
|
|
2321
|
-
};
|
|
2322
|
-
}
|
|
2323
|
-
}
|
|
2324
|
-
function createDevPermissionController(options) {
|
|
2325
|
-
const store = createDevPermissionOverrideStore({ cacheRoot: options.cacheRoot });
|
|
2326
|
-
let devContext = options.devContext;
|
|
2327
|
-
let ephemeralOverride;
|
|
2328
|
-
let persistenceError;
|
|
2329
|
-
const readScope = () => ({
|
|
2330
|
-
projectRoot: options.projectRoot,
|
|
2331
|
-
...(options.boundMiniProgramId ? { miniProgramId: options.boundMiniProgramId } : {}),
|
|
2332
|
-
});
|
|
2333
|
-
const createConfiguration = (overrides, error = persistenceError) => {
|
|
2334
|
-
const miniProgramId = options.boundMiniProgramId ?? devContext.miniProgramId;
|
|
2335
|
-
return {
|
|
2336
|
-
source: devContext.source,
|
|
2337
|
-
...(miniProgramId ? { mini_program_id: miniProgramId } : {}),
|
|
2338
|
-
...(isRuntimePermissionsSnapshot(devContext.runtimePermissions) ? { remote_permissions: devContext.runtimePermissions } : {}),
|
|
2339
|
-
...(overrides ? { override_permissions: context.createBrowserDevRuntimePermissions(overrides) } : {}),
|
|
2340
|
-
...(error ? { persistence_error: error } : {}),
|
|
2341
|
-
};
|
|
2342
|
-
};
|
|
2343
|
-
const get = async () => {
|
|
2344
|
-
try {
|
|
2345
|
-
const persistedOverride = await store.read(readScope());
|
|
2346
|
-
return createConfiguration(ephemeralOverride ?? persistedOverride);
|
|
2347
|
-
}
|
|
2348
|
-
catch (error) {
|
|
2349
|
-
persistenceError = index.readErrorMessage(error);
|
|
2350
|
-
return createConfiguration(ephemeralOverride, persistenceError);
|
|
2351
|
-
}
|
|
2352
|
-
};
|
|
2353
|
-
return {
|
|
2354
|
-
get,
|
|
2355
|
-
async reset() {
|
|
2356
|
-
if (devContext.source === 'anonymous' && !options.boundMiniProgramId) {
|
|
2357
|
-
await store.remove(readScope());
|
|
2358
|
-
ephemeralOverride = undefined;
|
|
2359
|
-
persistenceError = undefined;
|
|
2360
|
-
return createConfiguration(undefined);
|
|
2361
|
-
}
|
|
2362
|
-
let refreshedContext;
|
|
2363
|
-
try {
|
|
2364
|
-
refreshedContext = await options.loadRemoteContext();
|
|
2365
|
-
}
|
|
2366
|
-
catch (error) {
|
|
2367
|
-
throw new Error('远端权限读取失败,请检查 CLI 登录状态和网络后重试。', { cause: error });
|
|
2368
|
-
}
|
|
2369
|
-
await store.remove(readScope());
|
|
2370
|
-
devContext = refreshedContext;
|
|
2371
|
-
ephemeralOverride = undefined;
|
|
2372
|
-
persistenceError = undefined;
|
|
2373
|
-
return createConfiguration(undefined);
|
|
2374
|
-
},
|
|
2375
|
-
async save(runtimePermissions) {
|
|
2376
|
-
const overrides = context.createBrowserDevPermissionState({
|
|
2377
|
-
runtimePermissions,
|
|
2378
|
-
});
|
|
2379
|
-
try {
|
|
2380
|
-
await store.set(readScope(), overrides);
|
|
2381
|
-
ephemeralOverride = undefined;
|
|
2382
|
-
persistenceError = undefined;
|
|
2383
|
-
return createConfiguration(overrides);
|
|
2384
|
-
}
|
|
2385
|
-
catch {
|
|
2386
|
-
ephemeralOverride = overrides;
|
|
2387
|
-
persistenceError = '保存失败,当前页面仍会应用该权限,但刷新或重启后会丢失。';
|
|
2388
|
-
return createConfiguration(overrides, persistenceError);
|
|
2389
|
-
}
|
|
2390
|
-
},
|
|
2391
|
-
};
|
|
2392
|
-
}
|
|
2393
|
-
function isRuntimePermissionsSnapshot(value) {
|
|
2394
|
-
return context.parseMiniProgramRuntimePermissions(value).valid;
|
|
2395
|
-
}
|
|
2396
|
-
async function loadRemoteDevContextWithTimeout(options) {
|
|
2397
|
-
const controller = new AbortController();
|
|
2398
|
-
const fetchImpl = ((input, init) => options.fetchImpl(input, { ...init, signal: controller.signal }));
|
|
2399
|
-
let timeout;
|
|
2400
|
-
try {
|
|
2401
|
-
return await Promise.race([
|
|
2402
|
-
options.load(fetchImpl),
|
|
2403
|
-
new Promise((_resolve, reject) => {
|
|
2404
|
-
timeout = setTimeout(() => {
|
|
2405
|
-
controller.abort();
|
|
2406
|
-
reject(new Error('dev context timeout'));
|
|
2407
|
-
}, Math.max(1, options.timeoutMs));
|
|
2408
|
-
}),
|
|
2409
|
-
]);
|
|
2410
|
-
}
|
|
2411
|
-
finally {
|
|
2412
|
-
if (timeout) {
|
|
2413
|
-
clearTimeout(timeout);
|
|
2414
|
-
}
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2417
|
-
function createViteServerOptions(projectRoot, options) {
|
|
2418
|
-
const configFile = resolveProjectViteConfig(projectRoot);
|
|
2419
|
-
const server = {
|
|
2420
|
-
cors: true,
|
|
2421
|
-
hmr: {
|
|
2422
|
-
clientPort: options.port ?? DEFAULT_APP_PORT,
|
|
2423
|
-
...(options.hmrHost ? { host: options.hmrHost } : {}),
|
|
2424
|
-
},
|
|
2425
|
-
host: DEV_LISTEN_HOST,
|
|
2426
|
-
port: options.port ?? DEFAULT_APP_PORT,
|
|
2427
|
-
strictPort: true,
|
|
2428
|
-
};
|
|
2429
|
-
if (configFile) {
|
|
2430
|
-
return {
|
|
2431
|
-
configFile,
|
|
2432
|
-
logLevel: VITE_LOG_LEVEL,
|
|
2433
|
-
server,
|
|
2434
|
-
};
|
|
2435
|
-
}
|
|
2436
|
-
return {
|
|
2437
|
-
logLevel: VITE_LOG_LEVEL,
|
|
2438
|
-
root: projectRoot,
|
|
2439
|
-
server,
|
|
2440
|
-
};
|
|
2441
|
-
}
|
|
2442
|
-
function resolveProjectViteConfig(projectRoot) {
|
|
2443
|
-
const candidates = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'];
|
|
2444
|
-
return candidates.map((file) => path.join(projectRoot, file)).find((file) => fs.existsSync(file));
|
|
2445
|
-
}
|
|
2446
|
-
async function resolveViteAppUrl(server, options, fetchImpl) {
|
|
2447
|
-
const candidates = createViteAppUrlCandidates(server, options);
|
|
2448
|
-
for (const candidate of candidates) {
|
|
2449
|
-
if (await canReachDocumentUrl(candidate, fetchImpl)) {
|
|
2450
|
-
return candidate;
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2453
|
-
return candidates[0];
|
|
2454
|
-
}
|
|
2455
|
-
function createViteAppUrlCandidates(server, options) {
|
|
2456
|
-
const localUrl = rewriteLocalhostUrl(server.resolvedUrls?.local[0]);
|
|
2457
|
-
const origin = readViteOrigin(localUrl, options);
|
|
2458
|
-
const candidates = [localUrl, createH5EntryUrl(origin, readViteBase(server, localUrl))].filter((url) => Boolean(url));
|
|
2459
|
-
return Array.from(new Set(candidates));
|
|
2460
|
-
}
|
|
2461
|
-
function readViteOrigin(localUrl, options) {
|
|
2462
|
-
if (localUrl) {
|
|
2463
|
-
try {
|
|
2464
|
-
return new URL(localUrl).origin;
|
|
2465
|
-
}
|
|
2466
|
-
catch {
|
|
2467
|
-
/* fall through */
|
|
2468
|
-
}
|
|
2469
|
-
}
|
|
2470
|
-
const port = options.port ?? DEFAULT_APP_PORT;
|
|
2471
|
-
return `http://${LOCAL_DEV_URL_HOST}:${port}`;
|
|
2472
|
-
}
|
|
2473
|
-
function readViteBase(server, localUrl) {
|
|
2474
|
-
const base = server.config?.base;
|
|
2475
|
-
if (base && base !== '/') {
|
|
2476
|
-
return base;
|
|
2477
|
-
}
|
|
2478
|
-
if (localUrl) {
|
|
2479
|
-
try {
|
|
2480
|
-
return new URL(localUrl).pathname;
|
|
2481
|
-
}
|
|
2482
|
-
catch {
|
|
2483
|
-
/* fall through */
|
|
2484
|
-
}
|
|
2485
|
-
}
|
|
2486
|
-
return '/';
|
|
2487
|
-
}
|
|
2488
|
-
function createH5EntryUrl(origin, base) {
|
|
2489
|
-
const pathname = `${ensureTrailingSlash(base)}src/index.html`;
|
|
2490
|
-
return new URL(pathname, `${origin}/`).toString();
|
|
2491
|
-
}
|
|
2492
|
-
async function canReachDocumentUrl(url, fetchImpl) {
|
|
2493
|
-
try {
|
|
2494
|
-
const headResponse = await fetchImpl(url, {
|
|
2495
|
-
method: 'HEAD',
|
|
2496
|
-
redirect: 'follow',
|
|
2497
|
-
});
|
|
2498
|
-
if (headResponse.ok) {
|
|
2499
|
-
return true;
|
|
2500
|
-
}
|
|
2501
|
-
if (headResponse.status !== 404 && headResponse.status !== 405) {
|
|
2502
|
-
return false;
|
|
2503
|
-
}
|
|
2504
|
-
const getResponse = await fetchImpl(url, {
|
|
2505
|
-
method: 'GET',
|
|
2506
|
-
redirect: 'follow',
|
|
2507
|
-
});
|
|
2508
|
-
return getResponse.ok;
|
|
2509
|
-
}
|
|
2510
|
-
catch {
|
|
2511
|
-
return false;
|
|
2512
|
-
}
|
|
2513
|
-
}
|
|
2514
|
-
function ensureTrailingSlash(value) {
|
|
2515
|
-
return value.endsWith('/') ? value : `${value}/`;
|
|
2516
|
-
}
|
|
2517
|
-
function rewriteLocalhostUrl(input) {
|
|
2518
|
-
if (!input) {
|
|
2519
|
-
return undefined;
|
|
2520
|
-
}
|
|
2521
|
-
try {
|
|
2522
|
-
const url = new URL(input);
|
|
2523
|
-
if (isLocalhostHost(url.hostname)) {
|
|
2524
|
-
url.hostname = LOCAL_DEV_URL_HOST;
|
|
2525
|
-
return url.toString();
|
|
2526
|
-
}
|
|
2527
|
-
}
|
|
2528
|
-
catch {
|
|
2529
|
-
return input;
|
|
2530
|
-
}
|
|
2531
|
-
return input;
|
|
2532
|
-
}
|
|
2533
|
-
function isLocalhostHost(hostname) {
|
|
2534
|
-
return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
|
|
2535
|
-
}
|
|
2536
|
-
async function withManagedDevOutputEnv(action, env = process.env) {
|
|
2537
|
-
const previousManagedOutput = env[MANAGED_DEV_OUTPUT_ENV];
|
|
2538
|
-
env[MANAGED_DEV_OUTPUT_ENV] = '1';
|
|
2539
|
-
try {
|
|
2540
|
-
return await action();
|
|
2541
|
-
}
|
|
2542
|
-
finally {
|
|
2543
|
-
if (previousManagedOutput === undefined) {
|
|
2544
|
-
delete env[MANAGED_DEV_OUTPUT_ENV];
|
|
2545
|
-
}
|
|
2546
|
-
else {
|
|
2547
|
-
env[MANAGED_DEV_OUTPUT_ENV] = previousManagedOutput;
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
2551
|
-
function installShutdownHandlers(closers, processLike) {
|
|
2552
|
-
const shutdown = async () => {
|
|
2553
|
-
await closeAll(closers);
|
|
2554
|
-
processLike.exit(0);
|
|
2555
|
-
};
|
|
2556
|
-
processLike.once('SIGINT', () => {
|
|
2557
|
-
void shutdown();
|
|
2558
|
-
});
|
|
2559
|
-
processLike.once('SIGTERM', () => {
|
|
2560
|
-
void shutdown();
|
|
2561
|
-
});
|
|
2562
|
-
}
|
|
2563
|
-
async function closeAll(closers) {
|
|
2564
|
-
await Promise.allSettled(closers.map((close) => close()));
|
|
2565
|
-
}
|
|
2566
|
-
function startLocalDevLaunchTokenSession(options) {
|
|
2567
|
-
let closed = false;
|
|
2568
|
-
let expirationTimer;
|
|
2569
|
-
let refreshTimer;
|
|
2570
|
-
const refresh = async () => {
|
|
2571
|
-
try {
|
|
2572
|
-
const issued = await issueLocalDevLaunchToken(options);
|
|
2573
|
-
if (closed)
|
|
2574
|
-
return;
|
|
2575
|
-
if (!issued) {
|
|
2576
|
-
schedule(options.timeoutMs);
|
|
2577
|
-
return;
|
|
2578
|
-
}
|
|
2579
|
-
options.onToken(issued.token);
|
|
2580
|
-
const ttl = Math.max(0, issued.expiresAt - Date.now());
|
|
2581
|
-
scheduleExpiration(ttl);
|
|
2582
|
-
schedule(Math.max(1_000, Math.floor(ttl * 0.8)));
|
|
2583
|
-
}
|
|
2584
|
-
catch (error) {
|
|
2585
|
-
if (closed)
|
|
2586
|
-
return;
|
|
2587
|
-
options.logger.warn(`刷新本地调试启动票失败,将自动重试:${index.readRedactedErrorMessage(error)}`);
|
|
2588
|
-
schedule(options.timeoutMs);
|
|
2589
|
-
}
|
|
2590
|
-
};
|
|
2591
|
-
const schedule = (delayMs) => {
|
|
2592
|
-
refreshTimer = setTimeout(() => {
|
|
2593
|
-
void refresh();
|
|
2594
|
-
}, Math.max(1, delayMs));
|
|
2595
|
-
refreshTimer.unref?.();
|
|
2596
|
-
};
|
|
2597
|
-
const scheduleExpiration = (ttl) => {
|
|
2598
|
-
if (expirationTimer)
|
|
2599
|
-
clearTimeout(expirationTimer);
|
|
2600
|
-
expirationTimer = setTimeout(() => {
|
|
2601
|
-
if (!closed)
|
|
2602
|
-
options.onToken(undefined);
|
|
2603
|
-
}, Math.max(1, ttl));
|
|
2604
|
-
expirationTimer.unref?.();
|
|
2605
|
-
};
|
|
2606
|
-
void refresh();
|
|
2607
|
-
return {
|
|
2608
|
-
close: () => {
|
|
2609
|
-
closed = true;
|
|
2610
|
-
if (expirationTimer)
|
|
2611
|
-
clearTimeout(expirationTimer);
|
|
2612
|
-
if (refreshTimer)
|
|
2613
|
-
clearTimeout(refreshTimer);
|
|
2614
|
-
},
|
|
2615
|
-
};
|
|
2616
|
-
}
|
|
2617
|
-
async function issueLocalDevLaunchToken(options) {
|
|
2618
|
-
try {
|
|
2619
|
-
const issued = await loadRemoteDevContextWithTimeout({
|
|
2620
|
-
fetchImpl: options.fetchImpl,
|
|
2621
|
-
timeoutMs: options.timeoutMs,
|
|
2622
|
-
load: (fetchImpl) => context.createLocalDevLaunchToken({
|
|
2623
|
-
cwd: options.projectRoot,
|
|
2624
|
-
env: options.env,
|
|
2625
|
-
fetchImpl,
|
|
2626
|
-
miniProgramId: options.miniProgramId,
|
|
2627
|
-
}),
|
|
2628
|
-
});
|
|
2629
|
-
const token = typeof issued.token === 'string' ? issued.token.trim() : '';
|
|
2630
|
-
if (!token) {
|
|
2631
|
-
throw new Error('响应缺少 token');
|
|
2632
|
-
}
|
|
2633
|
-
const expiresAt = normalizeExpiresAt(issued.expires_at);
|
|
2634
|
-
if (!expiresAt || expiresAt <= Date.now()) {
|
|
2635
|
-
throw new Error('响应缺少有效 expires_at');
|
|
2636
|
-
}
|
|
2637
|
-
return { expiresAt, token };
|
|
2638
|
-
}
|
|
2639
|
-
catch (error) {
|
|
2640
|
-
options.logger.warn(`未能签发本地调试启动票,扫码后公开详情可能仍拿不到未发布小程序身份:${index.readRedactedErrorMessage(error)}`);
|
|
2641
|
-
return undefined;
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2644
|
-
function normalizeExpiresAt(value) {
|
|
2645
|
-
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
2646
|
-
return undefined;
|
|
2647
|
-
return value < 1_000_000_000_000 ? value * 1000 : value;
|
|
2648
|
-
}
|
|
2649
|
-
function createLanAddressCandidates(options) {
|
|
2650
|
-
const viteNetworkUrlByHost = new Map(options.viteNetworkUrls.map((url) => [readUrlHost(url), url]).filter((entry) => Boolean(entry[0])));
|
|
2651
|
-
const candidates = createLanInterfaceCandidates(options.interfaces)
|
|
2652
|
-
.filter(({ address }) => address === options.hmrHost)
|
|
2653
|
-
.map(({ address, id, name }) => {
|
|
2654
|
-
const appUrl = viteNetworkUrlByHost.get(address) ?? replaceUrlHost(options.appUrl, address);
|
|
2655
|
-
if (!appUrl) {
|
|
2656
|
-
return undefined;
|
|
2657
|
-
}
|
|
2658
|
-
const candidate = {
|
|
2659
|
-
address,
|
|
2660
|
-
appUrl,
|
|
2661
|
-
id,
|
|
2662
|
-
name,
|
|
2663
|
-
};
|
|
2664
|
-
const runtimeUrl = rewriteLoopbackUrlHost(options.runtimeUrl, address);
|
|
2665
|
-
if (runtimeUrl !== undefined) {
|
|
2666
|
-
candidate.mobileAppQrPayload = createMobileAppQrPayload(appUrl, {
|
|
2667
|
-
bareProtocol: options.rawProtocol,
|
|
2668
|
-
miniProgramId: options.miniProgramId,
|
|
2669
|
-
localDevLaunchToken: options.localDevLaunchToken,
|
|
2670
|
-
runtimeUrl,
|
|
2671
|
-
sdkVersion: options.sdkVersion,
|
|
2672
|
-
});
|
|
2673
|
-
}
|
|
2674
|
-
else {
|
|
2675
|
-
candidate.mobileAppQrPayload = createMobileAppQrPayload(appUrl, {
|
|
2676
|
-
bareProtocol: options.rawProtocol,
|
|
2677
|
-
miniProgramId: options.miniProgramId,
|
|
2678
|
-
localDevLaunchToken: options.localDevLaunchToken,
|
|
2679
|
-
sdkVersion: options.sdkVersion,
|
|
2680
|
-
});
|
|
2681
|
-
}
|
|
2682
|
-
return candidate;
|
|
2683
|
-
})
|
|
2684
|
-
.filter((candidate) => Boolean(candidate));
|
|
2685
|
-
return candidates.sort((left, right) => {
|
|
2686
|
-
const leftNetworkMatch = viteNetworkUrlByHost.has(left.address) ? 0 : 1;
|
|
2687
|
-
const rightNetworkMatch = viteNetworkUrlByHost.has(right.address) ? 0 : 1;
|
|
2688
|
-
if (leftNetworkMatch !== rightNetworkMatch) {
|
|
2689
|
-
return leftNetworkMatch - rightNetworkMatch;
|
|
2690
|
-
}
|
|
2691
|
-
return readInterfacePriority(left.name) - readInterfacePriority(right.name) || left.name.localeCompare(right.name);
|
|
2692
|
-
});
|
|
2693
|
-
}
|
|
2694
|
-
function createLanInterfaceCandidates(interfaces) {
|
|
2695
|
-
return Object.entries(interfaces)
|
|
2696
|
-
.flatMap(([name, infos]) => (infos ?? []).map((info) => ({
|
|
2697
|
-
info,
|
|
2698
|
-
name,
|
|
2699
|
-
})))
|
|
2700
|
-
.filter(({ info }) => info.family === 'IPv4' && !info.internal && isPrivateIPv4(info.address))
|
|
2701
|
-
.map(({ info, name }) => ({
|
|
2702
|
-
address: info.address,
|
|
2703
|
-
id: `${name}-${info.address}`,
|
|
2704
|
-
name,
|
|
2705
|
-
}))
|
|
2706
|
-
.sort((left, right) => {
|
|
2707
|
-
return readInterfacePriority(left.name) - readInterfacePriority(right.name) || left.name.localeCompare(right.name);
|
|
2708
|
-
});
|
|
2709
|
-
}
|
|
2710
|
-
function rewriteLoopbackUrlHost(input, host) {
|
|
2711
|
-
if (input === undefined || input === '') {
|
|
2712
|
-
return undefined;
|
|
2713
|
-
}
|
|
2714
|
-
try {
|
|
2715
|
-
const url = new URL(input);
|
|
2716
|
-
if (isLoopbackHost(url.hostname)) {
|
|
2717
|
-
url.hostname = host;
|
|
2718
|
-
return url.toString();
|
|
2719
|
-
}
|
|
2720
|
-
}
|
|
2721
|
-
catch {
|
|
2722
|
-
return input;
|
|
2723
|
-
}
|
|
2724
|
-
return input;
|
|
2725
|
-
}
|
|
2726
|
-
function isLoopbackHost(hostname) {
|
|
2727
|
-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
2728
|
-
}
|
|
2729
|
-
function readUrlHost(input) {
|
|
2730
|
-
try {
|
|
2731
|
-
return new URL(input).hostname;
|
|
2732
|
-
}
|
|
2733
|
-
catch {
|
|
2734
|
-
return undefined;
|
|
2735
|
-
}
|
|
2736
|
-
}
|
|
2737
|
-
function replaceUrlHost(input, host) {
|
|
2738
|
-
try {
|
|
2739
|
-
const url = new URL(input);
|
|
2740
|
-
url.hostname = host;
|
|
2741
|
-
return url.toString();
|
|
2742
|
-
}
|
|
2743
|
-
catch {
|
|
2744
|
-
return undefined;
|
|
2745
|
-
}
|
|
2746
|
-
}
|
|
2747
|
-
function readInterfacePriority(name) {
|
|
2748
|
-
const normalized = name.toLowerCase();
|
|
2749
|
-
if (normalized.includes('wi-fi') || normalized.includes('wifi') || normalized.includes('wlan')) {
|
|
2750
|
-
return 0;
|
|
2751
|
-
}
|
|
2752
|
-
if (normalized === 'en0') {
|
|
2753
|
-
return 1;
|
|
2754
|
-
}
|
|
2755
|
-
if (normalized.startsWith('en') || normalized.includes('ethernet')) {
|
|
2756
|
-
return 2;
|
|
2757
|
-
}
|
|
2758
|
-
return 3;
|
|
2759
|
-
}
|
|
2760
|
-
function isPrivateIPv4(address) {
|
|
2761
|
-
const parts = address.split('.').map((part) => Number(part));
|
|
2762
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
2763
|
-
return false;
|
|
2764
|
-
}
|
|
2765
|
-
return parts[0] === 10 || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 192 && parts[1] === 168);
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
exports.normalizeExpiresAt = normalizeExpiresAt;
|
|
2769
|
-
exports.runDevCommand = runDevCommand;
|