@open-wxa/wxa-runner-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,96 @@
1
1
  "use strict";
2
2
  (() => {
3
+ // src/runtime/host/ConsoleForwarder.ts
4
+ var DEDUPE_MS = 1e3;
5
+ var MAX_ARG_LENGTH = 1e3;
6
+ var MAX_TEXT_LENGTH = 8e3;
7
+ var LAYERS = { service: "\u903B\u8F91\u5C42", webview: "\u6E32\u67D3\u5C42" };
8
+ var MINI_PROGRAM_ERROR = /^(\(in promise\) )?MiniProgramError\n/;
9
+ var MODULE_URL = /\bhttps?:\/\/[^/\s]+\/__wxa__\/module\/([^?\s)]+?)(?:\?[^:\s)]*)?:(\d+):(\d+)/;
10
+ var ConsoleForwarder = class {
11
+ /** @param pageOf 出错的页面路径,不知道时返回 undefined */
12
+ constructor(mode, send, pageOf) {
13
+ this.mode = mode;
14
+ this.send = send;
15
+ this.pageOf = pageOf;
16
+ this.recentErrors = /* @__PURE__ */ new Map();
17
+ }
18
+ /** 运行时错误:全局 error、未处理的 Promise 拒绝、基础库捕获的业务代码错误 */
19
+ error(kind, webviewId, message, stack) {
20
+ const frames = businessFrames(stack);
21
+ if (!this.mode || this.isRepeated(`${kind}:${webviewId}:${message}
22
+ ${frames}`)) return;
23
+ this.post("error", `${this.origin(kind, webviewId)}\u62A5\u9519\uFF1A${message}${frames ? `
24
+ ${frames}` : ""}`);
25
+ }
26
+ /** 运行时自己的提醒,如调用了未实现的 wx API */
27
+ warn(text) {
28
+ if (this.mode) this.post("warn", text);
29
+ }
30
+ /** iframe 里的 console.warn / console.error;基础库捕获业务代码错误后打印的 console.error 按报错转发 */
31
+ console(kind, webviewId, level, args) {
32
+ const thrown = level === "error" ? miniProgramError(args[0]) : null;
33
+ if (thrown) {
34
+ this.error(kind, webviewId, thrown.message, thrown.stack);
35
+ } else if (this.mode === "all") {
36
+ this.post(level, `${this.origin(kind, webviewId)} console.${level}\uFF1A${args.map(formatArg).join(" ")}`);
37
+ }
38
+ }
39
+ post(level, text) {
40
+ this.send({ type: "console", level, text: text.length > MAX_TEXT_LENGTH ? `${text.slice(0, MAX_TEXT_LENGTH)}\u2026` : text });
41
+ }
42
+ origin(kind, webviewId) {
43
+ const page = this.pageOf(kind, webviewId);
44
+ return page ? `${LAYERS[kind]}\uFF08${page}\uFF09` : LAYERS[kind];
45
+ }
46
+ /** @param key 来源、错误信息和业务代码的帧:两路报来的同一个错误相同,不同位置抛出的同名错误不同 */
47
+ isRepeated(key) {
48
+ const now = Date.now();
49
+ for (const [recent, time] of this.recentErrors) {
50
+ if (now - time >= DEDUPE_MS) this.recentErrors.delete(recent);
51
+ }
52
+ if (this.recentErrors.has(key)) return true;
53
+ this.recentErrors.set(key, now);
54
+ return false;
55
+ }
56
+ };
57
+ function isErrorLike(value) {
58
+ return typeof value === "object" && value !== null && typeof value.message === "string";
59
+ }
60
+ function miniProgramError(value) {
61
+ if (isErrorLike(value) && MINI_PROGRAM_ERROR.test(value.message)) {
62
+ return { message: value.message.replace(MINI_PROGRAM_ERROR, ""), stack: value.stack };
63
+ }
64
+ if (typeof value === "string" && MINI_PROGRAM_ERROR.test(value)) {
65
+ const text = value.replace(MINI_PROGRAM_ERROR, "");
66
+ const firstFrame2 = text.search(/^\s*at /m);
67
+ const head = (firstFrame2 < 0 ? text : text.slice(0, firstFrame2)).replace(/\n$/, "");
68
+ const message = /^([\s\S]*)\n[^\n]*?: \1$/.exec(head)?.[1] ?? head;
69
+ return { message, stack: firstFrame2 < 0 ? void 0 : text.slice(firstFrame2) };
70
+ }
71
+ return null;
72
+ }
73
+ function businessFrames(stack) {
74
+ return (stack || "").split("\n").filter((line) => /^\s*at /.test(line) && MODULE_URL.test(line)).map((line) => ` ${line.trim().replace(MODULE_URL, "$1:$2:$3")}`).join("\n");
75
+ }
76
+ function formatArg(arg) {
77
+ if (typeof arg === "string") return truncate(arg);
78
+ if (isErrorLike(arg)) {
79
+ const frames = businessFrames(arg.stack);
80
+ return truncate(`${arg.name || "Error"}: ${arg.message}`) + (frames ? `
81
+ ${frames}` : "");
82
+ }
83
+ try {
84
+ const json = JSON.stringify(arg);
85
+ if (json !== void 0) return truncate(json);
86
+ } catch {
87
+ }
88
+ return truncate(String(arg));
89
+ }
90
+ function truncate(text) {
91
+ return text.length > MAX_ARG_LENGTH ? `${text.slice(0, MAX_ARG_LENGTH)}\u2026` : text;
92
+ }
93
+
3
94
  // src/runtime/host/api/registry.ts
