@heybox/hb-sdk 0.6.5 → 0.6.6-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/cli-chunks/{context-DjepdCaa.cjs → context-Do5YcZPw.cjs} +1 -1
- package/dist/cli-chunks/{create-D3BBKay9.cjs → create-CG36zW_8.cjs} +1 -1
- package/dist/cli-chunks/dev-BjlyFzNU.cjs +1976 -0
- package/dist/cli-chunks/{doctor-DEgZa2qC.cjs → doctor-OIjeEwrd.cjs} +1 -1
- package/dist/cli-chunks/{index-CKat0ExC.cjs → index-Ck6a9STO.cjs} +13 -13
- package/dist/cli-chunks/{index-B5gEl9ow.cjs → index-NrjOOQMK.cjs} +2 -2
- package/dist/cli-chunks/{login-B6gERpSX.cjs → login-CqXRKZgq.cjs} +2 -2
- package/dist/cli-chunks/{remote-BfQiZzeJ.cjs → remote-vquOa1cT.cjs} +4 -4
- package/dist/cli-chunks/{session-DPEq__gB.cjs → session-DUM4KQHQ.cjs} +1 -1
- package/dist/cli.cjs +1 -1
- package/dist/devtools/mock-host/index.html +1 -0
- package/dist/devtools/mock-host/main.js +258 -20
- 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 +4 -3
- package/skill/SKILL.md +3 -1
- package/skill/references/api-root.md +6 -2
- package/skill/references/cli.md +4 -2
- package/skill/skill.json +4 -4
- package/types/protocol/dev-session.d.ts +25 -0
- package/dist/cli-chunks/dev-C3u5FXIy.cjs +0 -1122
|
@@ -0,0 +1,1976 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var node_module = require('node:module');
|
|
4
|
+
var fs = require('node:fs');
|
|
5
|
+
var fs$1 = require('node:fs/promises');
|
|
6
|
+
var os = require('node:os');
|
|
7
|
+
var path = require('node:path');
|
|
8
|
+
var node_url = require('node:url');
|
|
9
|
+
var net = require('node:net');
|
|
10
|
+
var promises = require('node:dns/promises');
|
|
11
|
+
var node_http = require('node:http');
|
|
12
|
+
var node_crypto = require('node:crypto');
|
|
13
|
+
var undici = require('undici');
|
|
14
|
+
var browser = require('./browser-RAy8e8cV.cjs');
|
|
15
|
+
var index = require('./index-Ck6a9STO.cjs');
|
|
16
|
+
var context = require('./context-Do5YcZPw.cjs');
|
|
17
|
+
require('node:process');
|
|
18
|
+
require('node:buffer');
|
|
19
|
+
require('node:util');
|
|
20
|
+
require('node:child_process');
|
|
21
|
+
require('path');
|
|
22
|
+
require('os');
|
|
23
|
+
require('readline');
|
|
24
|
+
require('tty');
|
|
25
|
+
require('assert');
|
|
26
|
+
require('events');
|
|
27
|
+
require('stream');
|
|
28
|
+
require('buffer');
|
|
29
|
+
require('util');
|
|
30
|
+
require('./session-DUM4KQHQ.cjs');
|
|
31
|
+
require('fs');
|
|
32
|
+
require('constants');
|
|
33
|
+
|
|
34
|
+
class Locked extends Error {
|
|
35
|
+
constructor(port) {
|
|
36
|
+
super(`${port} is locked`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lockedPorts = {
|
|
41
|
+
old: new Set(),
|
|
42
|
+
young: new Set(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// On this interval, the old locked ports are discarded,
|
|
46
|
+
// the young locked ports are moved to old locked ports,
|
|
47
|
+
// and a new young set for locked ports are created.
|
|
48
|
+
const releaseOldLockedPortsIntervalMs = 1000 * 15;
|
|
49
|
+
|
|
50
|
+
// Keep `reserve` deliberately process-wide by port number.
|
|
51
|
+
// It is meant to avoid in-process races, not to model every possible
|
|
52
|
+
// IPv4/IPv6 or host-specific bind combination.
|
|
53
|
+
const reservedPorts = new Set();
|
|
54
|
+
|
|
55
|
+
// Lazily create timeout on first use
|
|
56
|
+
let timeout;
|
|
57
|
+
|
|
58
|
+
const getLocalHosts = () => {
|
|
59
|
+
const interfaces = os.networkInterfaces();
|
|
60
|
+
|
|
61
|
+
// Add undefined value for createServer function to use default host,
|
|
62
|
+
// and default IPv4 host in case createServer defaults to IPv6.
|
|
63
|
+
const results = new Set([undefined, '0.0.0.0']);
|
|
64
|
+
|
|
65
|
+
for (const _interface of Object.values(interfaces)) {
|
|
66
|
+
for (const config of _interface) {
|
|
67
|
+
results.add(config.address);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return results;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const checkAvailablePort = options =>
|
|
75
|
+
new Promise((resolve, reject) => {
|
|
76
|
+
const server = net.createServer();
|
|
77
|
+
server.unref();
|
|
78
|
+
server.on('error', reject);
|
|
79
|
+
|
|
80
|
+
server.listen(options, () => {
|
|
81
|
+
const {port} = server.address();
|
|
82
|
+
server.close(() => {
|
|
83
|
+
resolve(port);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const getAvailablePort = async (options, hosts) => {
|
|
89
|
+
if (options.host || options.port === 0) {
|
|
90
|
+
return checkAvailablePort(options);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const host of hosts) {
|
|
94
|
+
try {
|
|
95
|
+
await checkAvailablePort({port: options.port, host}); // eslint-disable-line no-await-in-loop
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!['EADDRNOTAVAIL', 'EINVAL'].includes(error.code)) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return options.port;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const isLockedPort = port => lockedPorts.old.has(port) || lockedPorts.young.has(port) || reservedPorts.has(port);
|
|
107
|
+
|
|
108
|
+
const portCheckSequence = function * (ports) {
|
|
109
|
+
if (ports) {
|
|
110
|
+
yield * ports;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
yield 0; // Fall back to 0 if anything else failed
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
async function getPorts(options) {
|
|
117
|
+
let ports;
|
|
118
|
+
let exclude = new Set();
|
|
119
|
+
|
|
120
|
+
if (options) {
|
|
121
|
+
if (options.port) {
|
|
122
|
+
ports = typeof options.port === 'number' ? [options.port] : options.port;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (options.exclude) {
|
|
126
|
+
const excludeIterable = options.exclude;
|
|
127
|
+
|
|
128
|
+
if (typeof excludeIterable[Symbol.iterator] !== 'function') {
|
|
129
|
+
throw new TypeError('The `exclude` option must be an iterable.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const element of excludeIterable) {
|
|
133
|
+
if (typeof element !== 'number') {
|
|
134
|
+
throw new TypeError('Each item in the `exclude` option must be a number corresponding to the port you want excluded.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!Number.isSafeInteger(element)) {
|
|
138
|
+
throw new TypeError(`Number ${element} in the exclude option is not a safe integer and can't be used`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exclude = new Set(excludeIterable);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const {reserve, ...netOptions} = options ?? {};
|
|
147
|
+
|
|
148
|
+
if (timeout === undefined) {
|
|
149
|
+
timeout = setTimeout(() => {
|
|
150
|
+
timeout = undefined;
|
|
151
|
+
|
|
152
|
+
lockedPorts.old = lockedPorts.young;
|
|
153
|
+
lockedPorts.young = new Set();
|
|
154
|
+
}, releaseOldLockedPortsIntervalMs);
|
|
155
|
+
|
|
156
|
+
// Does not exist in some environments (Electron, Jest jsdom env, browser, etc).
|
|
157
|
+
if (timeout.unref) {
|
|
158
|
+
timeout.unref();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const hosts = getLocalHosts();
|
|
163
|
+
|
|
164
|
+
for (const port of portCheckSequence(ports)) {
|
|
165
|
+
try {
|
|
166
|
+
if (exclude.has(port)) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let availablePort = await getAvailablePort({...netOptions, port}, hosts); // eslint-disable-line no-await-in-loop
|
|
171
|
+
while (isLockedPort(availablePort)) {
|
|
172
|
+
if (port !== 0) {
|
|
173
|
+
throw new Locked(port);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
availablePort = await getAvailablePort({...netOptions, port}, hosts); // eslint-disable-line no-await-in-loop
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (reserve) {
|
|
180
|
+
reservedPorts.add(availablePort);
|
|
181
|
+
} else {
|
|
182
|
+
lockedPorts.young.add(availablePort);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return availablePort;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!['EADDRINUSE', 'EACCES'].includes(error.code) && !(error instanceof Locked)) {
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
throw new Error('No available ports found');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const MANAGED_RUNTIME_PERMISSION_KEYS = new Set(['network.request']);
|
|
197
|
+
/**
|
|
198
|
+
* 判断 permission key 是否由 Runtime 权限快照管理。
|
|
199
|
+
*
|
|
200
|
+
* @param key 待判断的 permission key。
|
|
201
|
+
* @returns 该 key 需要读取 Runtime 权限快照时返回 `true`。
|
|
202
|
+
*/
|
|
203
|
+
function isManagedMiniProgramRuntimePermissionKey(key) {
|
|
204
|
+
return MANAGED_RUNTIME_PERMISSION_KEYS.has(key);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 解析服务端权限快照;格式不完整时整份快照失效并 fail closed。
|
|
208
|
+
*
|
|
209
|
+
* @param snapshot 待校验的服务端权限快照。
|
|
210
|
+
* @returns 解析状态和通过校验的受管权限。
|
|
211
|
+
*/
|
|
212
|
+
function parseMiniProgramRuntimePermissions(snapshot) {
|
|
213
|
+
if (!isRecord$2(snapshot) || snapshot.schema_version !== 1 || !Array.isArray(snapshot.entries)) {
|
|
214
|
+
return { valid: false, permissions: {} };
|
|
215
|
+
}
|
|
216
|
+
if (snapshot.revision !== undefined && (typeof snapshot.revision !== 'number' || !Number.isInteger(snapshot.revision) || snapshot.revision < 0)) {
|
|
217
|
+
return { valid: false, permissions: {} };
|
|
218
|
+
}
|
|
219
|
+
const seenKeys = new Set();
|
|
220
|
+
const permissions = {};
|
|
221
|
+
for (const rawEntry of snapshot.entries) {
|
|
222
|
+
if (!isRecord$2(rawEntry) || typeof rawEntry.key !== 'string' || !rawEntry.key.trim()) {
|
|
223
|
+
return { valid: false, permissions: {} };
|
|
224
|
+
}
|
|
225
|
+
const key = rawEntry.key.trim();
|
|
226
|
+
if (seenKeys.has(key) || (rawEntry.status !== 'enabled' && rawEntry.status !== 'disabled') || !isRecord$2(rawEntry.config)) {
|
|
227
|
+
return { valid: false, permissions: {} };
|
|
228
|
+
}
|
|
229
|
+
seenKeys.add(key);
|
|
230
|
+
if (!isManagedMiniProgramRuntimePermissionKey(key)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (key === 'network.request' && typeof rawEntry.config.useOfficialDomain !== 'boolean') {
|
|
234
|
+
return { valid: false, permissions: {} };
|
|
235
|
+
}
|
|
236
|
+
permissions[key] = {
|
|
237
|
+
key,
|
|
238
|
+
status: rawEntry.status,
|
|
239
|
+
config: { useOfficialDomain: rawEntry.config.useOfficialDomain },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { valid: true, permissions };
|
|
243
|
+
}
|
|
244
|
+
function isRecord$2(value) {
|
|
245
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Dev Shell 读取局域网 Dev Session endpoint 的 query 参数。 */
|
|
249
|
+
/** Mock Host 创建和读取 Dev Session 的固定路径。 */
|
|
250
|
+
const MINI_PROGRAM_DEV_CONTEXT_PATH = '/__hb_sdk__/dev-context';
|
|
251
|
+
/** Dev Session 当前使用的 schema 版本。 */
|
|
252
|
+
const MINI_PROGRAM_DEV_SESSION_SCHEMA_VERSION = 1;
|
|
253
|
+
|
|
254
|
+
const MOCK_HOST_JSON_BODY_LIMIT = 1024 * 1024;
|
|
255
|
+
function readJsonRequestBody(request) {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const chunks = [];
|
|
258
|
+
let size = 0;
|
|
259
|
+
request.on('data', (chunk) => {
|
|
260
|
+
const buffer = Buffer.from(chunk);
|
|
261
|
+
size += buffer.byteLength;
|
|
262
|
+
if (size > MOCK_HOST_JSON_BODY_LIMIT) {
|
|
263
|
+
reject(createMockHostError(413, 'REQUEST_ENTITY_TOO_LARGE', 'mock network proxy request body too large'));
|
|
264
|
+
request.destroy();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
chunks.push(buffer);
|
|
268
|
+
});
|
|
269
|
+
request.on('error', reject);
|
|
270
|
+
request.on('aborted', () => {
|
|
271
|
+
reject('mock network proxy request aborted');
|
|
272
|
+
});
|
|
273
|
+
request.on('end', () => {
|
|
274
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
275
|
+
if (!rawBody.trim()) {
|
|
276
|
+
resolve({});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
resolve(JSON.parse(rawBody));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
reject(createMockHostError(400, 'INVALID_JSON', 'mock network proxy received invalid JSON'));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function writeJsonResponse(response, status, body) {
|
|
289
|
+
response.writeHead(status, {
|
|
290
|
+
'content-type': 'application/json; charset=utf-8',
|
|
291
|
+
'cache-control': 'no-store',
|
|
292
|
+
});
|
|
293
|
+
response.end(body === undefined ? '' : JSON.stringify(body));
|
|
294
|
+
}
|
|
295
|
+
function writeTokenNetworkJsonResponse(response, status, body) {
|
|
296
|
+
response.writeHead(status, {
|
|
297
|
+
'access-control-allow-headers': 'content-type',
|
|
298
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
299
|
+
'access-control-allow-origin': '*',
|
|
300
|
+
'access-control-allow-private-network': 'true',
|
|
301
|
+
'cache-control': 'no-store',
|
|
302
|
+
'content-type': 'application/json; charset=utf-8',
|
|
303
|
+
});
|
|
304
|
+
response.end(body === undefined ? '' : JSON.stringify(body));
|
|
305
|
+
}
|
|
306
|
+
function writeSerializedJsonResponse(response, status, body) {
|
|
307
|
+
response.writeHead(status, {
|
|
308
|
+
'content-type': 'application/json; charset=utf-8',
|
|
309
|
+
'cache-control': 'no-store',
|
|
310
|
+
});
|
|
311
|
+
response.end(body);
|
|
312
|
+
}
|
|
313
|
+
function isRecord$1(value) {
|
|
314
|
+
return Object.prototype.toString.call(value) === '[object Object]';
|
|
315
|
+
}
|
|
316
|
+
function isObjectLike(value) {
|
|
317
|
+
return (typeof value === 'object' || typeof value === 'function') && value !== null;
|
|
318
|
+
}
|
|
319
|
+
function createMockHostError(status, code, message) {
|
|
320
|
+
const error = new Error(message);
|
|
321
|
+
error.code = code;
|
|
322
|
+
error.status = status;
|
|
323
|
+
return error;
|
|
324
|
+
}
|
|
325
|
+
function readMockHostErrorStatus(error) {
|
|
326
|
+
return isObjectLike(error) && typeof error.status === 'number' ? error.status : 500;
|
|
327
|
+
}
|
|
328
|
+
function toMockHostErrorPayload(error) {
|
|
329
|
+
if (isObjectLike(error) && typeof error.code === 'string' && typeof error.message === 'string') {
|
|
330
|
+
return {
|
|
331
|
+
code: error.code,
|
|
332
|
+
message: error.message,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
code: 'MOCK_NETWORK_ERROR',
|
|
337
|
+
message: readErrorMessage(error),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function readErrorMessage(error) {
|
|
341
|
+
return error instanceof Error ? error.message : String(error);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const MAX_MOCK_NETWORK_REDIRECTS = 5;
|
|
345
|
+
const MAX_MOCK_NETWORK_REQUEST_BODY = 2 * 1024 * 1024;
|
|
346
|
+
const MAX_MOCK_NETWORK_RESPONSE_BODY = 5 * 1024 * 1024;
|
|
347
|
+
async function handleMockNetworkProxy(request, response, fetchImpl, lookupHostname, cors = false) {
|
|
348
|
+
if (request.method === 'OPTIONS') {
|
|
349
|
+
writeNetworkJsonResponse(response, 204, undefined, cors);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (request.method !== 'POST') {
|
|
353
|
+
writeNetworkJsonResponse(response, 405, {
|
|
354
|
+
code: 'METHOD_NOT_ALLOWED',
|
|
355
|
+
message: 'mock network proxy only supports POST',
|
|
356
|
+
}, cors);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const payload = await readJsonRequestBody(request);
|
|
361
|
+
const result = await requestMockNetwork(payload, fetchImpl, lookupHostname);
|
|
362
|
+
writeNetworkJsonResponse(response, 200, result, cors);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
writeNetworkJsonResponse(response, readMockHostErrorStatus(error), toMockHostErrorPayload(error), cors);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function writeNetworkJsonResponse(response, status, body, cors) {
|
|
369
|
+
if (cors) {
|
|
370
|
+
writeTokenNetworkJsonResponse(response, status, body);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
writeJsonResponse(response, status, body);
|
|
374
|
+
}
|
|
375
|
+
async function requestMockNetwork(payload, fetchImpl, lookupHostname) {
|
|
376
|
+
if (!isRecord$1(payload) || typeof payload.url !== 'string' || !payload.url.trim()) {
|
|
377
|
+
throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是非空字符串');
|
|
378
|
+
}
|
|
379
|
+
const url = createMockNetworkUrl(payload.url, payload.params);
|
|
380
|
+
const method = typeof payload.method === 'string' && payload.method.trim() ? payload.method.trim().toUpperCase() : 'GET';
|
|
381
|
+
const headers = normalizeMockNetworkHeaders(payload.headers);
|
|
382
|
+
const body = createMockNetworkBody(method, payload.data, headers);
|
|
383
|
+
const controller = new AbortController();
|
|
384
|
+
const timeout = Number(payload.timeout) > 0 ? Number(payload.timeout) : 10000;
|
|
385
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
386
|
+
const pinnedTargets = new Map();
|
|
387
|
+
const dispatcher = createPinnedNetworkDispatcher(pinnedTargets);
|
|
388
|
+
try {
|
|
389
|
+
const response = await fetchMockNetworkWithRedirects({
|
|
390
|
+
body,
|
|
391
|
+
dispatcher,
|
|
392
|
+
fetchImpl,
|
|
393
|
+
headers,
|
|
394
|
+
lookupHostname,
|
|
395
|
+
method,
|
|
396
|
+
pinnedTargets,
|
|
397
|
+
signal: controller.signal,
|
|
398
|
+
url,
|
|
399
|
+
});
|
|
400
|
+
const contentType = response.headers.get('content-type') || '';
|
|
401
|
+
const text = await readLimitedMockNetworkResponse(response);
|
|
402
|
+
return {
|
|
403
|
+
data: contentType.includes('application/json') ? safeJsonParse(text) : text,
|
|
404
|
+
status: response.status,
|
|
405
|
+
statusText: response.statusText,
|
|
406
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (isObjectLike(error) && typeof error.status === 'number') {
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
throw createMockHostError(502, 'MOCK_NETWORK_REQUEST_FAILED', error instanceof Error && error.name === 'AbortError' ? `network.request timeout after ${timeout}ms` : readErrorMessage(error));
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
await dispatcher.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function fetchMockNetworkWithRedirects(options) {
|
|
421
|
+
let url = options.url;
|
|
422
|
+
let method = options.method;
|
|
423
|
+
let body = options.body;
|
|
424
|
+
for (let redirectCount = 0; redirectCount <= MAX_MOCK_NETWORK_REDIRECTS; redirectCount += 1) {
|
|
425
|
+
const pinnedTarget = await resolvePublicMockNetworkTarget(url, options.lookupHostname);
|
|
426
|
+
options.pinnedTargets.set(createPinnedNetworkTargetKey(url), pinnedTarget);
|
|
427
|
+
const response = await options.fetchImpl(url.toString(), {
|
|
428
|
+
body,
|
|
429
|
+
dispatcher: options.dispatcher,
|
|
430
|
+
headers: options.headers,
|
|
431
|
+
method,
|
|
432
|
+
redirect: 'manual',
|
|
433
|
+
signal: options.signal,
|
|
434
|
+
});
|
|
435
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
|
436
|
+
return response;
|
|
437
|
+
}
|
|
438
|
+
const location = response.headers.get('location');
|
|
439
|
+
if (!location) {
|
|
440
|
+
return response;
|
|
441
|
+
}
|
|
442
|
+
if (redirectCount === MAX_MOCK_NETWORK_REDIRECTS) {
|
|
443
|
+
throw createMockHostError(502, 'TOO_MANY_REDIRECTS', 'network.request redirect limit exceeded');
|
|
444
|
+
}
|
|
445
|
+
await response.body?.cancel();
|
|
446
|
+
url = new URL(location, url);
|
|
447
|
+
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
|
|
448
|
+
method = 'GET';
|
|
449
|
+
body = undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
throw createMockHostError(502, 'TOO_MANY_REDIRECTS', 'network.request redirect limit exceeded');
|
|
453
|
+
}
|
|
454
|
+
async function resolvePublicMockNetworkTarget(url, lookupHostname) {
|
|
455
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
456
|
+
throw createMockHostError(403, 'NETWORK_TARGET_DENIED', 'network.request redirect 仅支持 HTTP(S) URL');
|
|
457
|
+
}
|
|
458
|
+
if (url.username || url.password) {
|
|
459
|
+
throw createMockHostError(403, 'NETWORK_TARGET_DENIED', 'network.request URL 不允许包含凭据');
|
|
460
|
+
}
|
|
461
|
+
const addressFamily = net.isIP(url.hostname);
|
|
462
|
+
const addresses = addressFamily ? [{ address: url.hostname, family: addressFamily }] : await lookupHostname(url.hostname);
|
|
463
|
+
if (!addresses.length || addresses.some(({ address, family }) => !isPublicIpAddress(address, family))) {
|
|
464
|
+
throw createMockHostError(403, 'NETWORK_TARGET_DENIED', 'network.request 不允许指向本机、内网或保留地址');
|
|
465
|
+
}
|
|
466
|
+
return addresses[0];
|
|
467
|
+
}
|
|
468
|
+
function createPinnedNetworkDispatcher(pinnedTargets) {
|
|
469
|
+
const connect = undici.buildConnector({});
|
|
470
|
+
return new undici.Agent({
|
|
471
|
+
connect(options, callback) {
|
|
472
|
+
const hostname = normalizeNetworkHostname(options.hostname);
|
|
473
|
+
const target = pinnedTargets.get(`${options.protocol}//${hostname}:${options.port || (options.protocol === 'https:' ? '443' : '80')}`);
|
|
474
|
+
if (!target) {
|
|
475
|
+
callback(new Error('network.request target was not DNS-pinned'), null);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
connect({
|
|
479
|
+
...options,
|
|
480
|
+
hostname: target.address,
|
|
481
|
+
servername: options.servername || hostname,
|
|
482
|
+
}, callback);
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
function createPinnedNetworkTargetKey(url) {
|
|
487
|
+
return `${url.protocol}//${normalizeNetworkHostname(url.hostname)}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`;
|
|
488
|
+
}
|
|
489
|
+
function normalizeNetworkHostname(hostname) {
|
|
490
|
+
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
491
|
+
}
|
|
492
|
+
const blockedNetworkTargets = createBlockedNetworkTargets();
|
|
493
|
+
const publicIpv6Targets = createPublicIpv6Targets();
|
|
494
|
+
function createBlockedNetworkTargets() {
|
|
495
|
+
const blockList = new net.BlockList();
|
|
496
|
+
for (const [network, prefix] of [
|
|
497
|
+
['0.0.0.0', 8],
|
|
498
|
+
['10.0.0.0', 8],
|
|
499
|
+
['100.64.0.0', 10],
|
|
500
|
+
['127.0.0.0', 8],
|
|
501
|
+
['169.254.0.0', 16],
|
|
502
|
+
['172.16.0.0', 12],
|
|
503
|
+
['192.0.0.0', 24],
|
|
504
|
+
['192.0.2.0', 24],
|
|
505
|
+
['192.168.0.0', 16],
|
|
506
|
+
['198.18.0.0', 15],
|
|
507
|
+
['198.51.100.0', 24],
|
|
508
|
+
['203.0.113.0', 24],
|
|
509
|
+
['224.0.0.0', 4],
|
|
510
|
+
['240.0.0.0', 4],
|
|
511
|
+
]) {
|
|
512
|
+
blockList.addSubnet(network, prefix, 'ipv4');
|
|
513
|
+
}
|
|
514
|
+
for (const [network, prefix] of [
|
|
515
|
+
['::', 128],
|
|
516
|
+
['::1', 128],
|
|
517
|
+
['fc00::', 7],
|
|
518
|
+
['fe80::', 10],
|
|
519
|
+
['ff00::', 8],
|
|
520
|
+
['2001:db8::', 32],
|
|
521
|
+
['2001::', 23],
|
|
522
|
+
['2002::', 16],
|
|
523
|
+
['3ffe::', 16],
|
|
524
|
+
]) {
|
|
525
|
+
blockList.addSubnet(network, prefix, 'ipv6');
|
|
526
|
+
}
|
|
527
|
+
return blockList;
|
|
528
|
+
}
|
|
529
|
+
function createPublicIpv6Targets() {
|
|
530
|
+
const blockList = new net.BlockList();
|
|
531
|
+
blockList.addSubnet('2000::', 3, 'ipv6');
|
|
532
|
+
return blockList;
|
|
533
|
+
}
|
|
534
|
+
function isPublicIpAddress(address, family) {
|
|
535
|
+
if (family === 4 || net.isIP(address) === 4) {
|
|
536
|
+
return !blockedNetworkTargets.check(address, 'ipv4');
|
|
537
|
+
}
|
|
538
|
+
if (family === 6 || net.isIP(address) === 6) {
|
|
539
|
+
if (address.toLowerCase().startsWith('::ffff:')) {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
return publicIpv6Targets.check(address, 'ipv6') && !blockedNetworkTargets.check(address, 'ipv6');
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
async function readLimitedMockNetworkResponse(response) {
|
|
547
|
+
if (!response.body) {
|
|
548
|
+
return '';
|
|
549
|
+
}
|
|
550
|
+
const reader = response.body.getReader();
|
|
551
|
+
const chunks = [];
|
|
552
|
+
let size = 0;
|
|
553
|
+
while (true) {
|
|
554
|
+
const { done, value } = await reader.read();
|
|
555
|
+
if (done) {
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
size += value.byteLength;
|
|
559
|
+
if (size > MAX_MOCK_NETWORK_RESPONSE_BODY) {
|
|
560
|
+
await reader.cancel();
|
|
561
|
+
throw createMockHostError(502, 'NETWORK_RESPONSE_TOO_LARGE', 'network.request response body too large');
|
|
562
|
+
}
|
|
563
|
+
chunks.push(value);
|
|
564
|
+
}
|
|
565
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
|
|
566
|
+
}
|
|
567
|
+
function createMockNetworkUrl(input, params) {
|
|
568
|
+
let url;
|
|
569
|
+
try {
|
|
570
|
+
url = new URL(input);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是合法 URL');
|
|
574
|
+
}
|
|
575
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
576
|
+
throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request 仅支持 HTTP(S) URL');
|
|
577
|
+
}
|
|
578
|
+
if (isRecord$1(params)) {
|
|
579
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
580
|
+
if (value !== undefined && value !== null) {
|
|
581
|
+
url.searchParams.set(key, String(value));
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return url;
|
|
586
|
+
}
|
|
587
|
+
function normalizeMockNetworkHeaders(headers) {
|
|
588
|
+
const normalized = new Headers();
|
|
589
|
+
if (!isRecord$1(headers)) {
|
|
590
|
+
return normalized;
|
|
591
|
+
}
|
|
592
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
593
|
+
if (isSensitiveMockNetworkHeader(key)) {
|
|
594
|
+
throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', `network.request 不允许设置敏感请求头: ${key}`);
|
|
595
|
+
}
|
|
596
|
+
if (typeof value === 'string' && !isBlockedMockNetworkHeader(key)) {
|
|
597
|
+
normalized.set(key, value);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
return normalized;
|
|
601
|
+
}
|
|
602
|
+
function isSensitiveMockNetworkHeader(key) {
|
|
603
|
+
return ['authorization', 'cookie', 'proxy-authorization'].includes(key.toLowerCase());
|
|
604
|
+
}
|
|
605
|
+
function isBlockedMockNetworkHeader(key) {
|
|
606
|
+
return [
|
|
607
|
+
'connection',
|
|
608
|
+
'content-length',
|
|
609
|
+
'host',
|
|
610
|
+
'keep-alive',
|
|
611
|
+
'proxy-authenticate',
|
|
612
|
+
'proxy-authorization',
|
|
613
|
+
'te',
|
|
614
|
+
'trailer',
|
|
615
|
+
'transfer-encoding',
|
|
616
|
+
'upgrade',
|
|
617
|
+
].includes(key.toLowerCase());
|
|
618
|
+
}
|
|
619
|
+
function createMockNetworkBody(method, data, headers) {
|
|
620
|
+
if (data === undefined || method === 'GET' || method === 'HEAD') {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
const body = typeof data === 'string' ? data : JSON.stringify(data);
|
|
624
|
+
if (Buffer.byteLength(body) > MAX_MOCK_NETWORK_REQUEST_BODY) {
|
|
625
|
+
throw createMockHostError(413, 'REQUEST_ENTITY_TOO_LARGE', 'network.request data exceeds 2097152 bytes');
|
|
626
|
+
}
|
|
627
|
+
if (typeof data === 'string') {
|
|
628
|
+
return body;
|
|
629
|
+
}
|
|
630
|
+
if (!headers.has('content-type')) {
|
|
631
|
+
headers.set('content-type', 'application/json');
|
|
632
|
+
}
|
|
633
|
+
return body;
|
|
634
|
+
}
|
|
635
|
+
function safeJsonParse(text) {
|
|
636
|
+
try {
|
|
637
|
+
return JSON.parse(text);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return text;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const DEV_SESSION_TTL_MS = 5 * 60 * 1000;
|
|
645
|
+
const MAX_ACTIVE_DEV_SESSIONS = 64;
|
|
646
|
+
const MAX_EXPIRED_DEV_SESSION_TOMBSTONES = 128;
|
|
647
|
+
function createDevSessionRoutes(options) {
|
|
648
|
+
const devSessions = new Map();
|
|
649
|
+
const expiredDevSessions = new Set();
|
|
650
|
+
let revision = 0;
|
|
651
|
+
return {
|
|
652
|
+
match(pathname) {
|
|
653
|
+
const match = pathname.match(/^\/__hb_sdk__\/dev-context\/([a-f0-9]{64})(\/network)?$/);
|
|
654
|
+
if (!match) {
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
kind: match[2] ? 'network' : 'context',
|
|
659
|
+
token: match[1],
|
|
660
|
+
};
|
|
661
|
+
},
|
|
662
|
+
async create(request, response) {
|
|
663
|
+
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
664
|
+
writeJsonResponse(response, 400, { code: 'INVALID_DEV_SESSION', message: 'Dev Session body must be JSON' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const payload = await readJsonRequestBody(request);
|
|
669
|
+
const validated = validateDevSessionCreatePayload(payload, options.allowedMiniUrlOrigins);
|
|
670
|
+
reapExpiredDevSessions(devSessions, expiredDevSessions, options.now());
|
|
671
|
+
revokeDevSessionForOrigin(devSessions, validated.miniUrlOrigin);
|
|
672
|
+
if (devSessions.size >= MAX_ACTIVE_DEV_SESSIONS) {
|
|
673
|
+
throw createMockHostError(429, 'DEV_SESSION_LIMIT_REACHED', 'active Dev Session limit reached');
|
|
674
|
+
}
|
|
675
|
+
const expiresAt = options.now() + DEV_SESSION_TTL_MS;
|
|
676
|
+
const token = createDevSessionToken(devSessions);
|
|
677
|
+
const snapshot = {
|
|
678
|
+
schema_version: MINI_PROGRAM_DEV_SESSION_SCHEMA_VERSION,
|
|
679
|
+
revision: ++revision,
|
|
680
|
+
expires_at: expiresAt,
|
|
681
|
+
mini_url_origin: validated.miniUrlOrigin,
|
|
682
|
+
runtime_permissions: validated.runtimePermissions,
|
|
683
|
+
};
|
|
684
|
+
devSessions.set(token, {
|
|
685
|
+
expiresAt,
|
|
686
|
+
miniUrlOrigin: validated.miniUrlOrigin,
|
|
687
|
+
networkRequestEnabled: validated.networkRequestEnabled,
|
|
688
|
+
serialized: JSON.stringify(snapshot),
|
|
689
|
+
});
|
|
690
|
+
const result = { expires_at: expiresAt, token };
|
|
691
|
+
writeJsonResponse(response, 201, result);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
const status = readMockHostErrorStatus(error);
|
|
695
|
+
writeJsonResponse(response, status === 500 ? 400 : status, {
|
|
696
|
+
code: 'INVALID_DEV_SESSION',
|
|
697
|
+
message: readErrorMessage(error),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
async handle(route, request, response) {
|
|
702
|
+
if (route.kind === 'network') {
|
|
703
|
+
await handleTokenNetworkProxy(request, response, route.token, {
|
|
704
|
+
devSessions,
|
|
705
|
+
expiredDevSessions,
|
|
706
|
+
fetchImpl: options.fetchImpl,
|
|
707
|
+
lookupHostname: options.lookupHostname,
|
|
708
|
+
now: options.now,
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (request.method !== 'OPTIONS' && request.method !== 'GET') {
|
|
713
|
+
response.writeHead(405, { allow: 'GET', 'cache-control': 'no-store' });
|
|
714
|
+
response.end();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const resolved = resolveDevSession(route.token, devSessions, expiredDevSessions, options.now());
|
|
718
|
+
if (resolved.status !== 200) {
|
|
719
|
+
writeDevSessionCorsResponse(response, resolved.status);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
writeDevSessionCorsResponse(response, request.method === 'OPTIONS' ? 204 : 200, request.method === 'GET' ? resolved.session.serialized : undefined);
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function validateDevSessionCreatePayload(payload, allowedMiniUrlOrigins) {
|
|
727
|
+
if (!isRecord$1(payload) || Object.keys(payload).some((key) => !['mini_url_origin', 'runtime_permissions'].includes(key))) {
|
|
728
|
+
throw new TypeError('Dev Session body is invalid');
|
|
729
|
+
}
|
|
730
|
+
if (typeof payload.mini_url_origin !== 'string') {
|
|
731
|
+
throw new TypeError('mini_url_origin is invalid');
|
|
732
|
+
}
|
|
733
|
+
let miniUrlOrigin;
|
|
734
|
+
try {
|
|
735
|
+
miniUrlOrigin = new URL(payload.mini_url_origin);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
throw new TypeError('mini_url_origin is invalid');
|
|
739
|
+
}
|
|
740
|
+
if (miniUrlOrigin.protocol !== 'http:' ||
|
|
741
|
+
payload.mini_url_origin !== miniUrlOrigin.origin ||
|
|
742
|
+
!isPrivateLanIpv4(miniUrlOrigin.hostname) ||
|
|
743
|
+
!allowedMiniUrlOrigins.has(miniUrlOrigin.origin)) {
|
|
744
|
+
throw new TypeError('mini_url_origin must be an allowed LAN origin');
|
|
745
|
+
}
|
|
746
|
+
const parsedPermissions = parseMiniProgramRuntimePermissions(payload.runtime_permissions);
|
|
747
|
+
if (!parsedPermissions.valid) {
|
|
748
|
+
throw new TypeError('runtime_permissions is invalid');
|
|
749
|
+
}
|
|
750
|
+
const networkPermission = parsedPermissions.permissions['network.request'];
|
|
751
|
+
if (networkPermission?.config.useOfficialDomain !== false) {
|
|
752
|
+
throw new TypeError('Dev Session does not allow official domains');
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
miniUrlOrigin: miniUrlOrigin.origin,
|
|
756
|
+
networkRequestEnabled: networkPermission.status === 'enabled',
|
|
757
|
+
runtimePermissions: JSON.parse(JSON.stringify(payload.runtime_permissions)),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
async function handleTokenNetworkProxy(request, response, token, runtime) {
|
|
761
|
+
const resolved = resolveDevSession(token, runtime.devSessions, runtime.expiredDevSessions, runtime.now());
|
|
762
|
+
if (resolved.status !== 200) {
|
|
763
|
+
writeTokenNetworkJsonResponse(response, resolved.status, undefined);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (!resolved.session.networkRequestEnabled) {
|
|
767
|
+
writeTokenNetworkJsonResponse(response, 403, {
|
|
768
|
+
code: 'PERMISSION_DENIED',
|
|
769
|
+
message: 'network.request is disabled for this Dev Session',
|
|
770
|
+
});
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (request.method === 'OPTIONS') {
|
|
774
|
+
writeTokenNetworkJsonResponse(response, 204, undefined);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (request.method !== 'POST') {
|
|
778
|
+
writeTokenNetworkJsonResponse(response, 405, {
|
|
779
|
+
code: 'METHOD_NOT_ALLOWED',
|
|
780
|
+
message: 'Dev Session network proxy only supports POST',
|
|
781
|
+
});
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
await handleMockNetworkProxy(request, response, runtime.fetchImpl, runtime.lookupHostname, true);
|
|
785
|
+
}
|
|
786
|
+
function resolveDevSession(token, devSessions, expiredDevSessions, now) {
|
|
787
|
+
const session = devSessions.get(token);
|
|
788
|
+
if (!session) {
|
|
789
|
+
return { status: expiredDevSessions.has(token) ? 410 : 404 };
|
|
790
|
+
}
|
|
791
|
+
if (now >= session.expiresAt) {
|
|
792
|
+
devSessions.delete(token);
|
|
793
|
+
addExpiredDevSessionTombstone(expiredDevSessions, token);
|
|
794
|
+
return { status: 410 };
|
|
795
|
+
}
|
|
796
|
+
return { session, status: 200 };
|
|
797
|
+
}
|
|
798
|
+
function reapExpiredDevSessions(devSessions, expiredDevSessions, now) {
|
|
799
|
+
for (const [token, session] of devSessions) {
|
|
800
|
+
if (now >= session.expiresAt) {
|
|
801
|
+
devSessions.delete(token);
|
|
802
|
+
addExpiredDevSessionTombstone(expiredDevSessions, token);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function addExpiredDevSessionTombstone(expiredDevSessions, token) {
|
|
807
|
+
expiredDevSessions.add(token);
|
|
808
|
+
while (expiredDevSessions.size > MAX_EXPIRED_DEV_SESSION_TOMBSTONES) {
|
|
809
|
+
const oldest = expiredDevSessions.values().next().value;
|
|
810
|
+
if (!oldest) {
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
expiredDevSessions.delete(oldest);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function revokeDevSessionForOrigin(devSessions, miniUrlOrigin) {
|
|
817
|
+
for (const [token, session] of devSessions) {
|
|
818
|
+
if (session.miniUrlOrigin === miniUrlOrigin) {
|
|
819
|
+
devSessions.delete(token);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function createDevSessionToken(devSessions) {
|
|
824
|
+
let token;
|
|
825
|
+
do {
|
|
826
|
+
token = node_crypto.randomBytes(32).toString('hex');
|
|
827
|
+
} while (devSessions.has(token));
|
|
828
|
+
return token;
|
|
829
|
+
}
|
|
830
|
+
function isPrivateLanIpv4(hostname) {
|
|
831
|
+
const octets = hostname.split('.').map(Number);
|
|
832
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
|
836
|
+
}
|
|
837
|
+
function writeDevSessionCorsResponse(response, status, serializedBody) {
|
|
838
|
+
response.writeHead(status, {
|
|
839
|
+
'access-control-allow-methods': 'GET',
|
|
840
|
+
'access-control-allow-origin': '*',
|
|
841
|
+
'access-control-allow-private-network': 'true',
|
|
842
|
+
'cache-control': 'no-store',
|
|
843
|
+
...(serializedBody === undefined ? {} : { 'content-type': 'application/json; charset=utf-8' }),
|
|
844
|
+
});
|
|
845
|
+
response.end(serializedBody ?? '');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function selectAvailablePort(options) {
|
|
849
|
+
const maxAttempts = options.maxAttempts ?? 20;
|
|
850
|
+
const port = await options.getPort({
|
|
851
|
+
host: options.host,
|
|
852
|
+
port: createPortCandidates(options.startPort, maxAttempts),
|
|
853
|
+
});
|
|
854
|
+
if (port < options.startPort || port >= options.startPort + maxAttempts) {
|
|
855
|
+
throw new Error(`无法找到可用 ${options.label} 端口,起始端口: ${options.startPort}`);
|
|
856
|
+
}
|
|
857
|
+
return port;
|
|
858
|
+
}
|
|
859
|
+
function createPortCandidates(startPort, count) {
|
|
860
|
+
return Array.from({ length: count }, (_, index) => startPort + index);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const MINI_PROGRAM_URL_QUERY_PARAM$1 = 'mini_url';
|
|
864
|
+
const MOCK_HOST_BOOTSTRAP_PATH = '/__hb_sdk_bootstrap__';
|
|
865
|
+
const MOCK_NETWORK_PROXY_PATH = '/__hb_sdk_mock_network__';
|
|
866
|
+
const DEV_PERMISSIONS_PATH = '/__hb_sdk__/dev-permissions';
|
|
867
|
+
const DEV_LISTEN_HOST$1 = '0.0.0.0';
|
|
868
|
+
const LOCAL_DEV_URL_HOST$1 = '127.0.0.1';
|
|
869
|
+
const MOCK_HOST_ROOT_CANDIDATES = [
|
|
870
|
+
path.resolve(__dirname, 'devtools/mock-host'),
|
|
871
|
+
path.resolve(__dirname, '../devtools/mock-host'),
|
|
872
|
+
path.resolve(__dirname, '../../devtools/mock-host'),
|
|
873
|
+
path.resolve(__dirname, '../../../devtools/mock-host'),
|
|
874
|
+
];
|
|
875
|
+
async function startMiniProgramMockHostServer(options) {
|
|
876
|
+
const root = options.root ?? resolveMiniProgramMockHostRoot(options.rootCandidates);
|
|
877
|
+
assertCompleteMiniProgramMockHostRoot(root);
|
|
878
|
+
const bootstrapJson = serializeMockHostBootstrap({
|
|
879
|
+
defaultLanAddressId: options.defaultLanAddressId,
|
|
880
|
+
devContext: options.devContext,
|
|
881
|
+
lanAddresses: options.lanAddresses ?? [],
|
|
882
|
+
macAppProtocol: options.macAppProtocol,
|
|
883
|
+
nativeAppLaunchUnavailableReason: options.nativeAppLaunchUnavailableReason,
|
|
884
|
+
runtimePermissions: options.runtimePermissions,
|
|
885
|
+
});
|
|
886
|
+
const server = createMiniProgramMockHostServer(root, {
|
|
887
|
+
appUrl: options.appUrl,
|
|
888
|
+
allowedMiniUrlOrigins: new Set((options.lanAddresses ?? []).map(({ appUrl }) => new URL(appUrl).origin)),
|
|
889
|
+
bootstrapJson,
|
|
890
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
891
|
+
isLoopbackPeer: options.isLoopbackPeer ?? isLoopbackAddress,
|
|
892
|
+
lookupHostname: options.lookupHostname ?? (async (hostname) => promises.lookup(hostname, { all: true, verbatim: true })),
|
|
893
|
+
now: options.now ?? Date.now,
|
|
894
|
+
permissionController: options.permissionController,
|
|
895
|
+
});
|
|
896
|
+
const port = await listenHttpServer(server, {
|
|
897
|
+
port: options.port,
|
|
898
|
+
}, options.getPort ?? getPorts);
|
|
899
|
+
return {
|
|
900
|
+
close: () => new Promise((resolve) => {
|
|
901
|
+
server.close(() => resolve());
|
|
902
|
+
}),
|
|
903
|
+
networkUrls: createMiniProgramMockHostNetworkUrls({
|
|
904
|
+
lanAddresses: options.lanAddresses,
|
|
905
|
+
port,
|
|
906
|
+
}),
|
|
907
|
+
port,
|
|
908
|
+
root,
|
|
909
|
+
server,
|
|
910
|
+
url: createMiniProgramMockHostUrlWithHost({
|
|
911
|
+
appUrl: options.appUrl,
|
|
912
|
+
host: LOCAL_DEV_URL_HOST$1,
|
|
913
|
+
port,
|
|
914
|
+
}),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function resolveMiniProgramMockHostRoot(candidates = MOCK_HOST_ROOT_CANDIDATES) {
|
|
918
|
+
const found = candidates.find(isCompleteMiniProgramMockHostRoot);
|
|
919
|
+
if (!found) {
|
|
920
|
+
throw createMissingMockHostError();
|
|
921
|
+
}
|
|
922
|
+
return found;
|
|
923
|
+
}
|
|
924
|
+
function assertCompleteMiniProgramMockHostRoot(root) {
|
|
925
|
+
if (!isCompleteMiniProgramMockHostRoot(root)) {
|
|
926
|
+
throw createMissingMockHostError();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function isCompleteMiniProgramMockHostRoot(root) {
|
|
930
|
+
return fs.existsSync(path.join(root, 'index.html')) && fs.existsSync(path.join(root, 'main.js'));
|
|
931
|
+
}
|
|
932
|
+
function createMissingMockHostError() {
|
|
933
|
+
return new Error('未找到完整的 hb-sdk mock host 静态产物。请先执行 @heybox/hb-sdk 的 build:mock-host。');
|
|
934
|
+
}
|
|
935
|
+
function createMiniProgramMockHostNetworkUrls(options) {
|
|
936
|
+
return (options.lanAddresses ?? []).map((address) => createMiniProgramMockHostUrlWithHost({
|
|
937
|
+
appUrl: address.appUrl,
|
|
938
|
+
host: address.address,
|
|
939
|
+
port: options.port,
|
|
940
|
+
}));
|
|
941
|
+
}
|
|
942
|
+
function createMiniProgramMockHostUrlWithHost(options) {
|
|
943
|
+
const url = new URL(`http://${options.host}:${options.port}/`);
|
|
944
|
+
url.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM$1, options.appUrl);
|
|
945
|
+
return url.toString();
|
|
946
|
+
}
|
|
947
|
+
function serializeMockHostBootstrap(bootstrap) {
|
|
948
|
+
const serialized = JSON.stringify(bootstrap);
|
|
949
|
+
if (serialized === undefined) {
|
|
950
|
+
throw new TypeError('Mock host bootstrap must be JSON serializable');
|
|
951
|
+
}
|
|
952
|
+
const serializedBootstrap = JSON.parse(serialized);
|
|
953
|
+
if (bootstrap.runtimePermissions !== undefined && !Object.prototype.hasOwnProperty.call(serializedBootstrap, 'runtimePermissions')) {
|
|
954
|
+
throw new TypeError('Runtime permission snapshot must be JSON serializable');
|
|
955
|
+
}
|
|
956
|
+
return serialized;
|
|
957
|
+
}
|
|
958
|
+
function createMiniProgramMockHostServer(root, runtime) {
|
|
959
|
+
const devSessionRoutes = createDevSessionRoutes({
|
|
960
|
+
allowedMiniUrlOrigins: runtime.allowedMiniUrlOrigins,
|
|
961
|
+
fetchImpl: runtime.fetchImpl,
|
|
962
|
+
lookupHostname: runtime.lookupHostname,
|
|
963
|
+
now: runtime.now,
|
|
964
|
+
});
|
|
965
|
+
return node_http.createServer(async (request, response) => {
|
|
966
|
+
const requestUrl = new URL(request.url, 'http://localhost');
|
|
967
|
+
const pathname = decodeURIComponent(requestUrl.pathname);
|
|
968
|
+
const devSessionRoute = devSessionRoutes.match(pathname);
|
|
969
|
+
const loopbackPeer = runtime.isLoopbackPeer(request.socket.remoteAddress);
|
|
970
|
+
if (!loopbackPeer && !devSessionRoute) {
|
|
971
|
+
writeForbidden(response);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (isMockHostDocumentPath(pathname) && !requestUrl.searchParams.has(MINI_PROGRAM_URL_QUERY_PARAM$1)) {
|
|
975
|
+
const targetUrl = new URL(requestUrl);
|
|
976
|
+
targetUrl.pathname = '/';
|
|
977
|
+
targetUrl.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM$1, runtime.appUrl);
|
|
978
|
+
response.writeHead(302, {
|
|
979
|
+
location: `${targetUrl.pathname}${targetUrl.search}`,
|
|
980
|
+
'cache-control': 'no-store',
|
|
981
|
+
});
|
|
982
|
+
response.end();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (pathname === MOCK_HOST_BOOTSTRAP_PATH) {
|
|
986
|
+
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
987
|
+
writeForbidden(response);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (request.method !== 'GET') {
|
|
991
|
+
writeMethodNotAllowed(response, 'GET');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
writeSerializedJsonResponse(response, 200, runtime.bootstrapJson);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (pathname === DEV_PERMISSIONS_PATH && runtime.permissionController) {
|
|
998
|
+
if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
|
|
999
|
+
writeForbidden(response);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (request.method === 'GET') {
|
|
1003
|
+
writeJsonResponse(response, 200, await runtime.permissionController.get());
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if ((request.method !== 'PUT' && request.method !== 'DELETE') || !hasValidMutationOrigin(request)) {
|
|
1007
|
+
if (request.method !== 'PUT' && request.method !== 'DELETE') {
|
|
1008
|
+
writeMethodNotAllowed(response, 'GET, PUT, DELETE');
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
writeForbidden(response);
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
await handleDevPermissionMutation(request, response, runtime.permissionController);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (pathname === MINI_PROGRAM_DEV_CONTEXT_PATH) {
|
|
1019
|
+
if (!loopbackPeer || !hasNumericLoopbackHost(request) || !hasValidMutationOrigin(request)) {
|
|
1020
|
+
writeForbidden(response);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (request.method !== 'POST') {
|
|
1024
|
+
writeMethodNotAllowed(response, 'POST');
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
await devSessionRoutes.create(request, response);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (devSessionRoute) {
|
|
1031
|
+
await devSessionRoutes.handle(devSessionRoute, request, response);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (pathname === MOCK_NETWORK_PROXY_PATH) {
|
|
1035
|
+
if (!loopbackPeer || !hasNumericLoopbackHost(request) || !hasValidMutationOrigin(request)) {
|
|
1036
|
+
writeForbidden(response);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
await handleMockNetworkProxy(request, response, runtime.fetchImpl, runtime.lookupHostname);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const targetPath = resolveStaticPath(root, pathname);
|
|
1043
|
+
if (!targetPath) {
|
|
1044
|
+
response.writeHead(404);
|
|
1045
|
+
response.end('Not found');
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
const stat = fs.statSync(targetPath);
|
|
1050
|
+
if (stat.isDirectory()) {
|
|
1051
|
+
response.writeHead(404);
|
|
1052
|
+
response.end('Not found');
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
response.writeHead(200, {
|
|
1056
|
+
'content-type': readContentType(targetPath),
|
|
1057
|
+
'cache-control': 'no-store',
|
|
1058
|
+
});
|
|
1059
|
+
fs.createReadStream(targetPath).pipe(response);
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
response.writeHead(404);
|
|
1063
|
+
response.end('Not found');
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
async function handleDevPermissionMutation(request, response, controller) {
|
|
1068
|
+
try {
|
|
1069
|
+
if (request.method === 'DELETE') {
|
|
1070
|
+
if (hasRequestBody(request)) {
|
|
1071
|
+
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
1072
|
+
throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions body must be JSON');
|
|
1073
|
+
}
|
|
1074
|
+
const payload = await readJsonRequestBody(request);
|
|
1075
|
+
if (!isRecord$1(payload) || Object.keys(payload).length > 0) {
|
|
1076
|
+
throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions reset body is invalid');
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
writeJsonResponse(response, 200, await controller.reset());
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
1083
|
+
throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions body must be JSON');
|
|
1084
|
+
}
|
|
1085
|
+
const payload = await readJsonRequestBody(request);
|
|
1086
|
+
if (!isRecord$1(payload) || Object.keys(payload).length !== 1 || !Object.hasOwn(payload, 'runtime_permissions')) {
|
|
1087
|
+
throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions update body is invalid');
|
|
1088
|
+
}
|
|
1089
|
+
const parsed = parseMiniProgramRuntimePermissions(payload.runtime_permissions);
|
|
1090
|
+
if (!parsed.valid) {
|
|
1091
|
+
throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'runtime_permissions is invalid');
|
|
1092
|
+
}
|
|
1093
|
+
const runtimePermissions = JSON.parse(JSON.stringify(payload.runtime_permissions));
|
|
1094
|
+
writeJsonResponse(response, 200, await controller.save(runtimePermissions));
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
const errorStatus = readMockHostErrorStatus(error);
|
|
1098
|
+
const status = request.method === 'DELETE' && errorStatus === 500 ? 502 : errorStatus;
|
|
1099
|
+
writeJsonResponse(response, status, {
|
|
1100
|
+
code: request.method === 'DELETE' ? 'DEV_PERMISSIONS_RESET_FAILED' : 'DEV_PERMISSIONS_UPDATE_FAILED',
|
|
1101
|
+
message: readErrorMessage(error),
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
function hasRequestBody(request) {
|
|
1106
|
+
return request.headers['transfer-encoding'] !== undefined || Number(request.headers['content-length'] ?? 0) > 0;
|
|
1107
|
+
}
|
|
1108
|
+
function writeMethodNotAllowed(response, allowedMethod) {
|
|
1109
|
+
response.writeHead(405, {
|
|
1110
|
+
allow: allowedMethod,
|
|
1111
|
+
'cache-control': 'no-store',
|
|
1112
|
+
});
|
|
1113
|
+
response.end();
|
|
1114
|
+
}
|
|
1115
|
+
function writeForbidden(response) {
|
|
1116
|
+
response.writeHead(403, { 'cache-control': 'no-store' });
|
|
1117
|
+
response.end();
|
|
1118
|
+
}
|
|
1119
|
+
function hasValidMutationOrigin(request) {
|
|
1120
|
+
const origin = request.headers.origin;
|
|
1121
|
+
const host = request.headers.host;
|
|
1122
|
+
if (!host || !origin || origin === 'null') {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
try {
|
|
1126
|
+
return new URL(origin).origin === `http://${host}`;
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function hasNumericLoopbackHost(request) {
|
|
1133
|
+
const host = request.headers.host;
|
|
1134
|
+
if (!host) {
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
try {
|
|
1138
|
+
const hostname = new URL(`http://${host}`).hostname;
|
|
1139
|
+
const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
1140
|
+
return net.isIP(normalized) !== 0 && isLoopbackAddress(normalized);
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
function isLoopbackAddress(address) {
|
|
1147
|
+
return address === '::1' || address === '127.0.0.1' || address?.startsWith('127.') === true || address?.startsWith('::ffff:127.') === true;
|
|
1148
|
+
}
|
|
1149
|
+
function isMockHostDocumentPath(pathname) {
|
|
1150
|
+
return pathname === '/' || pathname === '/index.html';
|
|
1151
|
+
}
|
|
1152
|
+
function resolveStaticPath(root, pathname) {
|
|
1153
|
+
const safePathname = pathname === '/' ? '/index.html' : pathname;
|
|
1154
|
+
const normalizedRoot = path.normalize(root);
|
|
1155
|
+
const targetPath = path.normalize(path.join(normalizedRoot, safePathname));
|
|
1156
|
+
if (targetPath !== normalizedRoot && !targetPath.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
return targetPath;
|
|
1160
|
+
}
|
|
1161
|
+
function readContentType(filePath) {
|
|
1162
|
+
if (filePath.endsWith('.html')) {
|
|
1163
|
+
return 'text/html; charset=utf-8';
|
|
1164
|
+
}
|
|
1165
|
+
if (filePath.endsWith('.js')) {
|
|
1166
|
+
return 'text/javascript; charset=utf-8';
|
|
1167
|
+
}
|
|
1168
|
+
if (filePath.endsWith('.css')) {
|
|
1169
|
+
return 'text/css; charset=utf-8';
|
|
1170
|
+
}
|
|
1171
|
+
if (filePath.endsWith('.json')) {
|
|
1172
|
+
return 'application/json; charset=utf-8';
|
|
1173
|
+
}
|
|
1174
|
+
if (filePath.endsWith('.svg')) {
|
|
1175
|
+
return 'image/svg+xml';
|
|
1176
|
+
}
|
|
1177
|
+
return 'application/octet-stream';
|
|
1178
|
+
}
|
|
1179
|
+
async function listenHttpServer(server, options, getPortImpl) {
|
|
1180
|
+
const port = await selectAvailablePort({
|
|
1181
|
+
getPort: getPortImpl,
|
|
1182
|
+
host: DEV_LISTEN_HOST$1,
|
|
1183
|
+
label: 'mock host',
|
|
1184
|
+
startPort: options.port,
|
|
1185
|
+
});
|
|
1186
|
+
await new Promise((resolve, reject) => {
|
|
1187
|
+
const onError = (error) => {
|
|
1188
|
+
server.off('listening', onListening);
|
|
1189
|
+
reject(error);
|
|
1190
|
+
};
|
|
1191
|
+
const onListening = () => {
|
|
1192
|
+
server.off('error', onError);
|
|
1193
|
+
resolve();
|
|
1194
|
+
};
|
|
1195
|
+
server.once('error', onError);
|
|
1196
|
+
server.once('listening', onListening);
|
|
1197
|
+
server.listen(port, DEV_LISTEN_HOST$1);
|
|
1198
|
+
});
|
|
1199
|
+
const address = server.address();
|
|
1200
|
+
return address.port;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const MINI_PROGRAM_URL_QUERY_PARAM = 'mini_url';
|
|
1204
|
+
const MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM = 'runtime_url';
|
|
1205
|
+
const MINI_PROGRAM_SDK_VERSION_QUERY_PARAM = 'sdk_version';
|
|
1206
|
+
const MINI_PROGRAM_DEV_SHELL_URL = 'heybox-mini-dev://sandbox';
|
|
1207
|
+
const OPEN_IN_APP_URL = 'https://api.xiaoheihe.cn/open_inapp/';
|
|
1208
|
+
function createMacAppProtocol(appUrl, options = {}) {
|
|
1209
|
+
return createHeyboxProtocol(createMiniProgramDevShellOpenWindowPayload(appUrl, options));
|
|
1210
|
+
}
|
|
1211
|
+
function createMobileAppQrPayload(appUrl, options = {}) {
|
|
1212
|
+
return `${OPEN_IN_APP_URL}#${createHeyboxProtocol(createMiniProgramDevShellOpenWindowPayload(appUrl, { ...options, encodeMiniUrl: false }))}`;
|
|
1213
|
+
}
|
|
1214
|
+
function createMiniProgramDevShellOpenWindowPayload(appUrl, options = {}) {
|
|
1215
|
+
const devShellUrl = options.encodeMiniUrl === false
|
|
1216
|
+
? createPartiallyEncodedMiniProgramDevShellUrl(appUrl, options)
|
|
1217
|
+
: createEncodedMiniProgramDevShellUrl(appUrl, options);
|
|
1218
|
+
const miniProgramId = options.miniProgramId?.trim();
|
|
1219
|
+
return {
|
|
1220
|
+
protocol_type: 'openWindow',
|
|
1221
|
+
full_screen: true,
|
|
1222
|
+
mini_program: '1',
|
|
1223
|
+
...(miniProgramId ? { mini_program_id: miniProgramId } : {}),
|
|
1224
|
+
navigation_bar: {
|
|
1225
|
+
title: '',
|
|
1226
|
+
},
|
|
1227
|
+
webview: {
|
|
1228
|
+
url: devShellUrl,
|
|
1229
|
+
pull: false,
|
|
1230
|
+
refresh: false,
|
|
1231
|
+
},
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
function createPartiallyEncodedMiniProgramDevShellUrl(appUrl, options) {
|
|
1235
|
+
let devShellUrl = `${MINI_PROGRAM_DEV_SHELL_URL}?${MINI_PROGRAM_URL_QUERY_PARAM}=${appUrl}`;
|
|
1236
|
+
const miniProgramId = options.miniProgramId?.trim();
|
|
1237
|
+
if (miniProgramId) {
|
|
1238
|
+
devShellUrl += `&mini_program_id=${encodeURIComponent(miniProgramId)}`;
|
|
1239
|
+
}
|
|
1240
|
+
if (hasRuntimeUrl(options.runtimeUrl)) {
|
|
1241
|
+
devShellUrl += `&${MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM}=${encodeURIComponent(options.runtimeUrl)}`;
|
|
1242
|
+
}
|
|
1243
|
+
const sdkVersion = options.sdkVersion?.trim();
|
|
1244
|
+
if (sdkVersion) {
|
|
1245
|
+
devShellUrl += `&${MINI_PROGRAM_SDK_VERSION_QUERY_PARAM}=${encodeURIComponent(sdkVersion)}`;
|
|
1246
|
+
}
|
|
1247
|
+
return devShellUrl;
|
|
1248
|
+
}
|
|
1249
|
+
function createEncodedMiniProgramDevShellUrl(appUrl, options) {
|
|
1250
|
+
const devShellUrl = new URL(MINI_PROGRAM_DEV_SHELL_URL);
|
|
1251
|
+
devShellUrl.searchParams.set(MINI_PROGRAM_URL_QUERY_PARAM, appUrl);
|
|
1252
|
+
const miniProgramId = options.miniProgramId?.trim();
|
|
1253
|
+
if (miniProgramId) {
|
|
1254
|
+
devShellUrl.searchParams.set('mini_program_id', miniProgramId);
|
|
1255
|
+
}
|
|
1256
|
+
if (hasRuntimeUrl(options.runtimeUrl)) {
|
|
1257
|
+
devShellUrl.searchParams.set(MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM, options.runtimeUrl);
|
|
1258
|
+
}
|
|
1259
|
+
const sdkVersion = options.sdkVersion?.trim();
|
|
1260
|
+
if (sdkVersion) {
|
|
1261
|
+
devShellUrl.searchParams.set(MINI_PROGRAM_SDK_VERSION_QUERY_PARAM, sdkVersion);
|
|
1262
|
+
}
|
|
1263
|
+
return devShellUrl.toString();
|
|
1264
|
+
}
|
|
1265
|
+
function hasRuntimeUrl(runtimeUrl) {
|
|
1266
|
+
return runtimeUrl !== undefined && runtimeUrl !== '';
|
|
1267
|
+
}
|
|
1268
|
+
function createHeyboxProtocol(payload) {
|
|
1269
|
+
return `heybox://${encodeURIComponent(JSON.stringify(payload))}`;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const DEFAULT_PERMISSION_STATE = {
|
|
1273
|
+
networkRequest: {
|
|
1274
|
+
status: 'disabled',
|
|
1275
|
+
useOfficialDomain: false,
|
|
1276
|
+
},
|
|
1277
|
+
};
|
|
1278
|
+
function createMiniProgramMockPermissionState(context) {
|
|
1279
|
+
const parsed = parseMiniProgramRuntimePermissions(context.runtimePermissions);
|
|
1280
|
+
const networkRequest = parsed.permissions['network.request'];
|
|
1281
|
+
return parsed.valid && networkRequest
|
|
1282
|
+
? {
|
|
1283
|
+
networkRequest: {
|
|
1284
|
+
status: networkRequest.status,
|
|
1285
|
+
useOfficialDomain: networkRequest.config.useOfficialDomain === true,
|
|
1286
|
+
},
|
|
1287
|
+
}
|
|
1288
|
+
: cloneDefaultPermissionState();
|
|
1289
|
+
}
|
|
1290
|
+
function createMiniProgramMockRuntimePermissions(state) {
|
|
1291
|
+
return {
|
|
1292
|
+
schema_version: 1,
|
|
1293
|
+
entries: [
|
|
1294
|
+
{
|
|
1295
|
+
key: 'network.request',
|
|
1296
|
+
status: state.networkRequest.status,
|
|
1297
|
+
config: {
|
|
1298
|
+
useOfficialDomain: state.networkRequest.useOfficialDomain,
|
|
1299
|
+
},
|
|
1300
|
+
},
|
|
1301
|
+
],
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
function cloneDefaultPermissionState() {
|
|
1305
|
+
return {
|
|
1306
|
+
networkRequest: { ...DEFAULT_PERMISSION_STATE.networkRequest },
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** 构建时替换为当前发布包的实际版本。 */
|
|
1311
|
+
const HB_SDK_VERSION = typeof undefined === 'string'
|
|
1312
|
+
? undefined
|
|
1313
|
+
: '0.6.6-alpha.0';
|
|
1314
|
+
|
|
1315
|
+
class DevPermissionOverrideStoreError extends Error {
|
|
1316
|
+
code;
|
|
1317
|
+
constructor(message, code, options = {}) {
|
|
1318
|
+
super(message, { cause: options.cause });
|
|
1319
|
+
this.code = code;
|
|
1320
|
+
this.name = 'DevPermissionOverrideStoreError';
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
const CACHE_VERSION = 1;
|
|
1324
|
+
const CACHE_DIRECTORY_NAME = 'dev-permission-overrides';
|
|
1325
|
+
const defaultPaths = index.envPaths('hb-sdk', { suffix: '' });
|
|
1326
|
+
function createDevPermissionOverrideStore(options = {}) {
|
|
1327
|
+
const cacheDirectory = path.join(options.cacheRoot ?? defaultPaths.cache, CACHE_DIRECTORY_NAME);
|
|
1328
|
+
return {
|
|
1329
|
+
async read(scope) {
|
|
1330
|
+
return readScopeFile(await getScopeFile(cacheDirectory, scope));
|
|
1331
|
+
},
|
|
1332
|
+
async remove(scope) {
|
|
1333
|
+
const scopeFile = await getScopeFile(cacheDirectory, scope);
|
|
1334
|
+
try {
|
|
1335
|
+
await fs$1.unlink(scopeFile);
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
throw createWriteError(error);
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
async set(scope, overrides) {
|
|
1345
|
+
const scopeFile = await getScopeFile(cacheDirectory, scope);
|
|
1346
|
+
try {
|
|
1347
|
+
await fs$1.mkdir(cacheDirectory, { recursive: true, mode: 0o700 });
|
|
1348
|
+
await fs$1.chmod(cacheDirectory, 0o700);
|
|
1349
|
+
await readScopeFile(scopeFile);
|
|
1350
|
+
await writeScopeFileAtomically(scopeFile, {
|
|
1351
|
+
version: CACHE_VERSION,
|
|
1352
|
+
overrides: cloneOverrides(overrides),
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
if (error instanceof DevPermissionOverrideStoreError) {
|
|
1357
|
+
throw error;
|
|
1358
|
+
}
|
|
1359
|
+
throw createWriteError(error);
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
async function getScopeFile(cacheDirectory, scope) {
|
|
1365
|
+
const projectRoot = await fs$1.realpath(scope.projectRoot);
|
|
1366
|
+
const miniProgramId = scope.miniProgramId ?? 'anonymous';
|
|
1367
|
+
const scopeHash = node_crypto.createHash('sha256').update(`${projectRoot}\0${miniProgramId}`).digest('hex');
|
|
1368
|
+
return path.join(cacheDirectory, `${scopeHash}.json`);
|
|
1369
|
+
}
|
|
1370
|
+
async function readScopeFile(scopeFile) {
|
|
1371
|
+
let contents;
|
|
1372
|
+
try {
|
|
1373
|
+
contents = await fs$1.readFile(scopeFile, 'utf8');
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
throw error;
|
|
1380
|
+
}
|
|
1381
|
+
try {
|
|
1382
|
+
const parsed = JSON.parse(contents);
|
|
1383
|
+
if (!isPersistedOverride(parsed)) {
|
|
1384
|
+
throw new TypeError('invalid cache schema');
|
|
1385
|
+
}
|
|
1386
|
+
return cloneOverrides(parsed.overrides);
|
|
1387
|
+
}
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
throw new DevPermissionOverrideStoreError('hb-sdk 本地权限覆盖缓存已损坏', 'DEV_PERMISSION_OVERRIDE_CACHE_CORRUPT', {
|
|
1390
|
+
cause: error,
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
async function writeScopeFileAtomically(scopeFile, value) {
|
|
1395
|
+
const tempFile = `${scopeFile}.${process.pid}.${node_crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
1396
|
+
let handle;
|
|
1397
|
+
try {
|
|
1398
|
+
handle = await fs$1.open(tempFile, 'wx', 0o600);
|
|
1399
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
1400
|
+
await handle.sync();
|
|
1401
|
+
await handle.close();
|
|
1402
|
+
handle = undefined;
|
|
1403
|
+
await fs$1.rename(tempFile, scopeFile);
|
|
1404
|
+
}
|
|
1405
|
+
finally {
|
|
1406
|
+
await handle?.close().catch(() => undefined);
|
|
1407
|
+
await fs$1.unlink(tempFile).catch(() => undefined);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
function createWriteError(cause) {
|
|
1411
|
+
return new DevPermissionOverrideStoreError('无法写入 hb-sdk 本地权限覆盖缓存', 'DEV_PERMISSION_OVERRIDE_WRITE_FAILED', { cause });
|
|
1412
|
+
}
|
|
1413
|
+
function cloneOverrides(overrides) {
|
|
1414
|
+
return {
|
|
1415
|
+
networkRequest: { ...overrides.networkRequest },
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
function isPersistedOverride(value) {
|
|
1419
|
+
return isRecord(value) && value.version === CACHE_VERSION && Object.keys(value).length === 2 && isPermissionState(value.overrides);
|
|
1420
|
+
}
|
|
1421
|
+
function isPermissionState(value) {
|
|
1422
|
+
return (isRecord(value) &&
|
|
1423
|
+
Object.keys(value).length === 1 &&
|
|
1424
|
+
isRecord(value.networkRequest) &&
|
|
1425
|
+
(value.networkRequest.status === 'enabled' || value.networkRequest.status === 'disabled') &&
|
|
1426
|
+
typeof value.networkRequest.useOfficialDomain === 'boolean');
|
|
1427
|
+
}
|
|
1428
|
+
function isRecord(value) {
|
|
1429
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1430
|
+
}
|
|
1431
|
+
function isNodeError(error) {
|
|
1432
|
+
return error instanceof Error && 'code' in error;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const DEFAULT_APP_PORT = 5173;
|
|
1436
|
+
const DEFAULT_MOCK_PORT = 5174;
|
|
1437
|
+
const DEFAULT_DEV_CONTEXT_TIMEOUT_MS = 3000;
|
|
1438
|
+
const DEV_LISTEN_HOST = '0.0.0.0';
|
|
1439
|
+
const LOCAL_DEV_URL_HOST = '127.0.0.1';
|
|
1440
|
+
const DEV_CONTEXT_UNAVAILABLE_WARNING = '远端 dev context 不可用,已继续匿名调试。';
|
|
1441
|
+
// Shared with Heybox H5 Vite plugins so hb-sdk can own the launch output.
|
|
1442
|
+
const MANAGED_DEV_OUTPUT_ENV = 'HEYBOX_DEV_SERVER_MANAGED_OUTPUT';
|
|
1443
|
+
const VITE_LOG_LEVEL = 'warn';
|
|
1444
|
+
async function runDevCommand(options, runtime = {}) {
|
|
1445
|
+
const logger = runtime.logger ?? index.createCliLogger();
|
|
1446
|
+
const fetchImpl = runtime.fetchImpl ?? fetch;
|
|
1447
|
+
const openUrl = runtime.openExternalUrl ?? browser.openExternalUrl;
|
|
1448
|
+
const networkInterfaceSnapshot = (runtime.networkInterfaces ?? os.networkInterfaces)();
|
|
1449
|
+
const getPortImpl = runtime.getPort ?? getPorts;
|
|
1450
|
+
const appPort = await selectAvailablePort({
|
|
1451
|
+
getPort: getPortImpl,
|
|
1452
|
+
host: DEV_LISTEN_HOST,
|
|
1453
|
+
label: 'Vite dev server',
|
|
1454
|
+
startPort: options.port ?? DEFAULT_APP_PORT,
|
|
1455
|
+
});
|
|
1456
|
+
const resolvedOptions = {
|
|
1457
|
+
...options,
|
|
1458
|
+
hmrHost: createLanInterfaceCandidates(networkInterfaceSnapshot)[0]?.address,
|
|
1459
|
+
port: appPort,
|
|
1460
|
+
};
|
|
1461
|
+
const projectRoot = findProjectRoot(runtime.cwd ?? process.cwd());
|
|
1462
|
+
const devLaunchContext = await loadDevLaunchContext({
|
|
1463
|
+
env: runtime.env,
|
|
1464
|
+
fetchImpl,
|
|
1465
|
+
logger,
|
|
1466
|
+
projectRoot,
|
|
1467
|
+
timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
|
|
1468
|
+
});
|
|
1469
|
+
const permissionController = createDevPermissionController({
|
|
1470
|
+
boundMiniProgramId: await context.readBoundMiniProgramId(projectRoot),
|
|
1471
|
+
cacheRoot: runtime.devPermissionCacheRoot,
|
|
1472
|
+
devContext: devLaunchContext,
|
|
1473
|
+
loadRemoteContext: () => loadDevLaunchContext({
|
|
1474
|
+
env: runtime.env,
|
|
1475
|
+
fetchImpl,
|
|
1476
|
+
logger,
|
|
1477
|
+
projectRoot,
|
|
1478
|
+
requireRemote: true,
|
|
1479
|
+
timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
|
|
1480
|
+
}),
|
|
1481
|
+
projectRoot,
|
|
1482
|
+
});
|
|
1483
|
+
const vite = await logger.task('正在加载项目 Vite', () => loadProjectVite(projectRoot), { successText: '已加载项目 Vite' });
|
|
1484
|
+
const appServer = await logger.task('正在创建 Vite dev server', () => withManagedDevOutputEnv(() => vite.createServer(createViteServerOptions(projectRoot, resolvedOptions)), runtime.env), { successText: '已创建 Vite dev server' });
|
|
1485
|
+
await logger.task('正在启动 Vite dev server', () => appServer.listen(appPort), {
|
|
1486
|
+
successText: 'Vite dev server 已启动',
|
|
1487
|
+
});
|
|
1488
|
+
const appUrl = await logger.task('正在探测小程序入口', () => resolveViteAppUrl(appServer, resolvedOptions, fetchImpl), {
|
|
1489
|
+
successText: '已确定小程序入口',
|
|
1490
|
+
});
|
|
1491
|
+
const lanAddresses = createLanAddressCandidates({
|
|
1492
|
+
appUrl,
|
|
1493
|
+
hmrHost: resolvedOptions.hmrHost,
|
|
1494
|
+
interfaces: networkInterfaceSnapshot,
|
|
1495
|
+
miniProgramId: devLaunchContext.miniProgramId,
|
|
1496
|
+
runtimeUrl: options.runtimeUrl,
|
|
1497
|
+
sdkVersion: HB_SDK_VERSION,
|
|
1498
|
+
viteNetworkUrls: appServer.resolvedUrls?.network ?? [],
|
|
1499
|
+
});
|
|
1500
|
+
const closers = [() => appServer.close()];
|
|
1501
|
+
let mockHost;
|
|
1502
|
+
try {
|
|
1503
|
+
mockHost = await logger.task('正在启动 Mock runtime host', () => startMiniProgramMockHostServer({
|
|
1504
|
+
appUrl,
|
|
1505
|
+
devContext: devLaunchContext,
|
|
1506
|
+
fetchImpl,
|
|
1507
|
+
getPort: getPortImpl,
|
|
1508
|
+
defaultLanAddressId: lanAddresses[0]?.id,
|
|
1509
|
+
lanAddresses,
|
|
1510
|
+
lookupHostname: runtime.lookupHostname,
|
|
1511
|
+
macAppProtocol: createMacAppProtocol(appUrl, {
|
|
1512
|
+
miniProgramId: devLaunchContext.miniProgramId,
|
|
1513
|
+
runtimeUrl: options.runtimeUrl,
|
|
1514
|
+
sdkVersion: HB_SDK_VERSION,
|
|
1515
|
+
}),
|
|
1516
|
+
permissionController,
|
|
1517
|
+
port: options.mockPort ?? DEFAULT_MOCK_PORT,
|
|
1518
|
+
root: runtime.mockHostRoot,
|
|
1519
|
+
rootCandidates: runtime.mockHostRootCandidates,
|
|
1520
|
+
runtimePermissions: devLaunchContext.runtimePermissions,
|
|
1521
|
+
}), { successText: 'Mock runtime host 已启动' });
|
|
1522
|
+
}
|
|
1523
|
+
catch (error) {
|
|
1524
|
+
await closeAll(closers);
|
|
1525
|
+
throw error;
|
|
1526
|
+
}
|
|
1527
|
+
closers.push(() => mockHost.close());
|
|
1528
|
+
logger.success('Mock runtime host 已就绪');
|
|
1529
|
+
logger.info(`Mock runtime host: ${mockHost.url}`);
|
|
1530
|
+
logger.info(`Mini program URL: ${appUrl}`);
|
|
1531
|
+
logger.info('Mac APP: use the button in Mock runtime host');
|
|
1532
|
+
logger.info('Mobile APP: scan the QR code in Mock runtime host');
|
|
1533
|
+
if (devLaunchContext.source === 'anonymous') {
|
|
1534
|
+
logger.warn('当前使用匿名本地沙箱;真机调试可用,但不会携带远端小程序身份或权限。');
|
|
1535
|
+
}
|
|
1536
|
+
if (options.open !== false) {
|
|
1537
|
+
logger.debug('正在打开浏览器调试页');
|
|
1538
|
+
void openUrl(mockHost.url);
|
|
1539
|
+
}
|
|
1540
|
+
installShutdownHandlers(closers, runtime.process ?? process);
|
|
1541
|
+
}
|
|
1542
|
+
async function loadDevLaunchContext(options) {
|
|
1543
|
+
const miniProgramId = await context.readBoundMiniProgramId(options.projectRoot);
|
|
1544
|
+
if (!miniProgramId) {
|
|
1545
|
+
const anonymousReason = '当前项目未绑定小程序。请先运行 hb-sdk remote create 或 hb-sdk remote bind <mini-program-id>。';
|
|
1546
|
+
if (options.requireRemote) {
|
|
1547
|
+
throw new Error(anonymousReason);
|
|
1548
|
+
}
|
|
1549
|
+
options.logger.warn(`未读取到远端 Runtime 权限,Mock runtime 将默认拒绝受管能力:${anonymousReason}`);
|
|
1550
|
+
return {
|
|
1551
|
+
source: 'anonymous',
|
|
1552
|
+
warning: anonymousReason,
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
const { detail, miniProgramId: verifiedMiniProgramId } = await options.logger.task('正在读取远端小程序权限', () => loadRemoteDevContextWithTimeout({
|
|
1557
|
+
fetchImpl: options.fetchImpl,
|
|
1558
|
+
load: (fetchImpl) => context.getBoundMiniProgram({
|
|
1559
|
+
cwd: options.projectRoot,
|
|
1560
|
+
env: options.env,
|
|
1561
|
+
fetchImpl,
|
|
1562
|
+
}),
|
|
1563
|
+
timeoutMs: options.timeoutMs,
|
|
1564
|
+
}), { successText: '已读取远端小程序权限' });
|
|
1565
|
+
return {
|
|
1566
|
+
source: 'remote',
|
|
1567
|
+
miniProgramId: verifiedMiniProgramId,
|
|
1568
|
+
runtimePermissions: detail.runtime_permissions,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
catch (error) {
|
|
1572
|
+
if (error instanceof context.MiniProgramProjectBindingError && error.code !== 'MINI_PROGRAM_UNBOUND') {
|
|
1573
|
+
throw error;
|
|
1574
|
+
}
|
|
1575
|
+
if (options.requireRemote) {
|
|
1576
|
+
throw error;
|
|
1577
|
+
}
|
|
1578
|
+
options.logger.debug(`远端 dev context 失败详情:${index.readRedactedErrorMessage(error, { verbose: true })}`);
|
|
1579
|
+
options.logger.warn(`未读取到远端 Runtime 权限,Mock runtime 将默认拒绝受管能力:${DEV_CONTEXT_UNAVAILABLE_WARNING}`);
|
|
1580
|
+
return {
|
|
1581
|
+
source: 'anonymous',
|
|
1582
|
+
warning: DEV_CONTEXT_UNAVAILABLE_WARNING,
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
function createDevPermissionController(options) {
|
|
1587
|
+
const store = createDevPermissionOverrideStore({ cacheRoot: options.cacheRoot });
|
|
1588
|
+
let devContext = options.devContext;
|
|
1589
|
+
let ephemeralOverride;
|
|
1590
|
+
let persistenceError;
|
|
1591
|
+
const readScope = () => ({
|
|
1592
|
+
projectRoot: options.projectRoot,
|
|
1593
|
+
...(options.boundMiniProgramId ? { miniProgramId: options.boundMiniProgramId } : {}),
|
|
1594
|
+
});
|
|
1595
|
+
const createConfiguration = (overrides, error = persistenceError) => {
|
|
1596
|
+
const miniProgramId = options.boundMiniProgramId ?? devContext.miniProgramId;
|
|
1597
|
+
return {
|
|
1598
|
+
source: devContext.source,
|
|
1599
|
+
...(miniProgramId ? { mini_program_id: miniProgramId } : {}),
|
|
1600
|
+
...(isRuntimePermissionsSnapshot(devContext.runtimePermissions) ? { remote_permissions: devContext.runtimePermissions } : {}),
|
|
1601
|
+
...(overrides ? { override_permissions: createMiniProgramMockRuntimePermissions(overrides) } : {}),
|
|
1602
|
+
...(error ? { persistence_error: error } : {}),
|
|
1603
|
+
};
|
|
1604
|
+
};
|
|
1605
|
+
const get = async () => {
|
|
1606
|
+
try {
|
|
1607
|
+
const persistedOverride = await store.read(readScope());
|
|
1608
|
+
return createConfiguration(ephemeralOverride ?? persistedOverride);
|
|
1609
|
+
}
|
|
1610
|
+
catch (error) {
|
|
1611
|
+
persistenceError = index.readErrorMessage(error);
|
|
1612
|
+
return createConfiguration(ephemeralOverride, persistenceError);
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
return {
|
|
1616
|
+
get,
|
|
1617
|
+
async reset() {
|
|
1618
|
+
if (devContext.source === 'anonymous' && !options.boundMiniProgramId) {
|
|
1619
|
+
await store.remove(readScope());
|
|
1620
|
+
ephemeralOverride = undefined;
|
|
1621
|
+
persistenceError = undefined;
|
|
1622
|
+
return createConfiguration(undefined);
|
|
1623
|
+
}
|
|
1624
|
+
let refreshedContext;
|
|
1625
|
+
try {
|
|
1626
|
+
refreshedContext = await options.loadRemoteContext();
|
|
1627
|
+
}
|
|
1628
|
+
catch (error) {
|
|
1629
|
+
throw new Error('远端权限读取失败,请检查 CLI 登录状态和网络后重试。', { cause: error });
|
|
1630
|
+
}
|
|
1631
|
+
await store.remove(readScope());
|
|
1632
|
+
devContext = refreshedContext;
|
|
1633
|
+
ephemeralOverride = undefined;
|
|
1634
|
+
persistenceError = undefined;
|
|
1635
|
+
return createConfiguration(undefined);
|
|
1636
|
+
},
|
|
1637
|
+
async save(runtimePermissions) {
|
|
1638
|
+
const overrides = createMiniProgramMockPermissionState({
|
|
1639
|
+
runtimePermissions,
|
|
1640
|
+
});
|
|
1641
|
+
try {
|
|
1642
|
+
await store.set(readScope(), overrides);
|
|
1643
|
+
ephemeralOverride = undefined;
|
|
1644
|
+
persistenceError = undefined;
|
|
1645
|
+
return createConfiguration(overrides);
|
|
1646
|
+
}
|
|
1647
|
+
catch {
|
|
1648
|
+
ephemeralOverride = overrides;
|
|
1649
|
+
persistenceError = '保存失败,当前页面仍会应用该权限,但刷新或重启后会丢失。';
|
|
1650
|
+
return createConfiguration(overrides, persistenceError);
|
|
1651
|
+
}
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
function isRuntimePermissionsSnapshot(value) {
|
|
1656
|
+
return parseMiniProgramRuntimePermissions(value).valid;
|
|
1657
|
+
}
|
|
1658
|
+
async function loadRemoteDevContextWithTimeout(options) {
|
|
1659
|
+
const controller = new AbortController();
|
|
1660
|
+
const fetchImpl = ((input, init) => options.fetchImpl(input, { ...init, signal: controller.signal }));
|
|
1661
|
+
let timeout;
|
|
1662
|
+
try {
|
|
1663
|
+
return await Promise.race([
|
|
1664
|
+
options.load(fetchImpl),
|
|
1665
|
+
new Promise((_resolve, reject) => {
|
|
1666
|
+
timeout = setTimeout(() => {
|
|
1667
|
+
controller.abort();
|
|
1668
|
+
reject(new Error('dev context timeout'));
|
|
1669
|
+
}, Math.max(1, options.timeoutMs));
|
|
1670
|
+
}),
|
|
1671
|
+
]);
|
|
1672
|
+
}
|
|
1673
|
+
finally {
|
|
1674
|
+
if (timeout) {
|
|
1675
|
+
clearTimeout(timeout);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
function findProjectRoot(startDir) {
|
|
1680
|
+
let current = path.resolve(startDir);
|
|
1681
|
+
while (true) {
|
|
1682
|
+
if (fs.existsSync(path.join(current, 'package.json'))) {
|
|
1683
|
+
return current;
|
|
1684
|
+
}
|
|
1685
|
+
const parent = path.dirname(current);
|
|
1686
|
+
if (parent === current) {
|
|
1687
|
+
throw new Error('当前目录或父目录未找到 package.json');
|
|
1688
|
+
}
|
|
1689
|
+
current = parent;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
async function loadProjectVite(projectRoot) {
|
|
1693
|
+
const projectRequire = node_module.createRequire(path.join(projectRoot, 'package.json'));
|
|
1694
|
+
try {
|
|
1695
|
+
const packageJson = JSON.parse(await fs$1.readFile(path.join(projectRoot, 'package.json'), 'utf8'));
|
|
1696
|
+
if (!hasPackageDependency(packageJson, 'vite')) {
|
|
1697
|
+
throw new Error('package.json 未声明 vite');
|
|
1698
|
+
}
|
|
1699
|
+
const viteEntry = projectRequire.resolve('vite');
|
|
1700
|
+
return (await import(node_url.pathToFileURL(viteEntry).href));
|
|
1701
|
+
}
|
|
1702
|
+
catch (error) {
|
|
1703
|
+
throw new Error(`当前项目未安装 vite。请先安装项目依赖,或将 vite 放到项目 devDependencies。原始错误: ${index.readErrorMessage(error)}`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function hasPackageDependency(packageJson, name) {
|
|
1707
|
+
return Boolean(packageJson.dependencies?.[name] ||
|
|
1708
|
+
packageJson.devDependencies?.[name] ||
|
|
1709
|
+
packageJson.optionalDependencies?.[name] ||
|
|
1710
|
+
packageJson.peerDependencies?.[name]);
|
|
1711
|
+
}
|
|
1712
|
+
function createViteServerOptions(projectRoot, options) {
|
|
1713
|
+
const configFile = resolveProjectViteConfig(projectRoot);
|
|
1714
|
+
const server = {
|
|
1715
|
+
cors: true,
|
|
1716
|
+
hmr: {
|
|
1717
|
+
clientPort: options.port ?? DEFAULT_APP_PORT,
|
|
1718
|
+
...(options.hmrHost ? { host: options.hmrHost } : {}),
|
|
1719
|
+
},
|
|
1720
|
+
host: DEV_LISTEN_HOST,
|
|
1721
|
+
port: options.port ?? DEFAULT_APP_PORT,
|
|
1722
|
+
strictPort: true,
|
|
1723
|
+
};
|
|
1724
|
+
if (configFile) {
|
|
1725
|
+
return {
|
|
1726
|
+
configFile,
|
|
1727
|
+
logLevel: VITE_LOG_LEVEL,
|
|
1728
|
+
server,
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
return {
|
|
1732
|
+
logLevel: VITE_LOG_LEVEL,
|
|
1733
|
+
root: projectRoot,
|
|
1734
|
+
server,
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
function resolveProjectViteConfig(projectRoot) {
|
|
1738
|
+
const candidates = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'];
|
|
1739
|
+
return candidates.map((file) => path.join(projectRoot, file)).find((file) => fs.existsSync(file));
|
|
1740
|
+
}
|
|
1741
|
+
async function resolveViteAppUrl(server, options, fetchImpl) {
|
|
1742
|
+
const candidates = createViteAppUrlCandidates(server, options);
|
|
1743
|
+
for (const candidate of candidates) {
|
|
1744
|
+
if (await canReachDocumentUrl(candidate, fetchImpl)) {
|
|
1745
|
+
return candidate;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
return candidates[0];
|
|
1749
|
+
}
|
|
1750
|
+
function createViteAppUrlCandidates(server, options) {
|
|
1751
|
+
const localUrl = rewriteLocalhostUrl(server.resolvedUrls?.local[0]);
|
|
1752
|
+
const origin = readViteOrigin(localUrl, options);
|
|
1753
|
+
const candidates = [localUrl, createH5EntryUrl(origin, readViteBase(server, localUrl))].filter((url) => Boolean(url));
|
|
1754
|
+
return Array.from(new Set(candidates));
|
|
1755
|
+
}
|
|
1756
|
+
function readViteOrigin(localUrl, options) {
|
|
1757
|
+
if (localUrl) {
|
|
1758
|
+
try {
|
|
1759
|
+
return new URL(localUrl).origin;
|
|
1760
|
+
}
|
|
1761
|
+
catch {
|
|
1762
|
+
/* fall through */
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
const port = options.port ?? DEFAULT_APP_PORT;
|
|
1766
|
+
return `http://${LOCAL_DEV_URL_HOST}:${port}`;
|
|
1767
|
+
}
|
|
1768
|
+
function readViteBase(server, localUrl) {
|
|
1769
|
+
const base = server.config?.base;
|
|
1770
|
+
if (base && base !== '/') {
|
|
1771
|
+
return base;
|
|
1772
|
+
}
|
|
1773
|
+
if (localUrl) {
|
|
1774
|
+
try {
|
|
1775
|
+
return new URL(localUrl).pathname;
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
/* fall through */
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return '/';
|
|
1782
|
+
}
|
|
1783
|
+
function createH5EntryUrl(origin, base) {
|
|
1784
|
+
const pathname = `${ensureTrailingSlash(base)}src/index.html`;
|
|
1785
|
+
return new URL(pathname, `${origin}/`).toString();
|
|
1786
|
+
}
|
|
1787
|
+
async function canReachDocumentUrl(url, fetchImpl) {
|
|
1788
|
+
try {
|
|
1789
|
+
const headResponse = await fetchImpl(url, {
|
|
1790
|
+
method: 'HEAD',
|
|
1791
|
+
redirect: 'follow',
|
|
1792
|
+
});
|
|
1793
|
+
if (headResponse.ok) {
|
|
1794
|
+
return true;
|
|
1795
|
+
}
|
|
1796
|
+
if (headResponse.status !== 404 && headResponse.status !== 405) {
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1799
|
+
const getResponse = await fetchImpl(url, {
|
|
1800
|
+
method: 'GET',
|
|
1801
|
+
redirect: 'follow',
|
|
1802
|
+
});
|
|
1803
|
+
return getResponse.ok;
|
|
1804
|
+
}
|
|
1805
|
+
catch {
|
|
1806
|
+
return false;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
function ensureTrailingSlash(value) {
|
|
1810
|
+
return value.endsWith('/') ? value : `${value}/`;
|
|
1811
|
+
}
|
|
1812
|
+
function rewriteLocalhostUrl(input) {
|
|
1813
|
+
if (!input) {
|
|
1814
|
+
return undefined;
|
|
1815
|
+
}
|
|
1816
|
+
try {
|
|
1817
|
+
const url = new URL(input);
|
|
1818
|
+
if (isLocalhostHost(url.hostname)) {
|
|
1819
|
+
url.hostname = LOCAL_DEV_URL_HOST;
|
|
1820
|
+
return url.toString();
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
catch {
|
|
1824
|
+
return input;
|
|
1825
|
+
}
|
|
1826
|
+
return input;
|
|
1827
|
+
}
|
|
1828
|
+
function isLocalhostHost(hostname) {
|
|
1829
|
+
return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]';
|
|
1830
|
+
}
|
|
1831
|
+
async function withManagedDevOutputEnv(action, env = process.env) {
|
|
1832
|
+
const previous = env[MANAGED_DEV_OUTPUT_ENV];
|
|
1833
|
+
env[MANAGED_DEV_OUTPUT_ENV] = '1';
|
|
1834
|
+
try {
|
|
1835
|
+
return await action();
|
|
1836
|
+
}
|
|
1837
|
+
finally {
|
|
1838
|
+
if (previous === undefined) {
|
|
1839
|
+
delete env[MANAGED_DEV_OUTPUT_ENV];
|
|
1840
|
+
}
|
|
1841
|
+
else {
|
|
1842
|
+
env[MANAGED_DEV_OUTPUT_ENV] = previous;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
function installShutdownHandlers(closers, processLike) {
|
|
1847
|
+
const shutdown = async () => {
|
|
1848
|
+
await closeAll(closers);
|
|
1849
|
+
processLike.exit(0);
|
|
1850
|
+
};
|
|
1851
|
+
processLike.once('SIGINT', () => {
|
|
1852
|
+
void shutdown();
|
|
1853
|
+
});
|
|
1854
|
+
processLike.once('SIGTERM', () => {
|
|
1855
|
+
void shutdown();
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
async function closeAll(closers) {
|
|
1859
|
+
await Promise.allSettled(closers.map((close) => close()));
|
|
1860
|
+
}
|
|
1861
|
+
function createLanAddressCandidates(options) {
|
|
1862
|
+
const viteNetworkUrlByHost = new Map(options.viteNetworkUrls.map((url) => [readUrlHost(url), url]).filter((entry) => Boolean(entry[0])));
|
|
1863
|
+
const candidates = createLanInterfaceCandidates(options.interfaces)
|
|
1864
|
+
.filter(({ address }) => address === options.hmrHost)
|
|
1865
|
+
.map(({ address, id, name }) => {
|
|
1866
|
+
const appUrl = viteNetworkUrlByHost.get(address) ?? replaceUrlHost(options.appUrl, address);
|
|
1867
|
+
if (!appUrl) {
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
const candidate = {
|
|
1871
|
+
address,
|
|
1872
|
+
appUrl,
|
|
1873
|
+
id,
|
|
1874
|
+
name,
|
|
1875
|
+
};
|
|
1876
|
+
const runtimeUrl = rewriteLoopbackUrlHost(options.runtimeUrl, address);
|
|
1877
|
+
if (runtimeUrl !== undefined) {
|
|
1878
|
+
candidate.mobileAppQrPayload = createMobileAppQrPayload(appUrl, {
|
|
1879
|
+
miniProgramId: options.miniProgramId,
|
|
1880
|
+
runtimeUrl,
|
|
1881
|
+
sdkVersion: options.sdkVersion,
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
else {
|
|
1885
|
+
candidate.mobileAppQrPayload = createMobileAppQrPayload(appUrl, {
|
|
1886
|
+
miniProgramId: options.miniProgramId,
|
|
1887
|
+
sdkVersion: options.sdkVersion,
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
return candidate;
|
|
1891
|
+
})
|
|
1892
|
+
.filter((candidate) => Boolean(candidate));
|
|
1893
|
+
return candidates.sort((left, right) => {
|
|
1894
|
+
const leftNetworkMatch = viteNetworkUrlByHost.has(left.address) ? 0 : 1;
|
|
1895
|
+
const rightNetworkMatch = viteNetworkUrlByHost.has(right.address) ? 0 : 1;
|
|
1896
|
+
if (leftNetworkMatch !== rightNetworkMatch) {
|
|
1897
|
+
return leftNetworkMatch - rightNetworkMatch;
|
|
1898
|
+
}
|
|
1899
|
+
return readInterfacePriority(left.name) - readInterfacePriority(right.name) || left.name.localeCompare(right.name);
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
function createLanInterfaceCandidates(interfaces) {
|
|
1903
|
+
return Object.entries(interfaces)
|
|
1904
|
+
.flatMap(([name, infos]) => (infos ?? []).map((info) => ({
|
|
1905
|
+
info,
|
|
1906
|
+
name,
|
|
1907
|
+
})))
|
|
1908
|
+
.filter(({ info }) => info.family === 'IPv4' && !info.internal && isPrivateIPv4(info.address))
|
|
1909
|
+
.map(({ info, name }) => ({
|
|
1910
|
+
address: info.address,
|
|
1911
|
+
id: `${name}-${info.address}`,
|
|
1912
|
+
name,
|
|
1913
|
+
}))
|
|
1914
|
+
.sort((left, right) => {
|
|
1915
|
+
return readInterfacePriority(left.name) - readInterfacePriority(right.name) || left.name.localeCompare(right.name);
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
function rewriteLoopbackUrlHost(input, host) {
|
|
1919
|
+
if (input === undefined || input === '') {
|
|
1920
|
+
return undefined;
|
|
1921
|
+
}
|
|
1922
|
+
try {
|
|
1923
|
+
const url = new URL(input);
|
|
1924
|
+
if (isLoopbackHost(url.hostname)) {
|
|
1925
|
+
url.hostname = host;
|
|
1926
|
+
return url.toString();
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
catch {
|
|
1930
|
+
return input;
|
|
1931
|
+
}
|
|
1932
|
+
return input;
|
|
1933
|
+
}
|
|
1934
|
+
function isLoopbackHost(hostname) {
|
|
1935
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
|
1936
|
+
}
|
|
1937
|
+
function readUrlHost(input) {
|
|
1938
|
+
try {
|
|
1939
|
+
return new URL(input).hostname;
|
|
1940
|
+
}
|
|
1941
|
+
catch {
|
|
1942
|
+
return undefined;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
function replaceUrlHost(input, host) {
|
|
1946
|
+
try {
|
|
1947
|
+
const url = new URL(input);
|
|
1948
|
+
url.hostname = host;
|
|
1949
|
+
return url.toString();
|
|
1950
|
+
}
|
|
1951
|
+
catch {
|
|
1952
|
+
return undefined;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
function readInterfacePriority(name) {
|
|
1956
|
+
const normalized = name.toLowerCase();
|
|
1957
|
+
if (normalized.includes('wi-fi') || normalized.includes('wifi') || normalized.includes('wlan')) {
|
|
1958
|
+
return 0;
|
|
1959
|
+
}
|
|
1960
|
+
if (normalized === 'en0') {
|
|
1961
|
+
return 1;
|
|
1962
|
+
}
|
|
1963
|
+
if (normalized.startsWith('en') || normalized.includes('ethernet')) {
|
|
1964
|
+
return 2;
|
|
1965
|
+
}
|
|
1966
|
+
return 3;
|
|
1967
|
+
}
|
|
1968
|
+
function isPrivateIPv4(address) {
|
|
1969
|
+
const parts = address.split('.').map((part) => Number(part));
|
|
1970
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
return parts[0] === 10 || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 192 && parts[1] === 168);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
exports.runDevCommand = runDevCommand;
|