hw-weapp-compiler 1.0.23 → 1.0.24

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 CHANGED
@@ -53,6 +53,10 @@ module.exports = {
53
53
  server: 'XXX',
54
54
  bucket: 'XXX',
55
55
  dir: 'weapp',
56
+ // 可选:单次 SDK 请求超时、重试次数,以及编译器无进展兜底超时(秒)
57
+ timeout: 15,
58
+ max_retry_count: 1,
59
+ operation_timeout: 35,
56
60
  },
57
61
  ossConfig: {
58
62
  region: '<Your region>',
@@ -16,15 +16,198 @@ const { DIST_DIR } = require('../config/constants');
16
16
 
17
17
  const { obsConfig, ossConfig } = getConfig();
18
18
 
19
+ const UPLOAD_CONCURRENCY = 10;
20
+ const DEFAULT_OBS_TIMEOUT_SECONDS = 15;
21
+ const DEFAULT_OBS_MAX_RETRY_COUNT = 1;
22
+ const OBS_PENDING_WARNING_MS = 15 * 1000;
23
+ const OBS_TIMEOUT_BUFFER_MS = 5 * 1000;
24
+
19
25
  let obsClient;
20
26
  let ossClient;
21
27
  let progress;
22
28
  let uploadQueue = {};
29
+ let uploadRunner;
30
+
31
+ function getRemoteKey(config, file) {
32
+ return compatiblePath(path.join(config.dir, path.relative(DIST_DIR, file)));
33
+ }
34
+
35
+ function getDisplayFile(file) {
36
+ return compatiblePath(path.relative(process.cwd(), file));
37
+ }
38
+
39
+ function getObsStatus(value) {
40
+ return value && value.CommonMsg ? value.CommonMsg.Status : undefined;
41
+ }
42
+
43
+ function getInvalidObsResponseError(operation) {
44
+ const error = new Error(`OBS ${operation} returned no HTTP status`);
45
+ error.code = 'OBS_INVALID_RESPONSE';
46
+ return error;
47
+ }
48
+
49
+ function formatObsError(error) {
50
+ if (!error) {
51
+ return 'unknown error';
52
+ }
53
+
54
+ const commonMsg = error.CommonMsg || {};
55
+ const details = {
56
+ status: commonMsg.Status || error.statusCode,
57
+ code: commonMsg.Code || error.code,
58
+ message: commonMsg.Message || error.message,
59
+ requestId: commonMsg.RequestId || error.requestId,
60
+ };
61
+
62
+ const result = Object.entries(details)
63
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
64
+ .map(([key, value]) => `${key}=${value}`)
65
+ .join(', ');
66
+
67
+ return result || String(error);
68
+ }
69
+
70
+ function toNonNegativeNumber(value, fallback) {
71
+ const number = Number(value);
72
+ return Number.isFinite(number) && number >= 0 ? number : fallback;
73
+ }
74
+
75
+ function toPositiveNumber(value, fallback) {
76
+ const number = Number(value);
77
+ return Number.isFinite(number) && number > 0 ? number : fallback;
78
+ }
79
+
80
+ function getObsRuntimeOptions() {
81
+ const timeoutSeconds = toPositiveNumber(
82
+ obsConfig.timeout,
83
+ DEFAULT_OBS_TIMEOUT_SECONDS,
84
+ );
85
+ const maxRetryCount = toNonNegativeNumber(
86
+ obsConfig.max_retry_count,
87
+ DEFAULT_OBS_MAX_RETRY_COUNT,
88
+ );
89
+ const defaultOperationTimeoutMs =
90
+ timeoutSeconds * (maxRetryCount + 1) * 1000 + OBS_TIMEOUT_BUFFER_MS;
91
+ const operationTimeoutMs = obsConfig.operation_timeout
92
+ ? toPositiveNumber(
93
+ obsConfig.operation_timeout,
94
+ defaultOperationTimeoutMs / 1000,
95
+ ) * 1000
96
+ : defaultOperationTimeoutMs;
97
+
98
+ return {
99
+ timeoutSeconds,
100
+ maxRetryCount,
101
+ operationTimeoutMs,
102
+ };
103
+ }
104
+
105
+ function createObsRequestLogger(operation, file, key) {
106
+ const startedAt = Date.now();
107
+ const { timeoutSeconds, maxRetryCount, operationTimeoutMs } = getObsRuntimeOptions();
108
+ const displayFile = getDisplayFile(file);
109
+
110
+ console.log(`[OBS] ${operation} started: key=${key}, file=${displayFile}`);
111
+
112
+ const pendingTimer = setTimeout(() => {
113
+ console.warn(
114
+ `[OBS] ${operation} is still pending after ${OBS_PENDING_WARNING_MS / 1000}s: key=${key}, ` +
115
+ `server=${obsConfig.server}, bucket=${obsConfig.bucket}, ` +
116
+ `sdkTimeout=${timeoutSeconds}s, maxRetryCount=${maxRetryCount}, ` +
117
+ `operationTimeout=${operationTimeoutMs / 1000}s`,
118
+ );
119
+ }, OBS_PENDING_WARNING_MS);
120
+
121
+ // 诊断定时器不应单独阻止构建进程退出。
122
+ if (pendingTimer.unref) {
123
+ pendingTimer.unref();
124
+ }
125
+
126
+ return {
127
+ finish(outcome, detail) {
128
+ clearTimeout(pendingTimer);
129
+ const elapsed = Date.now() - startedAt;
130
+ const suffix = detail ? `, ${detail}` : '';
131
+ const message = `[OBS] ${operation} ${outcome} in ${elapsed}ms: key=${key}${suffix}`;
132
+
133
+ if (outcome === 'failed') {
134
+ console.error(message);
135
+ } else if (outcome === 'miss') {
136
+ console.warn(message);
137
+ } else {
138
+ console.log(message);
139
+ }
140
+ },
141
+ };
142
+ }
143
+
144
+ function runObsRequest(operation, file, key, request) {
145
+ return new Promise((resolve, reject) => {
146
+ const requestLogger = createObsRequestLogger(operation, file, key);
147
+ const { operationTimeoutMs } = getObsRuntimeOptions();
148
+ let settled = false;
149
+ let operationTimer;
150
+ let activeRequest;
151
+
152
+ const finish = (error, result, outcome) => {
153
+ if (settled) {
154
+ return;
155
+ }
156
+
157
+ settled = true;
158
+ clearTimeout(operationTimer);
159
+ activeRequest = null;
160
+ requestLogger.finish(
161
+ outcome || (error ? 'failed' : 'completed'),
162
+ formatObsError(error || result),
163
+ );
164
+
165
+ if (error) {
166
+ reject(error);
167
+ } else {
168
+ resolve(result);
169
+ }
170
+ };
171
+
172
+ const armOperationTimeout = () => {
173
+ if (settled) {
174
+ return;
175
+ }
176
+ clearTimeout(operationTimer);
177
+ operationTimer = setTimeout(() => {
178
+ const requestToAbort = activeRequest;
179
+ const error = new Error(
180
+ `OBS ${operation} timed out after ${operationTimeoutMs / 1000}s without progress`,
181
+ );
182
+ error.code = 'OBS_OPERATION_TIMEOUT';
183
+ finish(error);
184
+ if (requestToAbort && requestToAbort.destroy) {
185
+ requestToAbort.destroy();
186
+ }
187
+ }, operationTimeoutMs);
188
+ };
189
+
190
+ const registerRequest = (clientRequest) => {
191
+ activeRequest = clientRequest;
192
+ };
193
+
194
+ armOperationTimeout();
195
+
196
+ try {
197
+ request(finish, armOperationTimeout, registerRequest);
198
+ } catch (error) {
199
+ finish(error);
200
+ }
201
+ });
202
+ }
23
203
 
24
204
  function getObsClient() {
25
205
  if (!obsClient) {
206
+ const { timeoutSeconds, maxRetryCount } = getObsRuntimeOptions();
26
207
  obsClient = new OBSClient({
27
208
  ...obsConfig,
209
+ timeout: timeoutSeconds,
210
+ max_retry_count: maxRetryCount,
28
211
  });
29
212
  }
30
213
  return obsClient;
@@ -41,49 +224,60 @@ function getOssClient() {
41
224
 
42
225
  function doOssUpload(file) {
43
226
  return getOssClient().put(
44
- compatiblePath(path.join(ossConfig.dir, path.relative(DIST_DIR, file))),
227
+ getRemoteKey(ossConfig, file),
45
228
  file,
46
229
  );
47
230
  }
48
231
 
49
232
  function getOssStat(file) {
50
- return getOssClient().head(compatiblePath(path.join(ossConfig.dir, path.relative(DIST_DIR, file))));
233
+ return getOssClient().head(getRemoteKey(ossConfig, file));
51
234
  }
52
235
 
53
236
  function doObsUpload(file) {
54
- return new Promise((resolve, reject) => {
237
+ const key = getRemoteKey(obsConfig, file);
238
+ return runObsRequest('upload', file, key, (finish, reportProgress, registerRequest) => {
55
239
  getObsClient().putObject(
56
240
  {
57
241
  Bucket: obsConfig.bucket,
58
- Key: compatiblePath(path.join(obsConfig.dir, path.relative(DIST_DIR, file))),
242
+ Key: key,
59
243
  SourceFile: file,
244
+ ProgressCallback: reportProgress,
245
+ RequestHook: registerRequest,
60
246
  },
61
247
  (err, result) => {
248
+ const status = getObsStatus(result);
62
249
  if (err) {
63
- reject(err);
64
- } else if (result.CommonMsg.Status >= 300) {
65
- reject(new Error(`OBS upload failed with status ${result.CommonMsg.Status}`));
250
+ finish(err);
251
+ } else if (status === undefined) {
252
+ finish(getInvalidObsResponseError('upload'));
253
+ } else if (status >= 300) {
254
+ finish(result);
66
255
  } else {
67
- resolve(result);
256
+ finish(null, result);
68
257
  }
69
258
  },
70
259
  );
71
260
  });
72
261
  }
73
262
  async function getObsStat(file) {
74
- return new Promise((resolve, reject) => {
263
+ const key = getRemoteKey(obsConfig, file);
264
+ return runObsRequest('metadata check', file, key, (finish, _reportProgress, registerRequest) => {
75
265
  getObsClient().getObjectMetadata(
76
266
  {
77
267
  Bucket: obsConfig.bucket,
78
- Key: compatiblePath(path.join(obsConfig.dir, path.relative(DIST_DIR, file))),
268
+ Key: key,
269
+ RequestHook: registerRequest,
79
270
  },
80
271
  (err, result) => {
272
+ const status = getObsStatus(result);
81
273
  if (err) {
82
- reject(err);
83
- } else if (result.CommonMsg.Status < 300) {
84
- resolve(result);
274
+ finish(err);
275
+ } else if (status === undefined) {
276
+ finish(getInvalidObsResponseError('metadata check'));
277
+ } else if (status < 300) {
278
+ finish(null, result);
85
279
  } else {
86
- reject(result);
280
+ finish(result, null, 'miss');
87
281
  }
88
282
  },
89
283
  );
@@ -111,8 +305,7 @@ function doUpload(file) {
111
305
  }
112
306
 
113
307
  function updateProgress() {
114
- const completed =
115
- Object.entries(uploadQueue).filter((item) => item[1] === 'completed').length + 1;
308
+ const completed = Object.values(uploadQueue).filter((status) => status === 'completed').length;
116
309
  const total = Object.keys(uploadQueue).length;
117
310
 
118
311
  if (!progress) {
@@ -121,64 +314,100 @@ function updateProgress() {
121
314
  width: 40,
122
315
  clear: true,
123
316
  });
317
+ } else {
318
+ // watch 构建期间可能继续追加资源,进度条总数需要同步扩容。
319
+ progress.total = total;
124
320
  }
125
321
 
126
322
  progress.tick();
127
323
 
128
- if (completed === total) {
129
- progress.tick({
130
- current: total,
131
- });
132
- progress = null;
133
- uploadQueue = {};
134
- }
135
-
136
324
  return `[${completed}/${total}]`;
137
325
  }
138
326
 
139
- async function checkUpload() {
140
- const files = Object.entries(uploadQueue)
141
- .filter((item) => item[1] === false)
142
- .splice(0, 10);
143
-
144
- if (files && files.length) {
145
- await Promise.all(
146
- files.map(async (file) => {
147
- uploadQueue[file[0]] = 'uploading';
148
- try {
149
- await getStat(file[0]);
150
- setStorage(file[0], true);
151
- updateProgress();
152
- } catch (error) {
153
- try {
154
- await doUpload(file[0]);
155
- setStorage(file[0], true);
156
- } catch (uploadError) {
157
- console.error(`Failed to upload ${file[0]}:`, uploadError);
158
- }
159
- updateProgress();
160
- }
161
- uploadQueue[file[0]] = 'completed';
162
- }),
163
- );
327
+ async function uploadFile(file) {
328
+ uploadQueue[file] = 'uploading';
164
329
 
165
- checkUpload();
330
+ try {
331
+ await getStat(file);
332
+ setStorage(file, true);
333
+ } catch (error) {
334
+ try {
335
+ await doUpload(file);
336
+ setStorage(file, true);
337
+ } catch (uploadError) {
338
+ console.error(
339
+ `[assets] upload failed: file=${getDisplayFile(file)}, ${formatObsError(uploadError)}`,
340
+ );
341
+ }
342
+ } finally {
343
+ uploadQueue[file] = 'completed';
344
+ updateProgress();
166
345
  }
167
346
  }
168
347
 
348
+ function checkUpload() {
349
+ // done hook 可能在上一次上传尚未结束时再次触发。复用同一个 runner,避免每次构建
350
+ // 都额外启动 10 个请求,最终耗尽 OBS/HTTP 连接池。
351
+ if (uploadRunner) {
352
+ return uploadRunner;
353
+ }
354
+
355
+ uploadRunner = (async () => {
356
+ while (true) {
357
+ const files = Object.entries(uploadQueue)
358
+ .filter((item) => item[1] === 'pending')
359
+ .slice(0, UPLOAD_CONCURRENCY)
360
+ .map((item) => item[0]);
361
+
362
+ if (!files.length) {
363
+ break;
364
+ }
365
+
366
+ await Promise.all(files.map(uploadFile));
367
+ }
368
+ })().finally(() => {
369
+ uploadRunner = null;
370
+
371
+ // 文件状态已经写完后再清空,避免旧实现最后一个文件把已清空的队列重新写回。
372
+ if (!Object.values(uploadQueue).includes('pending')) {
373
+ progress = null;
374
+ uploadQueue = {};
375
+ return;
376
+ }
377
+
378
+ // 兜住 runner 完成与 watch 新增资源恰好交错的情况。
379
+ checkUpload();
380
+ });
381
+
382
+ return uploadRunner;
383
+ }
384
+
169
385
  function addToUploadQueue(assets) {
170
386
  if (!obsConfig && !ossConfig) {
171
387
  console.warn('请配置obsConfig 或 ossConfig,否则无法上传文件到obs 或 oss');
172
388
  return;
173
389
  }
174
390
 
391
+ let cachedCount = 0;
392
+ let pendingCount = 0;
393
+
175
394
  assets.forEach((asset) => {
176
395
  const file = path.resolve(DIST_DIR, asset);
396
+ if (getStorage(file)) {
397
+ cachedCount += 1;
398
+ return;
399
+ }
177
400
  if (uploadQueue[file] === undefined) {
178
- uploadQueue[file] = false;
401
+ uploadQueue[file] = 'pending';
402
+ pendingCount += 1;
179
403
  }
180
404
  });
181
- checkUpload();
405
+
406
+ console.log(
407
+ `[assets] upload queue: provider=${ossConfig ? 'OSS' : 'OBS'}, total=${assets.length}, ` +
408
+ `cached=${cachedCount}, pending=${pendingCount}`,
409
+ );
410
+ return pendingCount ? checkUpload() : Promise.resolve();
182
411
  }
183
412
 
184
413
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hw-weapp-compiler",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "基于 webpack5 的小程序构建工具,兼容 Node.js 14~24",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -52,6 +52,7 @@
52
52
  "scripts": {
53
53
  "build": "node ./index.js build",
54
54
  "dev": "nodemon --inspect --trace-deprecation --watch build ./index.js dev",
55
- "simulation": "node ./index.js build -s"
55
+ "simulation": "node ./index.js build -s",
56
+ "test": "node ./test/upload.js"
56
57
  }
57
58
  }
package/test/upload.js ADDED
@@ -0,0 +1,92 @@
1
+ const assert = require('assert');
2
+ const fs = require('fs');
3
+ const http = require('http');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ async function run() {
8
+ const requests = [];
9
+ const sockets = new Set();
10
+ const server = http.createServer((req, res) => {
11
+ requests.push(`${req.method} ${req.url}`);
12
+
13
+ if (req.url.includes('stuck.png')) {
14
+ return;
15
+ }
16
+
17
+ if (req.method === 'HEAD') {
18
+ res.statusCode = 404;
19
+ res.end();
20
+ return;
21
+ }
22
+
23
+ req.resume();
24
+ req.on('end', () => {
25
+ res.statusCode = 200;
26
+ res.setHeader('etag', 'test-etag');
27
+ res.end();
28
+ });
29
+ });
30
+ server.on('connection', (socket) => {
31
+ sockets.add(socket);
32
+ socket.on('close', () => sockets.delete(socket));
33
+ });
34
+
35
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
36
+
37
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hw-upload-test-'));
38
+ const originalCwd = process.cwd();
39
+ const endpoint = `http://127.0.0.1:${server.address().port}`;
40
+ const uploadModule = path.resolve(originalCwd, 'build/utils/upload.js');
41
+
42
+ try {
43
+ fs.mkdirSync(path.join(projectDir, 'dist/assets'), { recursive: true });
44
+ fs.writeFileSync(path.join(projectDir, 'dist/assets/ok.png'), 'ok');
45
+ fs.writeFileSync(path.join(projectDir, 'dist/assets/stuck.png'), 'stuck');
46
+ fs.writeFileSync(path.join(projectDir, 'dist/assets/later.png'), 'later');
47
+ fs.writeFileSync(
48
+ path.join(projectDir, '.weapp.js'),
49
+ `module.exports = { obsConfig: ${JSON.stringify({
50
+ access_key_id: 'test-ak',
51
+ secret_access_key: 'test-sk',
52
+ server: endpoint,
53
+ bucket: 'test-bucket',
54
+ dir: 'test-dir',
55
+ timeout: 10,
56
+ max_retry_count: 0,
57
+ operation_timeout: 0.1,
58
+ })} };`,
59
+ );
60
+
61
+ process.chdir(projectDir);
62
+ const { addToUploadQueue } = require(uploadModule);
63
+
64
+ await addToUploadQueue(['assets/ok.png']);
65
+ assert(requests.some((request) => request.startsWith('HEAD ')));
66
+ assert(requests.some((request) => request.startsWith('PUT ')));
67
+
68
+ const startedAt = Date.now();
69
+ const stuckRun = addToUploadQueue(['assets/stuck.png']);
70
+ const sameRun = addToUploadQueue(['assets/later.png']);
71
+ assert.strictEqual(stuckRun, sameRun, 'watch rebuild should reuse the active queue runner');
72
+ await sameRun;
73
+
74
+ const elapsed = Date.now() - startedAt;
75
+ assert(elapsed < 2000, `timeout fallback took ${elapsed}ms`);
76
+ assert(requests.filter((request) => request.includes('stuck.png')).length >= 2);
77
+ assert(requests.some((request) => request.includes('later.png')));
78
+
79
+ // 等待 storage.js 的防抖写入结束后再清理测试目录。
80
+ await new Promise((resolve) => setTimeout(resolve, 400));
81
+ } finally {
82
+ process.chdir(originalCwd);
83
+ sockets.forEach((socket) => socket.destroy());
84
+ await new Promise((resolve) => server.close(resolve));
85
+ fs.rmSync(projectDir, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ run().catch((error) => {
90
+ console.error(error);
91
+ process.exitCode = 1;
92
+ });