4
95
  var SILENT_APIS = /* @__PURE__ */ new Set([
5
96
  "reportKeyValue",
@@ -205,154 +296,103 @@
205
296
  };
206
297
  }
207
298
 
208
- // src/runtime/host/api/tempFiles.ts
209
- var TEMP_DIR = "/__wxa__/tmp";
210
- async function saveTempFile(data, type) {
211
- const res = await fetch(`${TEMP_DIR}?type=${encodeURIComponent(type)}`, { method: "POST", body: data });
212
- if (!res.ok) throw new Error(await res.text());
213
- const { url } = await res.json();
214
- return url;
215
- }
216
- function tempFileRootPath(url) {
217
- const prefix = `${location.origin}${TEMP_DIR}/`;
218
- return url.startsWith(prefix) ? url.slice(location.origin.length) : url;
219
- }
220
- function tempFileType(name, mimeType, fallback) {
221
- const fromName = /\.([a-z\d]+)$/i.exec(name)?.[1];
222
- const fromType = /^[a-z]+\/([a-z\d]+)\s*(;|$)/i.exec(mimeType)?.[1];
223
- return (fromName || fromType || fallback).toLowerCase();
224
- }
225
- function isTempFile(filePath) {
226
- return typeof filePath === "string" && filePath.startsWith(`${location.origin}${TEMP_DIR}/`);
227
- }
228
- async function tempFileSize(url) {
229
- const res = await fetch(url, { method: "HEAD" });
230
- return res.ok ? Number(res.headers.get("content-length")) : null;
231
- }
232
- async function removeTempFile(url) {
233
- await fetch(url, { method: "DELETE" });
299
+ // src/runtime/fileSession.ts
300
+ var KEY = "__wxa_file_session__";
301
+ function fileSession() {
302
+ let session = localStorage.getItem(KEY);
303
+ if (!session) {
304
+ session = crypto.randomUUID();
305
+ localStorage.setItem(KEY, session);
306
+ }
307
+ return session;
234
308
  }
235
309
 
236
310
  // src/runtime/host/api/fs.ts
237
- var DIR_MODE = 16877;
238
- var FILE_MODE = 33188;
239
- var MemoryFileSystem = class {
240
- constructor() {
241
- this.nodes = /* @__PURE__ */ new Map([["/", { dir: true, data: "", mtime: Date.now() }]]);
242
- }
243
- normalize(filePath) {
244
- const stripped = String(filePath || "").replace(/^[a-z]+:\/\/[^/]*/i, "");
245
- const parts = [];
246
- for (const part of stripped.split("/")) {
247
- if (!part || part === ".") continue;
248
- if (part === "..") parts.pop();
249
- else parts.push(part);
250
- }
251
- return "/" + parts.join("/");
252
- }
253
- stat(filePath) {
254
- return this.nodes.get(this.normalize(filePath));
255
- }
256
- mkdir(dirPath, recursive = false) {
257
- const target = this.normalize(dirPath);
258
- if (this.nodes.has(target)) throw new Error(`file already exists ${dirPath}`);
259
- const parent = target.slice(0, target.lastIndexOf("/")) || "/";
260
- if (!this.nodes.get(parent)?.dir) {
261
- if (!recursive) throw new Error(`no such file or directory ${dirPath}`);
262
- this.mkdir(parent, true);
263
- }
264
- this.nodes.set(target, { dir: true, data: "", mtime: Date.now() });
265
- }
266
- write(filePath, data, append = false) {
267
- const target = this.normalize(filePath);
268
- const parent = target.slice(0, target.lastIndexOf("/")) || "/";
269
- if (!this.nodes.get(parent)?.dir) throw new Error(`no such file or directory ${filePath}`);
270
- const existing = this.nodes.get(target);
271
- if (existing?.dir) throw new Error(`illegal operation on a directory ${filePath}`);
272
- this.nodes.set(target, { dir: false, data: append && existing ? existing.data + data : data, mtime: Date.now() });
273
- }
274
- read(filePath) {
275
- const node = this.stat(filePath);
276
- if (!node) throw new Error(`no such file or directory, open ${filePath}`);
277
- if (node.dir) throw new Error(`illegal operation on a directory ${filePath}`);
278
- return node.data;
279
- }
280
- readdir(dirPath) {
281
- const target = this.normalize(dirPath);
282
- if (!this.nodes.get(target)?.dir) throw new Error(`no such file or directory ${dirPath}`);
283
- const prefix = target === "/" ? "/" : target + "/";
284
- return [...this.nodes.keys()].filter((key) => key.startsWith(prefix) && key !== target && !key.slice(prefix.length).includes("/")).map((key) => key.slice(prefix.length));
285
- }
286
- remove(filePath, options) {
287
- const target = this.normalize(filePath);
288
- const node = this.nodes.get(target);
289
- if (!node) throw new Error(`no such file or directory ${filePath}`);
290
- if (node.dir !== options.dir) throw new Error(options.dir ? `not a directory ${filePath}` : `operation not permitted ${filePath}`);
291
- if (node.dir) {
292
- const children = this.readdir(target);
293
- if (children.length && !options.recursive) throw new Error(`directory not empty ${filePath}`);
294
- for (const key of [...this.nodes.keys()]) if (key.startsWith(target + "/")) this.nodes.delete(key);
295
- }
296
- this.nodes.delete(target);
297
- }
298
- };
299
- function createFileSystemApis() {
300
- const fs = new MemoryFileSystem();
301
- const guard = (api, action) => {
302
- try {
303
- return ok(api, action() || {});
304
- } catch (error) {
305
- return fail(api, error.message);
306
- }
307
- };
308
- const statResult = (node) => {
309
- const stats = {
310
- mode: node.dir ? DIR_MODE : FILE_MODE,
311
- size: node.data.length,
312
- lastAccessedTime: Math.floor(node.mtime / 1e3),
313
- lastModifiedTime: Math.floor(node.mtime / 1e3)
314
- };
315
- return { stats, ...stats };
316
- };
317
- const readMemoryFile = (args, { api }) => guard(api, () => ({ data: fs.read(args.filePath) }));
318
- const apis = {
319
- access: (args, { api }) => fs.stat(args.path) ? ok(api) : fail(api, `no such file or directory ${args.path}`),
320
- stat: (args, { api }) => {
321
- const node = fs.stat(args.path);
322
- return node ? ok(api, statResult(node)) : fail(api, `no such file or directory ${args.path}`);
323
- },
324
- mkdir: (args, { api }) => guard(api, () => fs.mkdir(args.dirPath, !!args.recursive)),
325
- writeFile: (args, { api }) => guard(api, () => fs.write(args.filePath, String(args.data ?? ""))),
326
- appendFile: (args, { api }) => guard(api, () => fs.write(args.filePath, String(args.data ?? ""), true)),
327
- readdir: (args, { api }) => guard(api, () => ({ files: fs.readdir(args.dirPath) })),
328
- unlink: (args, { api }) => guard(api, () => fs.remove(args.filePath, { dir: false })),
329
- rmdir: (args, { api }) => guard(api, () => fs.remove(args.dirPath, { dir: true, recursive: !!args.recursive })),
330
- getFileInfo: (args, { api }) => {
331
- if (isTempFile(args.filePath)) {
332
- return tempFileSize(args.filePath).then((size) => size === null ? fail(api, "file not exist") : ok(api, { size }));
311
+ function requestFile(operation, args, sync) {
312
+ const url = "/__wxa__/fs/" + fileSession();
313
+ const body = JSON.stringify({ operation, args });
314
+ if (!sync)
315
+ return fetch(url, {
316
+ method: "POST",
317
+ headers: { "content-type": "application/json" },
318
+ body
319
+ }).then(async (response) => fileResult(response.status, await response.text()));
320
+ const request = new XMLHttpRequest();
321
+ request.open("POST", url, false);
322
+ request.setRequestHeader("content-type", "application/json");
323
+ request.send(body);
324
+ return fileResult(request.status, request.responseText);
325
+ }
326
+ function fileResult(status, text) {
327
+ if (status !== 200) throw new Error(`filesystem HTTP ${status}`);
328
+ const { result, error } = JSON.parse(text);
329
+ if (error) throw new Error(error);
330
+ return result;
331
+ }
332
+ function createFileSystemApis(request = requestFile) {
333
+ const apis = {};
334
+ for (const operation of [
335
+ "access",
336
+ "stat",
337
+ "mkdir",
338
+ "writeFile",
339
+ "appendFile",
340
+ "readdir",
341
+ "unlink",
342
+ "rmdir",
343
+ "getFileInfo",
344
+ "copyFile",
345
+ "readFile"
346
+ ]) {
347
+ const handler = (args, { api }) => {
348
+ try {
349
+ const input = { ...args };
350
+ const isBuffer = Object.prototype.toString.call(input.data) === "[object ArrayBuffer]";
351
+ if (isBuffer || ArrayBuffer.isView(input.data)) {
352
+ const view = isBuffer ? new Uint8Array(input.data) : new Uint8Array(input.data.buffer, input.data.byteOffset, input.data.byteLength);
353
+ let binary = "";
354
+ for (let offset = 0; offset < view.length; offset += 32768) {
355
+ binary += String.fromCharCode(...view.subarray(offset, offset + 32768));
356
+ }
357
+ input.data = btoa(binary);
358
+ input.binary = true;
359
+ }
360
+ const decode = (result2) => {
361
+ if (result2.binary) {
362
+ result2.data = Uint8Array.from(atob(result2.data), (character) => character.charCodeAt(0)).buffer;
363
+ delete result2.binary;
364
+ }
365
+ return ok(api, result2);
366
+ };
367
+ const result = request(operation, input, api.endsWith("Sync"));
368
+ return result instanceof Promise ? result.then(decode, (error) => fail(api, error.message)) : decode(result);
369
+ } catch (error) {
370
+ return fail(api, error.message);
333
371
  }
334
- const node = fs.stat(args.filePath);
335
- return node ? ok(api, { size: node.data.length }) : fail(api, "file not exist");
336
- }
337
- };
338
- for (const name of Object.keys(apis)) apis[name + "Sync"] = apis[name];
339
- return {
340
- ...apis,
341
- fs_appendFile: apis.appendFile,
342
- // 不带协议的是代码包里的文件,要向开发服务器取;readFileSync 同步返回不了,只读内存文件系统。
343
- // 内存文件系统的路径去掉了协议,不能先在里面找:用户目录下的同名文件会顶替代码包里的
344
- readFile: (args, context) => /^[a-z]+:\/\//i.test(String(args.filePath)) ? readMemoryFile(args, context) : readPackageFile(fs.normalize(args.filePath), args.encoding).then(
345
- (data) => ok(context.api, { data }),
346
- (error) => fail(context.api, error.message)
347
- ),
348
- readFileSync: readMemoryFile
349
- };
372
+ };
373
+ apis[operation] = handler;
374
+ apis[operation + "Sync"] = handler;
375
+ apis["fs_" + operation] = handler;
376
+ apis["fs_" + operation + "Sync"] = handler;
377
+ }
378
+ const readUserFile = apis.readFile;
379
+ apis.readFile = (args, context) => /^[a-z]+:\/\//i.test(String(args.filePath)) || /^\/__wxa__\/(tmp|usr)\//.test(String(args.filePath)) ? readUserFile(args, context) : readPackageFile(args.filePath, args.encoding).then(
380
+ (data) => ok(context.api, { data }),
381
+ (error) => fail(context.api, error.message)
382
+ );
383
+ apis.fs_readFile = apis.readFile;
384
+ return apis;
350
385
  }
351
386
  async function readPackageFile(filePath, encoding) {
352
387
  if (encoding !== void 0 && encoding !== "utf8" && encoding !== "utf-8") {
353
388
  throw new Error(`\u8BFB\u53D6\u4EE3\u7801\u5305\u91CC\u7684\u6587\u4EF6\u53EA\u652F\u6301 utf8 \u7F16\u7801\uFF0C\u6216\u4E0D\u6307\u5B9A\u7F16\u7801\u5F97\u5230 ArrayBuffer\uFF0C\u4E0D\u652F\u6301 ${encoding}`);
354
389
  }
355
- const response = await fetch(filePath.split("/").map(encodeURIComponent).join("/"));
390
+ const parts = [];
391
+ for (const part of String(filePath).split("/")) {
392
+ if (part === "..") parts.pop();
393
+ else if (part && part !== ".") parts.push(part);
394
+ }
395
+ const response = await fetch("/" + parts.map(encodeURIComponent).join("/"));
356
396
  if (!response.ok) throw new Error(`no such file or directory, open ${filePath}`);
357
397
  return encoding ? response.text() : response.arrayBuffer();
358
398
  }
@@ -429,6 +469,34 @@
429
469
  }
