@heybox/hb-sdk 0.6.5 → 0.6.6-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,13 @@ var os = require('node:os');
7
7
  var path = require('node:path');
8
8
  var node_url = require('node:url');
9
9
  var net = require('node:net');
10
+ var promises = require('node:dns/promises');
10
11
  var node_http = require('node:http');
12
+ var node_crypto = require('node:crypto');
13
+ var undici = require('undici');
11
14
  var browser = require('./browser-RAy8e8cV.cjs');
12
- var index = require('./index-CKat0ExC.cjs');
13
- var context = require('./context-DjepdCaa.cjs');
15
+ var index = require('./index-gP0K2xx1.cjs');
16
+ var context = require('./context-D-cb_pNY.cjs');
14
17
  require('node:process');
15
18
  require('node:buffer');
16
19
  require('node:util');
@@ -24,8 +27,7 @@ require('events');
24
27
  require('stream');
25
28
  require('buffer');
26
29
  require('util');
27
- require('./session-DPEq__gB.cjs');
28
- require('node:crypto');
30
+ require('./session-BRwveiW8.cjs');
29
31
  require('fs');
30
32
  require('constants');
31
33
 
@@ -191,6 +193,605 @@ async function getPorts(options) {
191
193
  throw new Error('No available ports found');
192
194
  }