430
470
  };
431
471
 
472
+ // src/runtime/host/api/tempFiles.ts
473
+ var TEMP_DIR = "/__wxa__/tmp";
474
+ async function saveTempFile(data, type) {
475
+ const res = await fetch(`${TEMP_DIR}/${fileSession()}?type=${encodeURIComponent(type)}`, { method: "POST", body: data });
476
+ if (!res.ok) throw new Error(await res.text());
477
+ const { url } = await res.json();
478
+ return url;
479
+ }
480
+ function tempFileRootPath(url) {
481
+ const prefix = `${location.origin}${TEMP_DIR}/`;
482
+ return url.startsWith(prefix) ? url.slice(location.origin.length) : url;
483
+ }
484
+ function tempFileType(name, mimeType, fallback) {
485
+ const fromName = /\.([a-z\d]+)$/i.exec(name)?.[1];
486
+ const fromType = /^[a-z]+\/([a-z\d]+)\s*(;|$)/i.exec(mimeType)?.[1];
487
+ return (fromName || fromType || fallback).toLowerCase();
488
+ }
489
+ function isTempFile(filePath) {
490
+ return typeof filePath === "string" && filePath.startsWith(`${location.origin}${TEMP_DIR}/`);
491
+ }
492
+ async function tempFileSize(url) {
493
+ const res = await fetch(url, { method: "HEAD" });
494
+ return res.ok ? Number(res.headers.get("content-length")) : null;
495
+ }
496
+ async function removeTempFile(url) {
497
+ await fetch(url, { method: "DELETE" });
498
+ }
499
+
432
500
  // src/runtime/host/api/media.ts
433
501
  var SOURCES = ["camera", "album"];
434
502
  var SOURCE_LABELS = { camera: "\u62CD\u6444", album: "\u4ECE\u624B\u673A\u76F8\u518C\u9009\u62E9" };
@@ -630,6 +698,32 @@
630
698
  return fail(api, "not available in wxa-runner");
631
699
  }
632
700
 
701
+ // src/runtime/networkPanel.ts
702
+ var PROXY_PREFIX = "/__wxa__/proxy/";
703
+ var MOCK_PREFIX = "/__wxa__/mock/";
704
+ var MOCK_ID_HEADER = "x-wxa-mock-id";
705
+ function toServerPath(prefix, url) {
706
+ let target;
707
+ try {
708
+ target = new URL(url);
709
+ } catch {
710
+ return null;
711
+ }
712
+ if (target.protocol !== "http:" && target.protocol !== "https:") return null;
713
+ return `${prefix}${target.protocol.slice(0, -1)}/${target.host}${target.pathname}${target.search}`;
714
+ }
715
+ function toHeaders(header) {
716
+ const headers = new Headers();
717
+ for (const [name, value] of Object.entries(header)) {
718
+ if (value === void 0 || value === null) continue;
719
+ try {
720
+ headers.set(name, String(value));
721
+ } catch {
722
+ }
723
+ }
724
+ return headers;
725
+ }
726
+
633
727
  // src/runtime/host/api/network.ts