193
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 resolveMockNetworkTarget(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 resolveMockNetworkTarget(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 hostname = normalizeNetworkHostname(url.hostname);
462
+ const addressFamily = net.isIP(hostname);
463
+ const addresses = addressFamily ? [{ address: hostname, family: addressFamily }] : await lookupHostname(hostname);
464
+ if (!addresses.length) {
465
+ throw new Error('network.request target DNS lookup returned no addresses');
466
+ }
467
+ return addresses[0];
468
+ }
469
+ function createPinnedNetworkDispatcher(pinnedTargets) {
470
+ const connect = undici.buildConnector({});
471
+ return new undici.Agent({
472
+ connect(options, callback) {
473
+ const hostname = normalizeNetworkHostname(options.hostname);
474
+ const target = pinnedTargets.get(`${options.protocol}//${hostname}:${options.port || (options.protocol === 'https:' ? '443' : '80')}`);
475
+ if (!target) {
476
+ callback(new Error('network.request target was not DNS-pinned'), null);
477
+ return;
478
+ }
479
+ connect({
480
+ ...options,
481
+ hostname: target.address,
482
+ servername: options.servername || hostname,
483
+ }, callback);
484
+ },
485
+ });
486
+ }
487
+ function createPinnedNetworkTargetKey(url) {
488
+ return `${url.protocol}//${normalizeNetworkHostname(url.hostname)}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`;
489
+ }
490
+ function normalizeNetworkHostname(hostname) {
491
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
492
+ }
493
+ async function readLimitedMockNetworkResponse(response) {
494
+ if (!response.body) {
495
+ return '';
496
+ }
497
+ const reader = response.body.getReader();
498
+ const chunks = [];
499
+ let size = 0;
500
+ while (true) {
501
+ const { done, value } = await reader.read();
502
+ if (done) {
503
+ break;
504
+ }
505
+ size += value.byteLength;
506
+ if (size > MAX_MOCK_NETWORK_RESPONSE_BODY) {
507
+ await reader.cancel();
508
+ throw createMockHostError(502, 'NETWORK_RESPONSE_TOO_LARGE', 'network.request response body too large');
509
+ }
510
+ chunks.push(value);
511
+ }
512
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
513
+ }
514
+ function createMockNetworkUrl(input, params) {
515
+ let url;
516
+ try {
517
+ url = new URL(input);
518
+ }
519
+ catch {
520
+ throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是合法 URL');
521
+ }
522
+ if (!['http:', 'https:'].includes(url.protocol)) {
523
+ throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', 'network.request 仅支持 HTTP(S) URL');
524
+ }
525
+ if (isRecord$1(params)) {
526
+ Object.entries(params).forEach(([key, value]) => {
527
+ if (value !== undefined && value !== null) {
528
+ url.searchParams.set(key, String(value));
529
+ }
530
+ });
531
+ }
532
+ return url;
533
+ }
534
+ function normalizeMockNetworkHeaders(headers) {
535
+ const normalized = new Headers();
536
+ if (!isRecord$1(headers)) {
537
+ return normalized;
538
+ }
539
+ Object.entries(headers).forEach(([key, value]) => {
540
+ if (isSensitiveMockNetworkHeader(key)) {
541
+ throw createMockHostError(400, 'INVALID_NETWORK_REQUEST', `network.request 不允许设置敏感请求头: ${key}`);
542
+ }
543
+ if (typeof value === 'string' && !isBlockedMockNetworkHeader(key)) {
544
+ normalized.set(key, value);
545
+ }
546
+ });
547
+ return normalized;
548
+ }
549
+ function isSensitiveMockNetworkHeader(key) {
550
+ return ['authorization', 'cookie', 'proxy-authorization'].includes(key.toLowerCase());
551
+ }
552
+ function isBlockedMockNetworkHeader(key) {
553
+ return [
554
+ 'connection',
555
+ 'content-length',
556
+ 'host',
557
+ 'keep-alive',
558
+ 'proxy-authenticate',
559
+ 'proxy-authorization',
560
+ 'te',
561
+ 'trailer',
562
+ 'transfer-encoding',
563
+ 'upgrade',
564
+ ].includes(key.toLowerCase());
565
+ }
566
+ function createMockNetworkBody(method, data, headers) {
567
+ if (data === undefined || method === 'GET' || method === 'HEAD') {
568
+ return undefined;
569
+ }
570
+ const body = typeof data === 'string' ? data : JSON.stringify(data);
571
+ if (Buffer.byteLength(body) > MAX_MOCK_NETWORK_REQUEST_BODY) {
572
+ throw createMockHostError(413, 'REQUEST_ENTITY_TOO_LARGE', 'network.request data exceeds 2097152 bytes');
573
+ }
574
+ if (typeof data === 'string') {
575
+ return body;
576
+ }
577
+ if (!headers.has('content-type')) {
578
+ headers.set('content-type', 'application/json');
579
+ }
580
+ return body;
581
+ }
582
+ function safeJsonParse(text) {
583
+ try {
584
+ return JSON.parse(text);
585
+ }
586
+ catch {
587
+ return text;
588
+ }
589
+ }
590
+
591
+ const DEV_SESSION_TTL_MS = 5 * 60 * 1000;
592
+ const MAX_ACTIVE_DEV_SESSIONS = 64;
593
+ const MAX_EXPIRED_DEV_SESSION_TOMBSTONES = 128;
594
+ function createDevSessionRoutes(options) {
595
+ const devSessions = new Map();
596
+ const expiredDevSessions = new Set();
597
+ let revision = 0;
598
+ return {
599
+ match(pathname) {
600
+ const match = pathname.match(/^\/__hb_sdk__\/dev-context\/([a-f0-9]{64})(\/network)?$/);
601
+ if (!match) {
602
+ return undefined;
603
+ }
604
+ return {
605
+ kind: match[2] ? 'network' : 'context',
606
+ token: match[1],
607
+ };
608
+ },
609
+ async create(request, response) {
610
+ if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
611
+ writeJsonResponse(response, 400, { code: 'INVALID_DEV_SESSION', message: 'Dev Session body must be JSON' });
612
+ return;
613
+ }
614
+ try {
615
+ const payload = await readJsonRequestBody(request);
616
+ const validated = validateDevSessionCreatePayload(payload, options.allowedMiniUrlOrigins);
617
+ reapExpiredDevSessions(devSessions, expiredDevSessions, options.now());
618
+ revokeDevSessionForOrigin(devSessions, validated.miniUrlOrigin);
619
+ if (devSessions.size >= MAX_ACTIVE_DEV_SESSIONS) {
620
+ throw createMockHostError(429, 'DEV_SESSION_LIMIT_REACHED', 'active Dev Session limit reached');
621
+ }
622
+ const expiresAt = options.now() + DEV_SESSION_TTL_MS;
623
+ const token = createDevSessionToken(devSessions);
624
+ const snapshot = {
625
+ schema_version: MINI_PROGRAM_DEV_SESSION_SCHEMA_VERSION,
626
+ revision: ++revision,
627
+ expires_at: expiresAt,
628
+ mini_url_origin: validated.miniUrlOrigin,
629
+ runtime_permissions: validated.runtimePermissions,
630
+ };
631
+ devSessions.set(token, {
632
+ expiresAt,
633
+ miniUrlOrigin: validated.miniUrlOrigin,
634
+ networkRequestEnabled: validated.networkRequestEnabled,
635
+ serialized: JSON.stringify(snapshot),
636
+ });
637
+ const result = { expires_at: expiresAt, token };
638
+ writeJsonResponse(response, 201, result);
639
+ }
640
+ catch (error) {
641
+ const status = readMockHostErrorStatus(error);
642
+ writeJsonResponse(response, status === 500 ? 400 : status, {
643
+ code: 'INVALID_DEV_SESSION',
644
+ message: readErrorMessage(error),
645
+ });
646
+ }
647
+ },
648
+ async handle(route, request, response) {
649
+ if (route.kind === 'network') {
650
+ await handleTokenNetworkProxy(request, response, route.token, {
651
+ devSessions,
652
+ expiredDevSessions,
653
+ fetchImpl: options.fetchImpl,
654
+ lookupHostname: options.lookupHostname,
655
+ now: options.now,
656
+ });
657
+ return;
658
+ }
659
+ if (request.method !== 'OPTIONS' && request.method !== 'GET') {
660
+ response.writeHead(405, { allow: 'GET', 'cache-control': 'no-store' });
661
+ response.end();
662
+ return;
663
+ }
664
+ const resolved = resolveDevSession(route.token, devSessions, expiredDevSessions, options.now());
665
+ if (resolved.status !== 200) {
666
+ writeDevSessionCorsResponse(response, resolved.status);
667
+ return;
668
+ }
669
+ writeDevSessionCorsResponse(response, request.method === 'OPTIONS' ? 204 : 200, request.method === 'GET' ? resolved.session.serialized : undefined);
670
+ },
671
+ };
672
+ }
673
+ function validateDevSessionCreatePayload(payload, allowedMiniUrlOrigins) {
674
+ if (!isRecord$1(payload) || Object.keys(payload).some((key) => !['mini_url_origin', 'runtime_permissions'].includes(key))) {
675
+ throw new TypeError('Dev Session body is invalid');
676
+ }
677
+ if (typeof payload.mini_url_origin !== 'string') {
678
+ throw new TypeError('mini_url_origin is invalid');
679
+ }
680
+ let miniUrlOrigin;
681
+ try {
682
+ miniUrlOrigin = new URL(payload.mini_url_origin);
683
+ }
684
+ catch {
685
+ throw new TypeError('mini_url_origin is invalid');
686
+ }
687
+ if (miniUrlOrigin.protocol !== 'http:' ||
688
+ payload.mini_url_origin !== miniUrlOrigin.origin ||
689
+ !isPrivateLanIpv4(miniUrlOrigin.hostname) ||
690
+ !allowedMiniUrlOrigins.has(miniUrlOrigin.origin)) {
691
+ throw new TypeError('mini_url_origin must be an allowed LAN origin');
692
+ }
693
+ const parsedPermissions = parseMiniProgramRuntimePermissions(payload.runtime_permissions);
694
+ if (!parsedPermissions.valid) {
695
+ throw new TypeError('runtime_permissions is invalid');
696
+ }
697
+ const networkPermission = parsedPermissions.permissions['network.request'];
698
+ if (networkPermission?.config.useOfficialDomain !== false) {
699
+ throw new TypeError('Dev Session does not allow official domains');
700
+ }
701
+ return {
702
+ miniUrlOrigin: miniUrlOrigin.origin,
703
+ networkRequestEnabled: networkPermission.status === 'enabled',
704
+ runtimePermissions: JSON.parse(JSON.stringify(payload.runtime_permissions)),
705
+ };
706
+ }
707
+ async function handleTokenNetworkProxy(request, response, token, runtime) {
708
+ const resolved = resolveDevSession(token, runtime.devSessions, runtime.expiredDevSessions, runtime.now());
709
+ if (resolved.status !== 200) {
710
+ writeTokenNetworkJsonResponse(response, resolved.status, undefined);
711
+ return;
712
+ }
713
+ if (!resolved.session.networkRequestEnabled) {
714
+ writeTokenNetworkJsonResponse(response, 403, {
715
+ code: 'PERMISSION_DENIED',
716
+ message: 'network.request is disabled for this Dev Session',
717
+ });
718
+ return;
719
+ }
720
+ if (request.method === 'OPTIONS') {
721
+ writeTokenNetworkJsonResponse(response, 204, undefined);
722
+ return;
723
+ }
724
+ if (request.method !== 'POST') {
725
+ writeTokenNetworkJsonResponse(response, 405, {
726
+ code: 'METHOD_NOT_ALLOWED',
727
+ message: 'Dev Session network proxy only supports POST',
728
+ });
729
+ return;
730
+ }
731
+ await handleMockNetworkProxy(request, response, runtime.fetchImpl, runtime.lookupHostname, true);
732
+ }
733
+ function resolveDevSession(token, devSessions, expiredDevSessions, now) {
734
+ const session = devSessions.get(token);
735
+ if (!session) {
736
+ return { status: expiredDevSessions.has(token) ? 410 : 404 };
737
+ }
738
+ if (now >= session.expiresAt) {
739
+ devSessions.delete(token);
740
+ addExpiredDevSessionTombstone(expiredDevSessions, token);
741
+ return { status: 410 };
742
+ }
743
+ return { session, status: 200 };
744
+ }
745
+ function reapExpiredDevSessions(devSessions, expiredDevSessions, now) {
746
+ for (const [token, session] of devSessions) {
747
+ if (now >= session.expiresAt) {
748
+ devSessions.delete(token);
749
+ addExpiredDevSessionTombstone(expiredDevSessions, token);
750
+ }
751
+ }
752
+ }
753
+ function addExpiredDevSessionTombstone(expiredDevSessions, token) {
754
+ expiredDevSessions.add(token);
755
+ while (expiredDevSessions.size > MAX_EXPIRED_DEV_SESSION_TOMBSTONES) {
756
+ const oldest = expiredDevSessions.values().next().value;
757
+ if (!oldest) {
758
+ break;
759
+ }
760
+ expiredDevSessions.delete(oldest);
761
+ }
762
+ }
763
+ function revokeDevSessionForOrigin(devSessions, miniUrlOrigin) {
764
+ for (const [token, session] of devSessions) {
765
+ if (session.miniUrlOrigin === miniUrlOrigin) {
766
+ devSessions.delete(token);
767
+ }
768
+ }
769
+ }
770
+ function createDevSessionToken(devSessions) {
771
+ let token;
772
+ do {
773
+ token = node_crypto.randomBytes(32).toString('hex');
774
+ } while (devSessions.has(token));
775
+ return token;
776
+ }
777
+ function isPrivateLanIpv4(hostname) {
778
+ const octets = hostname.split('.').map(Number);
779
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
780
+ return false;
781
+ }
782
+ return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
783
+ }
784
+ function writeDevSessionCorsResponse(response, status, serializedBody) {
785
+ response.writeHead(status, {
786
+ 'access-control-allow-methods': 'GET',
787
+ 'access-control-allow-origin': '*',
788
+ 'access-control-allow-private-network': 'true',
789
+ 'cache-control': 'no-store',
790
+ ...(serializedBody === undefined ? {} : { 'content-type': 'application/json; charset=utf-8' }),
791
+ });
792
+ response.end(serializedBody ?? '');
793
+ }
794
+
194
795
  async function selectAvailablePort(options) {
195
796
  const maxAttempts = options.maxAttempts ?? 20;
196
797
  const port = await options.getPort({
@@ -209,7 +810,7 @@ function createPortCandidates(startPort, count) {
209
810
  const MINI_PROGRAM_URL_QUERY_PARAM$1 = 'mini_url';
210
811
  const MOCK_HOST_BOOTSTRAP_PATH = '/__hb_sdk_bootstrap__';
211
812
  const MOCK_NETWORK_PROXY_PATH = '/__hb_sdk_mock_network__';
212
- const MOCK_NETWORK_PROXY_BODY_LIMIT = 1024 * 1024;
813
+ const DEV_PERMISSIONS_PATH = '/__hb_sdk__/dev-permissions';
213
814
  const DEV_LISTEN_HOST$1 = '0.0.0.0';
214
815
  const LOCAL_DEV_URL_HOST$1 = '127.0.0.1';
215
816
  const MOCK_HOST_ROOT_CANDIDATES = [
@@ -231,8 +832,13 @@ async function startMiniProgramMockHostServer(options) {
231
832
  });
232
833
  const server = createMiniProgramMockHostServer(root, {
233
834
  appUrl: options.appUrl,
835
+ allowedMiniUrlOrigins: new Set((options.lanAddresses ?? []).map(({ appUrl }) => new URL(appUrl).origin)),
234
836
  bootstrapJson,
235
837
  fetchImpl: options.fetchImpl ?? fetch,
838
+ isLoopbackPeer: options.isLoopbackPeer ?? isLoopbackAddress,
839
+ lookupHostname: options.lookupHostname ?? (async (hostname) => promises.lookup(hostname, { all: true, verbatim: true })),
840
+ now: options.now ?? Date.now,
841
+ permissionController: options.permissionController,
236
842
  });
237
843
  const port = await listenHttpServer(server, {
238
844
  port: options.port,
@@ -291,16 +897,27 @@ function serializeMockHostBootstrap(bootstrap) {
291
897
  throw new TypeError('Mock host bootstrap must be JSON serializable');
292
898
  }
293
899
  const serializedBootstrap = JSON.parse(serialized);
294
- if (bootstrap.runtimePermissions !== undefined &&
295
- !Object.prototype.hasOwnProperty.call(serializedBootstrap, 'runtimePermissions')) {
900
+ if (bootstrap.runtimePermissions !== undefined && !Object.prototype.hasOwnProperty.call(serializedBootstrap, 'runtimePermissions')) {
296
901
  throw new TypeError('Runtime permission snapshot must be JSON serializable');
297
902
  }
298
903
  return serialized;
299
904
  }
300
905
  function createMiniProgramMockHostServer(root, runtime) {
906
+ const devSessionRoutes = createDevSessionRoutes({
907
+ allowedMiniUrlOrigins: runtime.allowedMiniUrlOrigins,
908
+ fetchImpl: runtime.fetchImpl,
909
+ lookupHostname: runtime.lookupHostname,
910
+ now: runtime.now,
911
+ });
301
912
  return node_http.createServer(async (request, response) => {
302
913
  const requestUrl = new URL(request.url, 'http://localhost');
303
914
  const pathname = decodeURIComponent(requestUrl.pathname);
915
+ const devSessionRoute = devSessionRoutes.match(pathname);
916
+ const loopbackPeer = runtime.isLoopbackPeer(request.socket.remoteAddress);
917
+ if (!loopbackPeer && !devSessionRoute) {
918
+ writeForbidden(response);
919
+ return;
920
+ }
304
921
  if (isMockHostDocumentPath(pathname) && !requestUrl.searchParams.has(MINI_PROGRAM_URL_QUERY_PARAM$1)) {
305
922
  const targetUrl = new URL(requestUrl);
306
923
  targetUrl.pathname = '/';
@@ -312,12 +929,61 @@ function createMiniProgramMockHostServer(root, runtime) {
312
929
  response.end();
313
930
  return;
314
931
  }
315
- if (pathname === MOCK_HOST_BOOTSTRAP_PATH) {
316
- writeSerializedJsonResponse(response, 200, runtime.bootstrapJson);
932
+ if (pathname === MOCK_HOST_BOOTSTRAP_PATH) {
933
+ if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
934
+ writeForbidden(response);
935
+ return;
936
+ }
937
+ if (request.method !== 'GET') {
938
+ writeMethodNotAllowed(response, 'GET');
939
+ return;
940
+ }
941
+ writeSerializedJsonResponse(response, 200, runtime.bootstrapJson);
942
+ return;
943
+ }
944
+ if (pathname === DEV_PERMISSIONS_PATH && runtime.permissionController) {
945
+ if (!loopbackPeer || !hasNumericLoopbackHost(request)) {
946
+ writeForbidden(response);
947
+ return;
948
+ }
949
+ if (request.method === 'GET') {
950
+ writeJsonResponse(response, 200, await runtime.permissionController.get());
951
+ return;
952
+ }
953
+ if ((request.method !== 'PUT' && request.method !== 'DELETE') || !hasValidMutationOrigin(request)) {
954
+ if (request.method !== 'PUT' && request.method !== 'DELETE') {
955
+ writeMethodNotAllowed(response, 'GET, PUT, DELETE');
956
+ }
957
+ else {
958
+ writeForbidden(response);
959
+ }
960
+ return;
961
+ }
962
+ await handleDevPermissionMutation(request, response, runtime.permissionController);
963
+ return;
964
+ }
965
+ if (pathname === MINI_PROGRAM_DEV_CONTEXT_PATH) {
966
+ if (!loopbackPeer || !hasNumericLoopbackHost(request) || !hasValidMutationOrigin(request)) {
967
+ writeForbidden(response);
968
+ return;
969
+ }
970
+ if (request.method !== 'POST') {
971
+ writeMethodNotAllowed(response, 'POST');
972
+ return;
973
+ }
974
+ await devSessionRoutes.create(request, response);
975
+ return;
976
+ }
977
+ if (devSessionRoute) {
978
+ await devSessionRoutes.handle(devSessionRoute, request, response);
317
979
  return;
318
980
  }
319
981
  if (pathname === MOCK_NETWORK_PROXY_PATH) {
320
- await handleMockNetworkProxy(request, response, runtime.fetchImpl);
982
+ if (!loopbackPeer || !hasNumericLoopbackHost(request) || !hasValidMutationOrigin(request)) {
983
+ writeForbidden(response);
984
+ return;
985
+ }
986
+ await handleMockNetworkProxy(request, response, runtime.fetchImpl, runtime.lookupHostname);
321
987
  return;
322
988
  }
323
989
  const targetPath = resolveStaticPath(root, pathname);
@@ -345,204 +1011,90 @@ function createMiniProgramMockHostServer(root, runtime) {
345
1011
  }
346
1012
  });
347
1013
  }
348
- function isMockHostDocumentPath(pathname) {
349
- return pathname === '/' || pathname === '/index.html';
350
- }
351
- async function handleMockNetworkProxy(request, response, fetchImpl) {
352
- if (request.method === 'OPTIONS') {
353
- writeJsonResponse(response, 204, undefined);
354
- return;
355
- }
356
- if (request.method !== 'POST') {
357
- writeJsonResponse(response, 405, {
358
- code: 'METHOD_NOT_ALLOWED',
359
- message: 'mock network proxy only supports POST',
360
- });
361
- return;
362
- }
1014
+ async function handleDevPermissionMutation(request, response, controller) {
363
1015
  try {
1016
+ if (request.method === 'DELETE') {
1017
+ if (hasRequestBody(request)) {
1018
+ if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
1019
+ throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions body must be JSON');
1020
+ }
1021
+ const payload = await readJsonRequestBody(request);
1022
+ if (!isRecord$1(payload) || Object.keys(payload).length > 0) {
1023
+ throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions reset body is invalid');
1024
+ }
1025
+ }
1026
+ writeJsonResponse(response, 200, await controller.reset());
1027
+ return;
1028
+ }
1029
+ if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
1030
+ throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions body must be JSON');
1031
+ }
364
1032
  const payload = await readJsonRequestBody(request);
365
- const result = await requestMockNetwork(payload, fetchImpl);
366
- writeJsonResponse(response, 200, result);
1033
+ if (!isRecord$1(payload) || Object.keys(payload).length !== 1 || !Object.hasOwn(payload, 'runtime_permissions')) {
1034
+ throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'Dev permissions update body is invalid');
1035
+ }
1036
+ const parsed = parseMiniProgramRuntimePermissions(payload.runtime_permissions);
1037
+ if (!parsed.valid) {
1038
+ throw createMockHostError(400, 'INVALID_DEV_PERMISSIONS', 'runtime_permissions is invalid');
1039
+ }
1040
+ const runtimePermissions = JSON.parse(JSON.stringify(payload.runtime_permissions));
1041
+ writeJsonResponse(response, 200, await controller.save(runtimePermissions));
367
1042
  }
368
1043
  catch (error) {
369
- writeJsonResponse(response, readMockNetworkErrorStatus(error), toMockNetworkErrorPayload(error));
370
- }
371
- }
372
- async function requestMockNetwork(payload, fetchImpl) {
373
- if (!isRecord(payload) || typeof payload.url !== 'string' || !payload.url.trim()) {
374
- throw createMockNetworkError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是非空字符串');
375
- }
376
- const url = createMockNetworkUrl(payload.url, payload.params);
377
- const method = typeof payload.method === 'string' && payload.method.trim() ? payload.method.trim().toUpperCase() : 'GET';
378
- const headers = normalizeMockNetworkHeaders(payload.headers);
379
- const controller = new AbortController();
380
- const timeout = Number(payload.timeout) > 0 ? Number(payload.timeout) : 10000;
381
- const timer = setTimeout(() => controller.abort(), timeout);
382
- try {
383
- const response = await fetchImpl(url.toString(), {
384
- method,
385
- headers,
386
- body: createMockNetworkBody(method, payload.data, headers),
387
- redirect: 'follow',
388
- signal: controller.signal,
1044
+ const errorStatus = readMockHostErrorStatus(error);
1045
+ const status = request.method === 'DELETE' && errorStatus === 500 ? 502 : errorStatus;
1046
+ writeJsonResponse(response, status, {
1047
+ code: request.method === 'DELETE' ? 'DEV_PERMISSIONS_RESET_FAILED' : 'DEV_PERMISSIONS_UPDATE_FAILED',
1048
+ message: readErrorMessage(error),
389
1049
  });
390
- const contentType = response.headers.get('content-type') || '';
391
- const text = await response.text();
392
- clearTimeout(timer);
393
- return {
394
- data: contentType.includes('application/json') ? safeJsonParse(text) : text,
395
- status: response.status,
396
- statusText: response.statusText,
397
- headers: Object.fromEntries(response.headers.entries()),
398
- };
399
- }
400
- catch (error) {
401
- clearTimeout(timer);
402
- throw createMockNetworkError(502, 'MOCK_NETWORK_REQUEST_FAILED', error instanceof Error && error.name === 'AbortError'
403
- ? `network.request timeout after ${timeout}ms`
404
- : readErrorMessage(error));
405
1050
  }
406
1051
  }
407
- function createMockNetworkUrl(input, params) {
408
- let url;
409
- try {
410
- url = new URL(input);
411
- }
412
- catch {
413
- throw createMockNetworkError(400, 'INVALID_NETWORK_REQUEST', 'network.request url 必须是合法 URL');
414
- }
415
- if (!['http:', 'https:'].includes(url.protocol)) {
416
- throw createMockNetworkError(400, 'INVALID_NETWORK_REQUEST', 'network.request 仅支持 HTTP(S) URL');
417
- }
418
- if (isRecord(params)) {
419
- Object.entries(params).forEach(([key, value]) => {
420
- if (value !== undefined && value !== null) {
421
- url.searchParams.set(key, String(value));
422
- }
423
- });
424
- }
425
- return url;
1052
+ function hasRequestBody(request) {
1053
+ return request.headers['transfer-encoding'] !== undefined || Number(request.headers['content-length'] ?? 0) > 0;
426
1054
  }
427
- function normalizeMockNetworkHeaders(headers) {
428
- const normalized = new Headers();
429
- if (!isRecord(headers)) {
430
- return normalized;
431
- }
432
- Object.entries(headers).forEach(([key, value]) => {
433
- if (typeof value === 'string' && !isBlockedMockNetworkHeader(key)) {
434
- normalized.set(key, value);
435
- }
1055
+ function writeMethodNotAllowed(response, allowedMethod) {
1056
+ response.writeHead(405, {
1057
+ allow: allowedMethod,
1058
+ 'cache-control': 'no-store',
436
1059
  });
437
- return normalized;
1060
+ response.end();
438
1061
  }
439
- function isBlockedMockNetworkHeader(key) {
440
- return [
441
- 'connection',
442
- 'content-length',
443
- 'host',
444
- 'keep-alive',
445
- 'proxy-authenticate',
446
- 'proxy-authorization',
447
- 'te',
448
- 'trailer',
449
- 'transfer-encoding',
450
- 'upgrade',
451
- ].includes(key.toLowerCase());
1062
+ function writeForbidden(response) {
1063
+ response.writeHead(403, { 'cache-control': 'no-store' });
1064
+ response.end();
452
1065
  }
453
- function createMockNetworkBody(method, data, headers) {
454
- if (data === undefined || method === 'GET' || method === 'HEAD') {
455
- return undefined;
1066
+ function hasValidMutationOrigin(request) {
1067
+ const origin = request.headers.origin;
1068
+ const host = request.headers.host;
1069
+ if (!host || !origin || origin === 'null') {
1070
+ return false;
456
1071
  }
457
- if (typeof data === 'string') {
458
- return data;
1072
+ try {
1073
+ return new URL(origin).origin === `http://${host}`;
459
1074
  }
460
- if (!headers.has('content-type')) {
461
- headers.set('content-type', 'application/json');
1075
+ catch {
1076
+ return false;
462
1077
  }
463
- return JSON.stringify(data);
464
- }
465
- function readJsonRequestBody(request) {
466
- return new Promise((resolve, reject) => {
467
- const chunks = [];
468
- let size = 0;
469
- request.on('data', (chunk) => {
470
- const buffer = Buffer.from(chunk);
471
- size += buffer.byteLength;
472
- if (size > MOCK_NETWORK_PROXY_BODY_LIMIT) {
473
- reject(createMockNetworkError(413, 'REQUEST_ENTITY_TOO_LARGE', 'mock network proxy request body too large'));
474
- request.destroy();
475
- return;
476
- }
477
- chunks.push(buffer);
478
- });
479
- request.on('error', reject);
480
- request.on('aborted', () => {
481
- reject('mock network proxy request aborted');
482
- });
483
- request.on('end', () => {
484
- const rawBody = Buffer.concat(chunks).toString('utf8');
485
- if (!rawBody.trim()) {
486
- resolve({});
487
- return;
488
- }
489
- try {
490
- resolve(JSON.parse(rawBody));
491
- }
492
- catch {
493
- reject(createMockNetworkError(400, 'INVALID_JSON', 'mock network proxy received invalid JSON'));
494
- }
495
- });
496
- });
497
- }
498
- function writeJsonResponse(response, status, body) {
499
- response.writeHead(status, {
500
- 'content-type': 'application/json; charset=utf-8',
501
- 'cache-control': 'no-store',
502
- });
503
- response.end(body === undefined ? '' : JSON.stringify(body));
504
- }
505
- function writeSerializedJsonResponse(response, status, body) {
506
- response.writeHead(status, {
507
- 'content-type': 'application/json; charset=utf-8',
508
- 'cache-control': 'no-store',
509
- });
510
- response.end(body);
511
1078
  }
512
- function safeJsonParse(text) {
1079
+ function hasNumericLoopbackHost(request) {
1080
+ const host = request.headers.host;
1081
+ if (!host) {
1082
+ return false;
1083
+ }
513
1084
  try {
514
- return JSON.parse(text);
1085
+ const hostname = new URL(`http://${host}`).hostname;
1086
+ const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
1087
+ return net.isIP(normalized) !== 0 && isLoopbackAddress(normalized);
515
1088
  }
516
1089
  catch {
517
- return text;
1090
+ return false;
518
1091
  }
519
1092
  }
520
- function isRecord(value) {
521
- return Object.prototype.toString.call(value) === '[object Object]';
522
- }
523
- function createMockNetworkError(status, code, message) {
524
- const error = new Error(message);
525
- error.code = code;
526
- error.status = status;
527
- return error;
528
- }
529
- function readMockNetworkErrorStatus(error) {
530
- return isObjectLike(error) && typeof error.status === 'number' ? error.status : 500;
531
- }
532
- function toMockNetworkErrorPayload(error) {
533
- if (isObjectLike(error) && typeof error.code === 'string' && typeof error.message === 'string') {
534
- return {
535
- code: error.code,
536
- message: error.message,
537
- };
538
- }
539
- return {
540
- code: 'MOCK_NETWORK_ERROR',
541
- message: readErrorMessage(error),
542
- };
1093
+ function isLoopbackAddress(address) {
1094
+ return address === '::1' || address === '127.0.0.1' || address?.startsWith('127.') === true || address?.startsWith('::ffff:127.') === true;
543
1095
  }
544
- function isObjectLike(value) {
545
- return (typeof value === 'object' || typeof value === 'function') && value !== null;
1096
+ function isMockHostDocumentPath(pathname) {
1097
+ return pathname === '/' || pathname === '/index.html';
546
1098
  }
547
1099
  function resolveStaticPath(root, pathname) {
548
1100
  const safePathname = pathname === '/' ? '/index.html' : pathname;
@@ -594,9 +1146,6 @@ async function listenHttpServer(server, options, getPortImpl) {
594
1146
  const address = server.address();
595
1147
  return address.port;
596
1148
  }
597
- function readErrorMessage(error) {
598
- return error instanceof Error ? error.message : String(error);
599
- }
600
1149
 
601
1150
  const MINI_PROGRAM_URL_QUERY_PARAM = 'mini_url';
602
1151
  const MINI_PROGRAM_RUNTIME_URL_QUERY_PARAM = 'runtime_url';
@@ -667,10 +1216,168 @@ function createHeyboxProtocol(payload) {
667
1216
  return `heybox://${encodeURIComponent(JSON.stringify(payload))}`;
668
1217
  }
669
1218
 
1219
+ const DEFAULT_PERMISSION_STATE = {
1220
+ networkRequest: {
1221
+ status: 'disabled',
1222
+ useOfficialDomain: false,
1223
+ },
1224
+ };
1225
+ function createMiniProgramMockPermissionState(context) {
1226
+ const parsed = parseMiniProgramRuntimePermissions(context.runtimePermissions);
1227
+ const networkRequest = parsed.permissions['network.request'];
1228
+ return parsed.valid && networkRequest
1229
+ ? {
1230
+ networkRequest: {
1231
+ status: networkRequest.status,
1232
+ useOfficialDomain: networkRequest.config.useOfficialDomain === true,
1233
+ },
1234
+ }
1235
+ : cloneDefaultPermissionState();
1236
+ }
1237
+ function createMiniProgramMockRuntimePermissions(state) {
1238
+ return {
1239
+ schema_version: 1,
1240
+ entries: [
1241
+ {
1242
+ key: 'network.request',
1243
+ status: state.networkRequest.status,
1244
+ config: {
1245
+ useOfficialDomain: state.networkRequest.useOfficialDomain,
1246
+ },
1247
+ },
1248
+ ],
1249
+ };
1250
+ }
1251
+ function cloneDefaultPermissionState() {
1252
+ return {
1253
+ networkRequest: { ...DEFAULT_PERMISSION_STATE.networkRequest },
1254
+ };
1255
+ }
1256
+
670
1257
  /** 构建时替换为当前发布包的实际版本。 */
671
1258
  const HB_SDK_VERSION = typeof undefined === 'string'
672
1259
  ? undefined
673
- : '0.6.5';
1260
+ : '0.6.6-alpha.1';
1261
+
1262
+ class DevPermissionOverrideStoreError extends Error {
1263
+ code;
1264
+ constructor(message, code, options = {}) {
1265
+ super(message, { cause: options.cause });
1266
+ this.code = code;
1267
+ this.name = 'DevPermissionOverrideStoreError';
1268
+ }
1269
+ }
1270
+ const CACHE_VERSION = 1;
1271
+ const CACHE_DIRECTORY_NAME = 'dev-permission-overrides';
1272
+ const defaultPaths = index.envPaths('hb-sdk', { suffix: '' });
1273
+ function createDevPermissionOverrideStore(options = {}) {
1274
+ const cacheDirectory = path.join(options.cacheRoot ?? defaultPaths.cache, CACHE_DIRECTORY_NAME);
1275
+ return {
1276
+ async read(scope) {
1277
+ return readScopeFile(await getScopeFile(cacheDirectory, scope));
1278
+ },
1279
+ async remove(scope) {
1280
+ const scopeFile = await getScopeFile(cacheDirectory, scope);
1281
+ try {
1282
+ await fs$1.unlink(scopeFile);
1283
+ }
1284
+ catch (error) {
1285
+ if (isNodeError(error) && error.code === 'ENOENT') {
1286
+ return;
1287
+ }
1288
+ throw createWriteError(error);
1289
+ }
1290
+ },
1291
+ async set(scope, overrides) {
1292
+ const scopeFile = await getScopeFile(cacheDirectory, scope);
1293
+ try {
1294
+ await fs$1.mkdir(cacheDirectory, { recursive: true, mode: 0o700 });
1295
+ await fs$1.chmod(cacheDirectory, 0o700);
1296
+ await readScopeFile(scopeFile);
1297
+ await writeScopeFileAtomically(scopeFile, {
1298
+ version: CACHE_VERSION,
1299
+ overrides: cloneOverrides(overrides),
1300
+ });
1301
+ }
1302
+ catch (error) {
1303
+ if (error instanceof DevPermissionOverrideStoreError) {
1304
+ throw error;
1305
+ }
1306
+ throw createWriteError(error);
1307
+ }
1308
+ },
1309
+ };
1310
+ }
1311
+ async function getScopeFile(cacheDirectory, scope) {
1312
+ const projectRoot = await fs$1.realpath(scope.projectRoot);
1313
+ const miniProgramId = scope.miniProgramId ?? 'anonymous';
1314
+ const scopeHash = node_crypto.createHash('sha256').update(`${projectRoot}\0${miniProgramId}`).digest('hex');
1315
+ return path.join(cacheDirectory, `${scopeHash}.json`);
1316
+ }
1317
+ async function readScopeFile(scopeFile) {
1318
+ let contents;
1319
+ try {
1320
+ contents = await fs$1.readFile(scopeFile, 'utf8');
1321
+ }
1322
+ catch (error) {
1323
+ if (isNodeError(error) && error.code === 'ENOENT') {
1324
+ return undefined;
1325
+ }
1326
+ throw error;
1327
+ }
1328
+ try {
1329
+ const parsed = JSON.parse(contents);
1330
+ if (!isPersistedOverride(parsed)) {
1331
+ throw new TypeError('invalid cache schema');
1332
+ }
1333
+ return cloneOverrides(parsed.overrides);
1334
+ }
1335
+ catch (error) {
1336
+ throw new DevPermissionOverrideStoreError('hb-sdk 本地权限覆盖缓存已损坏', 'DEV_PERMISSION_OVERRIDE_CACHE_CORRUPT', {
1337
+ cause: error,
1338
+ });
1339
+ }
1340
+ }
1341
+ async function writeScopeFileAtomically(scopeFile, value) {
1342
+ const tempFile = `${scopeFile}.${process.pid}.${node_crypto.randomBytes(8).toString('hex')}.tmp`;
1343
+ let handle;
1344
+ try {
1345
+ handle = await fs$1.open(tempFile, 'wx', 0o600);
1346
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
1347
+ await handle.sync();
1348
+ await handle.close();
1349
+ handle = undefined;
1350
+ await fs$1.rename(tempFile, scopeFile);
1351
+ }
1352
+ finally {
1353
+ await handle?.close().catch(() => undefined);
1354
+ await fs$1.unlink(tempFile).catch(() => undefined);
1355
+ }
1356
+ }
1357
+ function createWriteError(cause) {
1358
+ return new DevPermissionOverrideStoreError('无法写入 hb-sdk 本地权限覆盖缓存', 'DEV_PERMISSION_OVERRIDE_WRITE_FAILED', { cause });
1359
+ }
1360
+ function cloneOverrides(overrides) {
1361
+ return {
1362
+ networkRequest: { ...overrides.networkRequest },
1363
+ };
1364
+ }
1365
+ function isPersistedOverride(value) {
1366
+ return isRecord(value) && value.version === CACHE_VERSION && Object.keys(value).length === 2 && isPermissionState(value.overrides);
1367
+ }
1368
+ function isPermissionState(value) {
1369
+ return (isRecord(value) &&
1370
+ Object.keys(value).length === 1 &&
1371
+ isRecord(value.networkRequest) &&
1372
+ (value.networkRequest.status === 'enabled' || value.networkRequest.status === 'disabled') &&
1373
+ typeof value.networkRequest.useOfficialDomain === 'boolean');
1374
+ }
1375
+ function isRecord(value) {
1376
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1377
+ }
1378
+ function isNodeError(error) {
1379
+ return error instanceof Error && 'code' in error;
1380
+ }
674
1381
 
675
1382
  const DEFAULT_APP_PORT = 5173;
676
1383
  const DEFAULT_MOCK_PORT = 5174;
@@ -706,6 +1413,20 @@ async function runDevCommand(options, runtime = {}) {
706
1413
  projectRoot,
707
1414
  timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
708
1415
  });
1416
+ const permissionController = createDevPermissionController({
1417
+ boundMiniProgramId: await context.readBoundMiniProgramId(projectRoot),
1418
+ cacheRoot: runtime.devPermissionCacheRoot,
1419
+ devContext: devLaunchContext,
1420
+ loadRemoteContext: () => loadDevLaunchContext({
1421
+ env: runtime.env,
1422
+ fetchImpl,
1423
+ logger,
1424
+ projectRoot,
1425
+ requireRemote: true,
1426
+ timeoutMs: runtime.devContextTimeoutMs ?? DEFAULT_DEV_CONTEXT_TIMEOUT_MS,
1427
+ }),
1428
+ projectRoot,
1429
+ });
709
1430
  const vite = await logger.task('正在加载项目 Vite', () => loadProjectVite(projectRoot), { successText: '已加载项目 Vite' });
710
1431
  const appServer = await logger.task('正在创建 Vite dev server', () => withManagedDevOutputEnv(() => vite.createServer(createViteServerOptions(projectRoot, resolvedOptions)), runtime.env), { successText: '已创建 Vite dev server' });
711
1432
  await logger.task('正在启动 Vite dev server', () => appServer.listen(appPort), {
@@ -733,11 +1454,13 @@ async function runDevCommand(options, runtime = {}) {
733
1454
  getPort: getPortImpl,
734
1455
  defaultLanAddressId: lanAddresses[0]?.id,
735
1456
  lanAddresses,
1457
+ lookupHostname: runtime.lookupHostname,
736
1458
  macAppProtocol: createMacAppProtocol(appUrl, {
737
1459
  miniProgramId: devLaunchContext.miniProgramId,
738
1460
  runtimeUrl: options.runtimeUrl,
739
1461
  sdkVersion: HB_SDK_VERSION,
740
1462
  }),
1463
+ permissionController,
741
1464
  port: options.mockPort ?? DEFAULT_MOCK_PORT,
742
1465
  root: runtime.mockHostRoot,
743
1466
  rootCandidates: runtime.mockHostRootCandidates,
@@ -767,6 +1490,9 @@ async function loadDevLaunchContext(options) {
767
1490
  const miniProgramId = await context.readBoundMiniProgramId(options.projectRoot);
768
1491
  if (!miniProgramId) {
769
1492
  const anonymousReason = '当前项目未绑定小程序。请先运行 hb-sdk remote create 或 hb-sdk remote bind <mini-program-id>。';
1493
+ if (options.requireRemote) {
1494
+ throw new Error(anonymousReason);
1495
+ }
770
1496
  options.logger.warn(`未读取到远端 Runtime 权限,Mock runtime 将默认拒绝受管能力:${anonymousReason}`);
771
1497
  return {
772
1498
  source: 'anonymous',
@@ -793,6 +1519,9 @@ async function loadDevLaunchContext(options) {
793
1519
  if (error instanceof context.MiniProgramProjectBindingError && error.code !== 'MINI_PROGRAM_UNBOUND') {
794
1520
  throw error;
795
1521
  }
1522
+ if (options.requireRemote) {
1523
+ throw error;
1524
+ }
796
1525
  options.logger.debug(`远端 dev context 失败详情:${index.readRedactedErrorMessage(error, { verbose: true })}`);
797
1526
  options.logger.warn(`未读取到远端 Runtime 权限,Mock runtime 将默认拒绝受管能力:${DEV_CONTEXT_UNAVAILABLE_WARNING}`);
798
1527
  return {
@@ -801,6 +1530,78 @@ async function loadDevLaunchContext(options) {
801
1530
  };
802
1531
  }
803
1532
  }
1533
+ function createDevPermissionController(options) {
1534
+ const store = createDevPermissionOverrideStore({ cacheRoot: options.cacheRoot });
1535
+ let devContext = options.devContext;
1536
+ let ephemeralOverride;
1537
+ let persistenceError;
1538
+ const readScope = () => ({
1539
+ projectRoot: options.projectRoot,
1540
+ ...(options.boundMiniProgramId ? { miniProgramId: options.boundMiniProgramId } : {}),
1541
+ });
1542
+ const createConfiguration = (overrides, error = persistenceError) => {
1543
+ const miniProgramId = options.boundMiniProgramId ?? devContext.miniProgramId;
1544
+ return {
1545
+ source: devContext.source,
1546
+ ...(miniProgramId ? { mini_program_id: miniProgramId } : {}),
1547
+ ...(isRuntimePermissionsSnapshot(devContext.runtimePermissions) ? { remote_permissions: devContext.runtimePermissions } : {}),
1548
+ ...(overrides ? { override_permissions: createMiniProgramMockRuntimePermissions(overrides) } : {}),
1549
+ ...(error ? { persistence_error: error } : {}),
1550
+ };
1551
+ };
1552
+ const get = async () => {
1553
+ try {
1554
+ const persistedOverride = await store.read(readScope());
1555
+ return createConfiguration(ephemeralOverride ?? persistedOverride);
1556
+ }
1557
+ catch (error) {
1558
+ persistenceError = index.readErrorMessage(error);
1559
+ return createConfiguration(ephemeralOverride, persistenceError);
1560
+ }
1561
+ };
1562
+ return {
1563
+ get,
1564
+ async reset() {
1565
+ if (devContext.source === 'anonymous' && !options.boundMiniProgramId) {
1566
+ await store.remove(readScope());
1567
+ ephemeralOverride = undefined;
1568
+ persistenceError = undefined;
1569
+ return createConfiguration(undefined);
1570
+ }
1571
+ let refreshedContext;
1572
+ try {
1573
+ refreshedContext = await options.loadRemoteContext();
1574
+ }
1575
+ catch (error) {
1576
+ throw new Error('远端权限读取失败,请检查 CLI 登录状态和网络后重试。', { cause: error });
1577
+ }
1578
+ await store.remove(readScope());
1579
+ devContext = refreshedContext;
1580
+ ephemeralOverride = undefined;
1581
+ persistenceError = undefined;
1582
+ return createConfiguration(undefined);
1583
+ },
1584
+ async save(runtimePermissions) {
1585
+ const overrides = createMiniProgramMockPermissionState({
1586
+ runtimePermissions,
1587
+ });
1588
+ try {
1589
+ await store.set(readScope(), overrides);
1590
+ ephemeralOverride = undefined;
1591
+ persistenceError = undefined;
1592
+ return createConfiguration(overrides);
1593
+ }
1594
+ catch {
1595
+ ephemeralOverride = overrides;
1596
+ persistenceError = '保存失败,当前页面仍会应用该权限,但刷新或重启后会丢失。';
1597
+ return createConfiguration(overrides, persistenceError);
1598
+ }
1599
+ },
1600
+ };
1601
+ }
1602
+ function isRuntimePermissionsSnapshot(value) {
1603
+ return parseMiniProgramRuntimePermissions(value).valid;
1604
+ }
804
1605
  async function loadRemoteDevContextWithTimeout(options) {
805
1606
  const controller = new AbortController();
806
1607
  const fetchImpl = ((input, init) => options.fetchImpl(input, { ...init, signal: controller.signal }));