634
728
  var RequestLog = class {
635
729
  constructor(limit = 1e3) {
@@ -676,8 +770,9 @@
676
770
  options.onRequest?.({ ...request, header: { ...request.header }, routedBy: route ? route.pattern : null });
677
771
  const requestTaskId = startTask(args, async (signal, id) => {
678
772
  try {
679
- const mocked = route && await route.respond(signal);
680
- const res = mocked ? { statusCode: mocked.statusCode, header: mocked.header, cookies: [], response: new Response(mocked.body) } : await proxyFetch(request.url, { method: request.method, header: args.header, body: args.data, signal });
773
+ const mocked = route?.respond(signal);
774
+ if (mocked) options.onMocked?.(request, mocked, signal);
775
+ const res = mocked ? await mocked.then(({ statusCode, header, body }) => ({ statusCode, header, cookies: [], response: new Response(body) })) : await proxyFetch(request.url, { method: request.method, header: args.header, body: args.data, signal });
681
776
  emit("onRequestTaskStateChange", {
682
777
  requestTaskId: id,
683
778
  state: "headersReceived",
@@ -685,7 +780,36 @@
685
780
  statusCode: res.statusCode,
686
781
  cookies: res.cookies
687
782
  });
688
- const data = args.responseType === "arraybuffer" ? await res.response.arrayBuffer() : await res.response.text();
783
+ let data;
784
+ if (args.enableChunked && res.response.body) {
785
+ const reader = res.response.body.getReader();
786
+ const chunks = [];
787
+ let length = 0;
788
+ try {
789
+ for (; ; ) {
790
+ const { done, value } = await reader.read();
791
+ if (done) break;
792
+ chunks.push(value);
793
+ length += value.byteLength;
794
+ emit("onRequestTaskStateChange", {
795
+ requestTaskId: id,
796
+ state: "chunked",
797
+ data: value.slice().buffer
798
+ });
799
+ }
800
+ } finally {
801
+ reader.releaseLock();
802
+ }
803
+ const bytes = new Uint8Array(length);
804
+ let offset = 0;
805
+ for (const chunk of chunks) {
806
+ bytes.set(chunk, offset);
807
+ offset += chunk.byteLength;
808
+ }
809
+ data = args.responseType === "arraybuffer" ? bytes.buffer : new TextDecoder().decode(bytes);
810
+ } else {
811
+ data = args.responseType === "arraybuffer" ? await res.response.arrayBuffer() : await res.response.text();
812
+ }
689
813
  emit("onRequestTaskStateChange", {
690
814
  requestTaskId: id,
691
815
  state: "success",
@@ -733,7 +857,9 @@
733
857
  const createUploadTask = (args, { api }) => {
734
858
  const uploadTaskId = startTask(args, async (signal, id) => {
735
859
  try {
736
- const file = await (await fetch(args.filePath)).blob();
860
+ const source = await fetch(args.filePath);
861
+ if (!source.ok) throw new Error(`file not exist ${args.filePath}`);
862
+ const file = await source.blob();
737
863
  const form = new FormData();
738
864
  for (const [key, value] of Object.entries(args.formData || {})) form.append(key, String(value));
739
865
  form.append(args.name || "file", file, String(args.filePath).split("/").pop() || "file");
@@ -824,12 +950,12 @@
824
950
  return args.responseType === "arraybuffer" && args.enableCache === true;
825
951
  }
826
952
  async function proxyFetch(url, init) {
827
- if (!/^https?:\/\//i.test(url)) throw new Error(`invalid url "${url}"`);
828
- const hasBody = init.body !== void 0 && init.body !== "" && !["GET", "HEAD"].includes(init.method);
829
- const response = await fetch(`/__wxa__/proxy?url=${encodeURIComponent(url)}`, {
953
+ const path = toServerPath(PROXY_PREFIX, url);
954
+ if (!path) throw new Error(`invalid url "${url}"`);
955
+ const response = await fetch(path, {
830
956
  method: init.method,
831
957
  headers: { "x-wxa-headers": JSON.stringify(init.header || {}) },
832
- body: hasBody ? init.body : void 0,
958
+ body: hasRequestBody(init.method, init.body) ? init.body : void 0,
833
959
  signal: init.signal
834
960
  });
835
961
  if (response.headers.get("x-wxa-proxy-error")) {
@@ -842,11 +968,56 @@
842
968
  response
843
969
  };
844
970
  }
971
+ function hasRequestBody(method, body) {
972
+ return body !== void 0 && body !== "" && !["GET", "HEAD"].includes(method);
973
+ }
845
974
  function reason(error) {
846
975
  if (error instanceof DOMException && error.name === "AbortError") return "abort";
847
976
  return error instanceof Error ? error.message : String(error);
848
977
  }
849
978
 
979
+ // src/runtime/host/api/mockMirror.ts
980
+ var SERVICE_WORKER_URL = "/__wxa__/runtime/sw.js";
981
+ var MockMirror = class {
982
+ constructor(serviceWorker) {
983
+ this.serviceWorker = serviceWorker;
984
+ /** 正在显示的请求,Service Worker 按 id 来要响应 */
985
+ this.replies = /* @__PURE__ */ new Map();
986
+ this.seq = 0;
987
+ serviceWorker.addEventListener("message", (event) => this.answer(event));
988
+ serviceWorker.startMessages();
989
+ }
990
+ /** response 失败(规则出错、请求被取消)时这一行显示为失败;signal abort 时显示为取消 */
991
+ show(request, response, signal) {
992
+ const path = toServerPath(MOCK_PREFIX, request.url);
993
+ if (!path || !this.serviceWorker.controller) return;
994
+ const id = String(++this.seq);
995
+ const cancelled = new Promise((resolve) => signal.addEventListener("abort", () => resolve(null), { once: true }));
996
+ this.replies.set(id, Promise.race([response.catch(() => null), cancelled]));
997
+ const headers = toHeaders(request.header);
998
+ headers.set(MOCK_ID_HEADER, id);
999
+ fetch(path, {
1000
+ method: request.method,
1001
+ headers,
1002
+ body: hasRequestBody(request.method, request.data) ? request.data : void 0,
1003
+ // 拦截规则给出 3xx 时只显示这个响应:跟随 Location 会真的发出请求,小程序那边也不跟随
1004
+ redirect: "manual",
1005
+ signal
1006
+ }).catch(() => void 0).finally(() => this.replies.delete(id));
1007
+ }
1008
+ answer(event) {
1009
+ const query = event.data;
1010
+ const port = event.ports[0];
1011
+ if (query?.type !== "wxa-mock-response" || !port) return;
1012
+ (this.replies.get(query.id) ?? Promise.resolve(null)).then((reply) => port.postMessage(reply));
1013
+ }
1014
+ };
1015
+ function installMockMirror() {
1016
+ if (navigator.webdriver || !navigator.serviceWorker) return null;
1017
+ navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: "/" }).catch((error) => console.warn("[wxa-runner] Service Worker \u6CE8\u518C\u5931\u8D25\uFF0C\u88AB\u62E6\u622A\u7684\u8BF7\u6C42\u4E0D\u4F1A\u663E\u793A\u5728 Network \u9762\u677F\u91CC", error));
1018
+ return new MockMirror(navigator.serviceWorker);
1019
+ }
1020
+
850
1021
  // src/runtime/host/api/picker.ts
851
1022
  async function fetchRegionData(kind) {
852
1023
  const res = await fetch(`/__wxa__/region/${kind}`);
@@ -2285,7 +2456,8 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2285
2456
  var BINDING_CANVAS_TYPES = /* @__PURE__ */ new Set(["2d", "webgl", "webgl2"]);
2286
2457
  var PROBE_TOP = -54321;
2287
2458
  var Host = class {
2288
- constructor(info, root) {
2459
+ /** @param sendReport 把报错发给开发服务器打印到终端,见 AppInfo.forwardConsole */
2460
+ constructor(info, root, sendReport = () => void 0) {
2289
2461
  this.info = info;
2290
2462
  this.errors = [];
2291
2463
  /** 小程序发出的 wx.request */
@@ -2293,6 +2465,8 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2293
2465
  this.api = new ApiRegistry((context) => this.reportUnimplemented(context));
2294
2466
  /** setup 和测试 API 声明的请求拦截 */
2295
2467
  this.routes = new RequestRoutes();
2468
+ /** 把被拦截的请求显示在 Network 面板里 */
2469
+ this.mockMirror = installMockMirror();
2296
2470
  this.mapOperations = new MapOperations((webviewId, event, data) => this.publishToPages(event, data, [webviewId]));
2297
2471
  this.service = null;
2298
2472
  /** 新版基础库的业务子上下文;旧版基础库业务代码直接在逻辑层 iframe 里 */
@@ -2309,6 +2483,11 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2309
2483
  /** 渲染层报来的 2d、webgl 画布,按编号记下所在页面和最近的位置,供 placeCanvas 认出逻辑层的画布元素 */
2310
2484
  this.canvases = /* @__PURE__ */ new Map();
2311
2485
  this.device = info.device;
2486
+ this.forwarder = new ConsoleForwarder(
2487
+ info.forwardConsole ?? false,
2488
+ sendReport,
2489
+ (kind, webviewId) => kind === "service" ? this.currentPage()?.path : this.pages.get(webviewId)?.path
2490
+ );
2312
2491
  this.shell = new PhoneShell(root, info.device);
2313
2492
  this.tabPaths = new Set((info.wxConfig.tabBar?.list || []).map((item) => stripHtml(item.pagePath)));
2314
2493
  this.storage = new MiniProgramStorage(window.localStorage, info.appid);
@@ -2427,27 +2606,36 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2427
2606
  }
2428
2607
  reportError(kind, webviewId, error) {
2429
2608
  const err = typeof error?.message === "string" ? error : null;
2430
- this.errors.push({
2609
+ const report = {
2431
2610
  kind,
2432
2611
  webviewId,
2433
2612
  message: err ? err.message : String(error),
2434
2613
  stack: typeof err?.stack === "string" ? err.stack : void 0
2435
- });
2614
+ };
2615
+ this.errors.push(report);
2616
+ this.forwarder.error(kind, webviewId, report.message, report.stack);
2617
+ }
2618
+ get forwardConsole() {
2619
+ return this.forwarder.mode;
2620
+ }
2621
+ reportConsole(kind, webviewId, level, args) {
2622
+ this.forwarder.console(kind, webviewId, level, args);
2436
2623
  }
2437
2624
  /**
2438
- * 业务代码只运行在逻辑层,所以只记逻辑层调用、名字在 wx 上的 API,每个 API 一次。
2625
+ * 只记录逻辑层的公开 wx API 和 FileSystemManager 方法,每个原生接口一次。
2439
2626
  * 其余是基础库内部调用的原生接口(如 getPermissionBytes),业务既没调用也 mock 不了
2440
2627
  */
2441
2628
  reportUnimplemented({ api, from }) {
2629
+ if (from !== "service") return;
2442
2630
  const wx = this.serviceWindow()?.wx;
2443
- if (from !== "service" || typeof wx?.[api] !== "function") return;
2631
+ const fileMethod = api.startsWith("fs_") ? api.slice(3) : api;
2632
+ const isFileApi = typeof wx?.getFileSystemManager?.()[fileMethod] === "function";
2633
+ if (typeof wx?.[api] !== "function" && !isFileApi) return;
2444
2634
  if (this.errors.some((error) => error.api === api)) return;
2445
- this.errors.push({
2446
- kind: "service",
2447
- webviewId: 0,
2448
- api,
2449
- message: `wx.${api} \u5C1A\u672A\u5B9E\u73B0\uFF0C\u8FD4\u56DE\u4E86\u9ED8\u8BA4\u6210\u529F\u3002\u53EF\u4EE5\u7528 mock \u8986\u76D6\uFF0C\u542F\u52A8\u9636\u6BB5\u5C31\u8C03\u7528\u7684\u8981\u5728 setup \u91CC mock`
2450
- });
2635
+ const name = isFileApi ? `wx.getFileSystemManager().${fileMethod}` : `wx.${api}`;
2636
+ const message = `${name} \u5C1A\u672A\u5B9E\u73B0\uFF0C\u8FD4\u56DE\u4E86\u9ED8\u8BA4\u6210\u529F\u3002\u53EF\u4EE5\u7528 mock \u8986\u76D6\uFF0C\u542F\u52A8\u9636\u6BB5\u5C31\u8C03\u7528\u7684\u8981\u5728 setup \u91CC mock`;
2637
+ this.errors.push({ kind: "service", webviewId: 0, api, message });
2638
+ this.forwarder.warn(message);
2451
2639
  }
2452
2640
  // ---------------------------------------------------------------------------
2453
2641
  // 路由
@@ -2681,7 +2869,8 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2681
2869
  emit: (event, data) => this.emitToService(event, data),
2682
2870
  defaultTimeout: () => this.info.wxConfig.networkTimeout?.request || 6e4,
2683
2871
  routes: this.routes,
2684
- onRequest: (request) => this.requests.add(request)
2872
+ onRequest: (request) => this.requests.add(request),
2873
+ onMocked: (request, response, signal) => this.mockMirror?.show(request, response, signal)
2685
2874
  }),
2686
2875
  ...createAudioApis({ emit: (event, data) => this.emitToService(event, data) }),
2687
2876
  ...createMapApis({ registry: this.api, operations: this.mapOperations }),
@@ -3124,14 +3313,14 @@ ${script}
3124
3313
  const root = document.getElementById("wxa-root");
3125
3314
  const info = await (await fetch("/__wxa__/app-info.json")).json();
3126
3315
  const restore = new PageStackRestore(info.appid);
3127
- connectDevServer(restore);
3316
+ const sendReport = connectDevServer(restore);
3128
3317
  if (info.buildError) {
3129
3318
  new PhoneShell(root, info.device).showError(`\u7F16\u8BD1\u5931\u8D25
3130
3319
 
3131
3320
  ${info.buildError}`);
3132
3321
  return;
3133
3322
  }
3134
- const host = new Host(info, root);
3323
+ const host = new Host(info, root, sendReport);
3135
3324
  window.__wxaHost = host;
3136
3325
  let markReady;
3137
3326
  const ready = new Promise((resolve) => {
@@ -3199,6 +3388,13 @@ ${message.message}`);
3199
3388
  else restore.reload(host);
3200
3389
  }
3201
3390
  };
3391
+ const pending = [];
3392
+ socket.onopen = () => pending.splice(0).forEach((data) => socket.send(data));
3393
+ return (report) => {
3394
+ const data = JSON.stringify(report);
3395
+ if (socket.readyState === WebSocket.OPEN) socket.send(data);
3396
+ else if (socket.readyState === WebSocket.CONNECTING) pending.push(data);
3397
+ };
3202
3398
  }
3203
3399
  function currentHost() {
3204
3400
  return window.__wxaHost;