@wcstack/upload 1.19.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +1 -0
- package/README.md +1 -0
- package/dist/index.d.ts +99 -4
- package/dist/index.esm.js +527 -52
- package/dist/index.esm.js.map +1 -1
- package/dist/index.esm.min.js +1 -1
- package/dist/index.esm.min.js.map +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -49,6 +49,388 @@ function setConfig(partialConfig) {
|
|
|
49
49
|
frozenConfig = null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ===========================================================================
|
|
53
|
+
// AUTO-GENERATED FILE - DO NOT EDIT.
|
|
54
|
+
// Generated from /io-core/operation-lane.ts by scripts/sync-io-core.mjs.
|
|
55
|
+
// Run `node scripts/sync-io-core.mjs` after editing the source.
|
|
56
|
+
// ===========================================================================
|
|
57
|
+
/**
|
|
58
|
+
* 1 レーン = 独立した排他単位。Core が 1 つ以上所有する (module singleton にしない —
|
|
59
|
+
* 複数 <wcs-fetch> 間で漏れるため)。
|
|
60
|
+
*/
|
|
61
|
+
class OperationLane {
|
|
62
|
+
laneKey;
|
|
63
|
+
policy;
|
|
64
|
+
_ownerGeneration = 0;
|
|
65
|
+
_latestEpoch = 0;
|
|
66
|
+
_nextOperationId = 1;
|
|
67
|
+
// latest / queue / exhaust の単一 active。queue は head を指す。
|
|
68
|
+
_activeOperationId = undefined;
|
|
69
|
+
// overlap の active set (§5: 内部 bookkeeping のみ・observable 公開はしない)。
|
|
70
|
+
_activeOperationIds = new Set();
|
|
71
|
+
// queue policy の FIFO。
|
|
72
|
+
_queue = [];
|
|
73
|
+
_inFlightCount = 0;
|
|
74
|
+
// opId → 終端状態 (absence = pending)。
|
|
75
|
+
_terminal = new Map();
|
|
76
|
+
// claimTerminal で確定した outcome (finalize が 'committing' を最終値へ移す)。
|
|
77
|
+
_claimedOutcome = new Map();
|
|
78
|
+
// opId → AbortController (identity は opId が保証。cross-op clobber は構造上起きない)。
|
|
79
|
+
_controllers = new Map();
|
|
80
|
+
// opId → attempt 数。
|
|
81
|
+
_attempts = new Map();
|
|
82
|
+
_withSignal;
|
|
83
|
+
_trace;
|
|
84
|
+
constructor(laneKey, policy, options = {}) {
|
|
85
|
+
this.laneKey = laneKey;
|
|
86
|
+
this.policy = policy;
|
|
87
|
+
this._withSignal = options.withSignal ?? false;
|
|
88
|
+
this._trace = options.trace;
|
|
89
|
+
}
|
|
90
|
+
get ownerGeneration() {
|
|
91
|
+
return this._ownerGeneration;
|
|
92
|
+
}
|
|
93
|
+
get inFlightCount() {
|
|
94
|
+
return this._inFlightCount;
|
|
95
|
+
}
|
|
96
|
+
get latestEpoch() {
|
|
97
|
+
return this._latestEpoch;
|
|
98
|
+
}
|
|
99
|
+
get activeOperationId() {
|
|
100
|
+
return this._activeOperationId;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 新しい要求の到着。arrival policy を適用し ticket + 最初の attempt を発行する。
|
|
104
|
+
* exhaust で実行中の場合だけ null を返す (新要求を ticket 化せず拒否 = 冪等 no-op)。
|
|
105
|
+
*/
|
|
106
|
+
begin() {
|
|
107
|
+
let supersedeEpoch;
|
|
108
|
+
switch (this.policy) {
|
|
109
|
+
case "latest": {
|
|
110
|
+
// latestEpoch を進め、旧 active を abort (可能なら)。旧 ticket は settle 時に
|
|
111
|
+
// eligibility 不一致で stale となる。
|
|
112
|
+
supersedeEpoch = ++this._latestEpoch;
|
|
113
|
+
if (this._activeOperationId !== undefined) {
|
|
114
|
+
this._abortController(this._activeOperationId);
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "exhaust": {
|
|
119
|
+
// 実行中なら新要求を拒否 (呼び出し側は既存結果へ合流)。
|
|
120
|
+
if (this._activeOperationId !== undefined) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const operationId = this._nextOperationId++;
|
|
127
|
+
const ticket = {
|
|
128
|
+
operationId,
|
|
129
|
+
ownerGeneration: this._ownerGeneration,
|
|
130
|
+
laneKey: this.laneKey,
|
|
131
|
+
policy: this.policy,
|
|
132
|
+
supersedeEpoch,
|
|
133
|
+
};
|
|
134
|
+
switch (this.policy) {
|
|
135
|
+
case "latest":
|
|
136
|
+
case "exhaust":
|
|
137
|
+
this._activeOperationId = operationId;
|
|
138
|
+
break;
|
|
139
|
+
case "queue":
|
|
140
|
+
this._queue.push(ticket);
|
|
141
|
+
// 先頭だけを active にする (先行が完了するまで待つ)。
|
|
142
|
+
if (this._activeOperationId === undefined) {
|
|
143
|
+
this._activeOperationId = operationId;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case "overlap":
|
|
147
|
+
this._activeOperationIds.add(operationId);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
this._inFlightCount += 1;
|
|
151
|
+
this._attempts.set(operationId, 1);
|
|
152
|
+
const attempt = this._makeAttempt(operationId, 1);
|
|
153
|
+
if (this._trace !== undefined) {
|
|
154
|
+
this._trace({ type: "io:operation-started", operationId, laneKey: this.laneKey, policy: this.policy });
|
|
155
|
+
}
|
|
156
|
+
return { ticket, attempt };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* retry: 同じ operationId に新しい attempt を作る。attempt number と resource signal
|
|
160
|
+
* だけを更新する (§5)。既に終端した operation には作れない (null)。
|
|
161
|
+
*/
|
|
162
|
+
retry(ticket) {
|
|
163
|
+
if (ticket.ownerGeneration !== this._ownerGeneration)
|
|
164
|
+
return null;
|
|
165
|
+
if (this._terminal.has(ticket.operationId))
|
|
166
|
+
return null;
|
|
167
|
+
const previous = this._attempts.get(ticket.operationId);
|
|
168
|
+
if (previous === undefined)
|
|
169
|
+
return null;
|
|
170
|
+
const attemptNo = previous + 1;
|
|
171
|
+
this._attempts.set(ticket.operationId, attemptNo);
|
|
172
|
+
// 前の attempt の signal は破棄し、新しい controller を張る。
|
|
173
|
+
this._releaseController(ticket.operationId);
|
|
174
|
+
const attempt = this._makeAttempt(ticket.operationId, attemptNo);
|
|
175
|
+
if (this._trace !== undefined) {
|
|
176
|
+
this._trace({ type: "io:operation-retried", operationId: ticket.operationId, laneKey: this.laneKey, attempt: attemptNo });
|
|
177
|
+
}
|
|
178
|
+
return attempt;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* CommitGuard (§5.1)。外部可視の setter / event dispatch の直前に呼ぶ。
|
|
182
|
+
* (1) owner lifecycle generation 一致 (2) terminal settle 前 (3) policy eligibility。
|
|
183
|
+
*/
|
|
184
|
+
canCommit(ticket) {
|
|
185
|
+
if (ticket.ownerGeneration !== this._ownerGeneration)
|
|
186
|
+
return false;
|
|
187
|
+
const status = this._terminal.get(ticket.operationId);
|
|
188
|
+
// absence = pending / 'committing' = multi-setter commit 中。どちらも settle 前。
|
|
189
|
+
if (status !== undefined && status !== "committing")
|
|
190
|
+
return false;
|
|
191
|
+
return this._isEligible(ticket);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* terminal CAS (§5.1): pending → committing を claim する。勝者だけが true。
|
|
195
|
+
* eligibility / owner gen を満たさない場合も false。claim 後は commit 中となり、
|
|
196
|
+
* canCommit は各 setter の直前で再検査する (setter が同期 supersede しても取りこぼさない)。
|
|
197
|
+
*/
|
|
198
|
+
claimTerminal(ticket, outcome) {
|
|
199
|
+
if (ticket.ownerGeneration !== this._ownerGeneration)
|
|
200
|
+
return false;
|
|
201
|
+
if (this._terminal.has(ticket.operationId))
|
|
202
|
+
return false; // 既に committing / 終端
|
|
203
|
+
if (!this._isEligible(ticket))
|
|
204
|
+
return false;
|
|
205
|
+
this._terminal.set(ticket.operationId, "committing");
|
|
206
|
+
this._claimedOutcome.set(ticket.operationId, outcome);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
/** claim 済み outcome (timer が claim → catch が読む等)。未 claim なら undefined。 */
|
|
210
|
+
claimedOutcome(ticket) {
|
|
211
|
+
return this._claimedOutcome.get(ticket.operationId);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* operation の後始末。claim 済みなら outcome を確定し、未 claim なら stale-drop。
|
|
215
|
+
* controller を解放し in-flight を減らし、policy の bookkeeping を進める。冪等。
|
|
216
|
+
*/
|
|
217
|
+
finalize(ticket) {
|
|
218
|
+
const operationId = ticket.operationId;
|
|
219
|
+
const status = this._terminal.get(operationId);
|
|
220
|
+
if (status !== undefined && status !== "committing") {
|
|
221
|
+
// 既に確定済み。冪等に return。
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let outcome;
|
|
225
|
+
if (status === "committing") {
|
|
226
|
+
outcome = this._claimedOutcome.get(operationId) ?? "stale";
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// 一度も claim されなかった (supersede / dispose で eligibility を失った)。
|
|
230
|
+
outcome = "stale";
|
|
231
|
+
}
|
|
232
|
+
this._terminal.set(operationId, outcome);
|
|
233
|
+
this._claimedOutcome.delete(operationId);
|
|
234
|
+
this._releaseController(operationId);
|
|
235
|
+
this._attempts.delete(operationId);
|
|
236
|
+
if (this._inFlightCount > 0)
|
|
237
|
+
this._inFlightCount -= 1;
|
|
238
|
+
this._advanceBookkeeping(operationId);
|
|
239
|
+
if (this._trace !== undefined) {
|
|
240
|
+
if (outcome === "stale") {
|
|
241
|
+
this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
this._trace({ type: "io:operation-settled", operationId, laneKey: this.laneKey, outcome });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** operation の signal (resource 解放用)。withSignal でなければ undefined。 */
|
|
249
|
+
signalOf(ticket) {
|
|
250
|
+
return this._controllers.get(ticket.operationId)?.signal;
|
|
251
|
+
}
|
|
252
|
+
/** best-effort な resource 中断。正しさは owner gen / eligibility / terminal CAS が担う。 */
|
|
253
|
+
abort(ticket) {
|
|
254
|
+
this._abortController(ticket.operationId);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* 現在 active な operation を中断する (利用者による明示キャンセル)。epoch は進めない —
|
|
258
|
+
* 中断された operation は eligibility を保ったまま 'aborted' を claim できる
|
|
259
|
+
* (loading をクリアしつつ in-flight 状態を残す)。
|
|
260
|
+
*/
|
|
261
|
+
abortActive() {
|
|
262
|
+
if (this._activeOperationId !== undefined) {
|
|
263
|
+
this._abortController(this._activeOperationId);
|
|
264
|
+
}
|
|
265
|
+
for (const operationId of this._activeOperationIds) {
|
|
266
|
+
this._abortController(operationId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* dispose (§4.1 world generation)。owner generation を bump して全 ticket を無効化し、
|
|
271
|
+
* 生きている controller を全て abort する。dispose 後に settle した operation は
|
|
272
|
+
* owner gen 不一致で外部 commit しない。retention gate (§10.3) のため live な
|
|
273
|
+
* 全 operation を即時に stale として finalize し、controller / attempt を解放する。
|
|
274
|
+
*/
|
|
275
|
+
disposeOwner() {
|
|
276
|
+
this._ownerGeneration += 1;
|
|
277
|
+
for (const operationId of Array.from(this._controllers.keys())) {
|
|
278
|
+
this._abortController(operationId);
|
|
279
|
+
// finalize は dispose 後 (terminal='stale') に early-return するため controller を
|
|
280
|
+
// 解放しない。retention gate (§10.3) を満たすためここで明示的に解放する。
|
|
281
|
+
this._releaseController(operationId);
|
|
282
|
+
}
|
|
283
|
+
for (const operationId of Array.from(this._attempts.keys())) {
|
|
284
|
+
if (!this._terminal.has(operationId)) {
|
|
285
|
+
this._terminal.set(operationId, "stale");
|
|
286
|
+
}
|
|
287
|
+
this._claimedOutcome.delete(operationId);
|
|
288
|
+
this._attempts.delete(operationId);
|
|
289
|
+
if (this._trace !== undefined) {
|
|
290
|
+
this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
this._activeOperationId = undefined;
|
|
294
|
+
this._activeOperationIds.clear();
|
|
295
|
+
this._queue.length = 0;
|
|
296
|
+
this._inFlightCount = 0;
|
|
297
|
+
}
|
|
298
|
+
// --- internal ---
|
|
299
|
+
_makeAttempt(operationId, attemptNo) {
|
|
300
|
+
let signal;
|
|
301
|
+
// AbortController 不在環境(古い runtime / 一部 SSR)では degraded: signal なしで進む。
|
|
302
|
+
// 正しさは owner generation / eligibility / terminal CAS が担うため、native 中断が
|
|
303
|
+
// 無くても supersede / dispose は機能する(best-effort resource 中断が省かれるだけ)。
|
|
304
|
+
if (this._withSignal && typeof AbortController === "function") {
|
|
305
|
+
const controller = new AbortController();
|
|
306
|
+
this._controllers.set(operationId, controller);
|
|
307
|
+
signal = controller.signal;
|
|
308
|
+
}
|
|
309
|
+
return { operationId, attempt: attemptNo, signal };
|
|
310
|
+
}
|
|
311
|
+
_isEligible(ticket) {
|
|
312
|
+
switch (this.policy) {
|
|
313
|
+
case "latest":
|
|
314
|
+
return ticket.supersedeEpoch === this._latestEpoch;
|
|
315
|
+
case "queue":
|
|
316
|
+
case "exhaust":
|
|
317
|
+
return this._activeOperationId === ticket.operationId;
|
|
318
|
+
case "overlap":
|
|
319
|
+
return this._activeOperationIds.has(ticket.operationId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
_advanceBookkeeping(operationId) {
|
|
323
|
+
switch (this.policy) {
|
|
324
|
+
case "latest":
|
|
325
|
+
case "exhaust":
|
|
326
|
+
if (this._activeOperationId === operationId) {
|
|
327
|
+
this._activeOperationId = undefined;
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
case "queue": {
|
|
331
|
+
// 完了した ticket を FIFO から取り除き、次の先頭を active にする。filter で
|
|
332
|
+
// 「先頭 / 非先頭 / 不在」を一様に扱う (finalize は冪等ガードを通った op のみ到達)。
|
|
333
|
+
const remaining = this._queue.filter((t) => t.operationId !== operationId);
|
|
334
|
+
this._queue.length = 0;
|
|
335
|
+
this._queue.push(...remaining);
|
|
336
|
+
this._activeOperationId = this._queue.length > 0 ? this._queue[0].operationId : undefined;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case "overlap":
|
|
340
|
+
this._activeOperationIds.delete(operationId);
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
_abortController(operationId) {
|
|
345
|
+
const controller = this._controllers.get(operationId);
|
|
346
|
+
if (controller !== undefined && !controller.signal.aborted) {
|
|
347
|
+
controller.abort();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
_releaseController(operationId) {
|
|
351
|
+
this._controllers.delete(operationId);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ===========================================================================
|
|
356
|
+
// AUTO-GENERATED FILE - DO NOT EDIT.
|
|
357
|
+
// Generated from /io-core/platform-capability.ts by scripts/sync-io-core.mjs.
|
|
358
|
+
// Run `node scripts/sync-io-core.mjs` after editing the source.
|
|
359
|
+
// ===========================================================================
|
|
360
|
+
function isSecureContext() {
|
|
361
|
+
return globalThis.isSecureContext === true;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* capability を利用直前に評価して PlatformAssessment を作る。
|
|
365
|
+
* required が 1 つでも欠ければ readiness は "idle"(開始不可)、
|
|
366
|
+
* required 揃い + optional 欠けは "degraded"、全揃いは "ready"。
|
|
367
|
+
*/
|
|
368
|
+
function assessCapabilities(registry, options) {
|
|
369
|
+
const availability = new Map();
|
|
370
|
+
const evaluate = (id) => {
|
|
371
|
+
const spec = registry.get(id);
|
|
372
|
+
if (spec === undefined)
|
|
373
|
+
return "unknown";
|
|
374
|
+
return spec.probe() ? "available" : "missing";
|
|
375
|
+
};
|
|
376
|
+
let requiredAllAvailable = true;
|
|
377
|
+
for (const id of options.required) {
|
|
378
|
+
const a = evaluate(id);
|
|
379
|
+
availability.set(id, a);
|
|
380
|
+
if (a !== "available")
|
|
381
|
+
requiredAllAvailable = false;
|
|
382
|
+
}
|
|
383
|
+
let optionalAllAvailable = true;
|
|
384
|
+
for (const id of options.optional ?? []) {
|
|
385
|
+
const a = evaluate(id);
|
|
386
|
+
availability.set(id, a);
|
|
387
|
+
if (a !== "available")
|
|
388
|
+
optionalAllAvailable = false;
|
|
389
|
+
}
|
|
390
|
+
const readiness = !requiredAllAvailable ? "idle" : (optionalAllAvailable ? "ready" : "degraded");
|
|
391
|
+
// preconditions: 対象 capability のいずれかが要求する場合だけ評価する。
|
|
392
|
+
const allIds = [...options.required, ...(options.optional ?? [])];
|
|
393
|
+
const needsSecure = allIds.some((id) => registry.get(id)?.requiresSecureContext === true);
|
|
394
|
+
const needsActivation = allIds.some((id) => registry.get(id)?.requiresUserActivation === true);
|
|
395
|
+
const secureContext = needsSecure ? (isSecureContext() ? "satisfied" : "required") : "not-applicable";
|
|
396
|
+
const userActivation = needsActivation ? "required" : "not-applicable";
|
|
397
|
+
return {
|
|
398
|
+
availability,
|
|
399
|
+
permission: options.permission ?? "not-applicable",
|
|
400
|
+
readiness,
|
|
401
|
+
activity: options.activity ?? "inactive",
|
|
402
|
+
preconditions: { secureContext, userActivation },
|
|
403
|
+
epoch: options.epoch ?? 0,
|
|
404
|
+
lastError: options.lastError,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/** availability から「required がすべて available か」を判定するヘルパ(supported の最低条件)。 */
|
|
408
|
+
function requiredCapabilitiesAvailable(assessment, required) {
|
|
409
|
+
return required.every((id) => assessment.availability.get(id) === "available");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* uploadCapabilities.ts
|
|
414
|
+
*
|
|
415
|
+
* Upload node 固有の capability registry と error code。汎用の assess 機構・型は
|
|
416
|
+
* `./platformCapability.js`(/io-core/ から copy-distribution される生成ファイル)から
|
|
417
|
+
* import する。node 固有の宣言はこのハンドライトファイルに置き、生成コピーとは分離する。
|
|
418
|
+
*/
|
|
419
|
+
/** 安定した upload error code(taxonomy)。値は公開キーとして固定。 */
|
|
420
|
+
const WCS_UPLOAD_ERROR_CODE = {
|
|
421
|
+
CapabilityMissing: "capability-missing",
|
|
422
|
+
InvalidArgument: "invalid-argument",
|
|
423
|
+
Network: "network",
|
|
424
|
+
HttpError: "http-error",
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* upload node の capability registry。`XMLHttpRequest`(progress 取得のため fetch では
|
|
428
|
+
* なく XHR を用いる)の presence を probe する。
|
|
429
|
+
*/
|
|
430
|
+
const UPLOAD_CAPABILITIES = new Map([
|
|
431
|
+
["web.xhr", { probe: () => typeof globalThis.XMLHttpRequest === "function", compatKey: "api.XMLHttpRequest" }],
|
|
432
|
+
]);
|
|
433
|
+
|
|
52
434
|
class UploadCore extends EventTarget {
|
|
53
435
|
static wcBindable = {
|
|
54
436
|
protocol: "wc-bindable",
|
|
@@ -59,6 +441,12 @@ class UploadCore extends EventTarget {
|
|
|
59
441
|
{ name: "progress", event: "wcs-upload:progress" },
|
|
60
442
|
{ name: "error", event: "wcs-upload:error" },
|
|
61
443
|
{ name: "status", event: "wcs-upload:response", getter: (e) => e.detail.status },
|
|
444
|
+
// Serializable failure taxonomy (stable code / phase / recoverable), or null.
|
|
445
|
+
// Additive bindable output; the existing `error` property/event are unchanged.
|
|
446
|
+
// Fires its own `wcs-upload:error-info-changed` event; no getter, so the bound
|
|
447
|
+
// value is the event detail (mirrors `error` / `loading`). An abort() is not a
|
|
448
|
+
// failure — it clears loading without setting error/errorInfo.
|
|
449
|
+
{ name: "errorInfo", event: "wcs-upload:error-info-changed" },
|
|
62
450
|
],
|
|
63
451
|
inputs: [
|
|
64
452
|
{ name: "url" },
|
|
@@ -70,19 +458,22 @@ class UploadCore extends EventTarget {
|
|
|
70
458
|
{ name: "abort" },
|
|
71
459
|
],
|
|
72
460
|
};
|
|
461
|
+
// Required capability (probed at call time, never at module eval).
|
|
462
|
+
static REQUIRED_CAPABILITIES = ["web.xhr"];
|
|
73
463
|
_target;
|
|
74
464
|
_value = null;
|
|
75
465
|
_loading = false;
|
|
76
466
|
_progress = 0;
|
|
77
467
|
_error = null;
|
|
78
468
|
_status = 0;
|
|
469
|
+
_errorInfo = null;
|
|
79
470
|
_xhr = null;
|
|
80
471
|
_promise = Promise.resolve(null);
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
472
|
+
// Concurrency lane (io-core). `latest`: a new upload supersedes the in-flight one
|
|
473
|
+
// (switchMap). `withSignal: false`: upload uses XMLHttpRequest.abort() rather than
|
|
474
|
+
// an AbortSignal, so the lane owns epoch / commit-guard while abort() below owns
|
|
475
|
+
// the XHR cancellation. dispose() bumps the owner generation and aborts the XHR.
|
|
476
|
+
_lane = new OperationLane("upload", "latest", { withSignal: false });
|
|
86
477
|
// SSR: no asynchronous probe to await, so readiness is immediate.
|
|
87
478
|
_ready = Promise.resolve();
|
|
88
479
|
constructor(target) {
|
|
@@ -92,14 +483,15 @@ class UploadCore extends EventTarget {
|
|
|
92
483
|
get ready() {
|
|
93
484
|
return this._ready;
|
|
94
485
|
}
|
|
95
|
-
// Lifecycle (§3.5). Upload is command-driven with no subscription to
|
|
96
|
-
//
|
|
97
|
-
//
|
|
486
|
+
// Lifecycle (§3.5). Upload is command-driven with no subscription to establish,
|
|
487
|
+
// so observe() is an idempotent no-op that resolves once ready; dispose() bumps
|
|
488
|
+
// the lane's owner generation (invalidating any in-flight upload) and aborts the
|
|
489
|
+
// XHR.
|
|
98
490
|
observe() {
|
|
99
491
|
return this._ready;
|
|
100
492
|
}
|
|
101
493
|
dispose() {
|
|
102
|
-
this.
|
|
494
|
+
this._lane.disposeOwner();
|
|
103
495
|
this.abort();
|
|
104
496
|
}
|
|
105
497
|
get value() {
|
|
@@ -120,6 +512,41 @@ class UploadCore extends EventTarget {
|
|
|
120
512
|
get promise() {
|
|
121
513
|
return this._promise;
|
|
122
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* The last failure's serializable `WcsIoErrorInfo` (stable `code` / `phase` /
|
|
517
|
+
* `recoverable` / `capabilityId`), or null. Exposed as an additive wc-bindable
|
|
518
|
+
* property (event `wcs-upload:error-info-changed`); the existing `error`
|
|
519
|
+
* property/event are unchanged. An abort() is not a failure (no errorInfo).
|
|
520
|
+
*/
|
|
521
|
+
get errorInfo() {
|
|
522
|
+
return this._errorInfo;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Whether the required platform capability (`web.xhr`) is available right now —
|
|
526
|
+
* decided by call-time feature detection, not User-Agent. Core-only, additive.
|
|
527
|
+
*/
|
|
528
|
+
get supported() {
|
|
529
|
+
return requiredCapabilitiesAvailable(this.platformAssessment, UploadCore.REQUIRED_CAPABILITIES);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Full platform assessment (availability / readiness / preconditions), probed at
|
|
533
|
+
* call time. Core-only opt-in dev / sidecar view.
|
|
534
|
+
*/
|
|
535
|
+
get platformAssessment() {
|
|
536
|
+
return assessCapabilities(UPLOAD_CAPABILITIES, {
|
|
537
|
+
required: UploadCore.REQUIRED_CAPABILITIES,
|
|
538
|
+
activity: this._loading ? "active" : "inactive",
|
|
539
|
+
lastError: this._errorInfo ?? undefined,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
// CommitGuard (§5.1): external setters / event dispatch only run if the ticket
|
|
543
|
+
// still holds owner generation, is pre-terminal, and is the lane's latest epoch
|
|
544
|
+
// (a superseding upload can invalidate a ticket mid-commit).
|
|
545
|
+
_commitStep(ticket, step) {
|
|
546
|
+
if (this._lane.canCommit(ticket)) {
|
|
547
|
+
step();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
123
550
|
// --- State setters with event dispatch ---
|
|
124
551
|
_setLoading(loading) {
|
|
125
552
|
this._loading = loading;
|
|
@@ -165,14 +592,29 @@ class UploadCore extends EventTarget {
|
|
|
165
592
|
bubbles: true,
|
|
166
593
|
}));
|
|
167
594
|
}
|
|
595
|
+
// Single mutation point for `errorInfo`, mirroring `_setError`'s same-value guard
|
|
596
|
+
// and event dispatch so the additive `errorInfo` wc-bindable property stays in
|
|
597
|
+
// sync with `error`. Each failure builds a fresh object (reference guard passes);
|
|
598
|
+
// the clear path passes null (suppresses a redundant null→null per upload start).
|
|
599
|
+
_setErrorInfo(code, phase, recoverable, message, capabilityId) {
|
|
600
|
+
this._commitErrorInfo({ code, phase, recoverable, message, ...(capabilityId === undefined ? {} : { capabilityId }) });
|
|
601
|
+
}
|
|
602
|
+
_commitErrorInfo(info) {
|
|
603
|
+
if (this._errorInfo === info)
|
|
604
|
+
return;
|
|
605
|
+
this._errorInfo = info;
|
|
606
|
+
this._target.dispatchEvent(new CustomEvent("wcs-upload:error-info-changed", {
|
|
607
|
+
detail: info,
|
|
608
|
+
bubbles: true,
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
168
611
|
// --- Public API ---
|
|
169
612
|
abort() {
|
|
170
|
-
// `
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// FetchCore.abort() と挙動を揃えるため。
|
|
613
|
+
// Abort the current XHR. Its `abort` event handler claims the `aborted` terminal
|
|
614
|
+
// (while the ticket is still latest — abort() runs before a superseding upload's
|
|
615
|
+
// begin()), unifying the loading-release path with success/error/network. When a
|
|
616
|
+
// superseding upload or dispose() has already advanced the epoch/owner gen, the
|
|
617
|
+
// handler's claim fails and it writes nothing (stale-drop).
|
|
176
618
|
if (this._xhr) {
|
|
177
619
|
this._xhr.abort();
|
|
178
620
|
this._xhr = null;
|
|
@@ -183,10 +625,12 @@ class UploadCore extends EventTarget {
|
|
|
183
625
|
// サニタイズ値(null)を返す。command-token 経路からの呼び出しが unhandled
|
|
184
626
|
// rejection にならず、「upload() は全終了ケースで resolve」契約とも整合する。
|
|
185
627
|
if (!url) {
|
|
628
|
+
this._setErrorInfo(WCS_UPLOAD_ERROR_CODE.InvalidArgument, "start", false, "url is required.");
|
|
186
629
|
this._setError({ message: "url is required." });
|
|
187
630
|
return null;
|
|
188
631
|
}
|
|
189
632
|
if (!files || files.length === 0) {
|
|
633
|
+
this._setErrorInfo(WCS_UPLOAD_ERROR_CODE.InvalidArgument, "start", false, "files are required.");
|
|
190
634
|
this._setError({ message: "files are required." });
|
|
191
635
|
return null;
|
|
192
636
|
}
|
|
@@ -196,79 +640,107 @@ class UploadCore extends EventTarget {
|
|
|
196
640
|
}
|
|
197
641
|
// --- Internal ---
|
|
198
642
|
_doUpload(url, files, options) {
|
|
199
|
-
//
|
|
643
|
+
// Probe the required capability just before starting (SSR / very old runtime).
|
|
644
|
+
const assessment = this.platformAssessment;
|
|
645
|
+
if (!requiredCapabilitiesAvailable(assessment, UploadCore.REQUIRED_CAPABILITIES)) {
|
|
646
|
+
const missing = UploadCore.REQUIRED_CAPABILITIES.find((id) => assessment.availability.get(id) !== "available");
|
|
647
|
+
const message = `Required capability "${missing}" is unavailable.`;
|
|
648
|
+
this._setErrorInfo(WCS_UPLOAD_ERROR_CODE.CapabilityMissing, "start", false, message, missing);
|
|
649
|
+
this._setError({ message });
|
|
650
|
+
return Promise.resolve(null);
|
|
651
|
+
}
|
|
652
|
+
// Abort the previous XHR BEFORE advancing the epoch, so its `abort` handler
|
|
653
|
+
// claims `aborted` while still latest (preserving the loading true→false→true
|
|
654
|
+
// supersede sequence). Then begin() advances the epoch for THIS upload.
|
|
200
655
|
this.abort();
|
|
201
|
-
this.
|
|
202
|
-
|
|
203
|
-
this.
|
|
656
|
+
const started = this._lane.begin(); // `latest` begin never returns null
|
|
657
|
+
const { ticket } = started;
|
|
658
|
+
this._commitStep(ticket, () => this._setLoading(true));
|
|
659
|
+
this._commitStep(ticket, () => {
|
|
660
|
+
this._setProgress(0);
|
|
661
|
+
this._commitErrorInfo(null);
|
|
662
|
+
this._setError(null);
|
|
663
|
+
});
|
|
204
664
|
const { method = "POST", headers = {}, fieldName = "file", } = options;
|
|
205
665
|
const formData = new FormData();
|
|
206
666
|
for (let i = 0; i < files.length; i++) {
|
|
207
667
|
formData.append(fieldName, files[i]);
|
|
208
668
|
}
|
|
209
|
-
const gen = ++this._gen;
|
|
210
669
|
return new Promise((resolve) => {
|
|
211
670
|
const xhr = new XMLHttpRequest();
|
|
212
671
|
this._xhr = xhr;
|
|
213
672
|
xhr.upload.addEventListener("progress", (event) => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
673
|
+
// Guarded: a superseded / disposed upload's late progress writes nothing.
|
|
674
|
+
this._commitStep(ticket, () => {
|
|
675
|
+
if (event.lengthComputable) {
|
|
676
|
+
this._setProgress(Math.round((event.loaded / event.total) * 100));
|
|
677
|
+
}
|
|
678
|
+
});
|
|
220
679
|
});
|
|
221
680
|
xhr.addEventListener("load", () => {
|
|
222
681
|
this._xhr = null;
|
|
223
|
-
if (gen !== this._gen) {
|
|
224
|
-
resolve(null);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
this._status = xhr.status;
|
|
228
682
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
683
|
+
if (!this._lane.claimTerminal(ticket, "success")) {
|
|
684
|
+
resolve(null);
|
|
685
|
+
this._lane.finalize(ticket);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
229
688
|
let value = xhr.responseText;
|
|
230
689
|
const contentType = xhr.getResponseHeader("Content-Type") || "";
|
|
231
690
|
if (contentType.includes("application/json")) {
|
|
232
691
|
try {
|
|
233
692
|
value = JSON.parse(xhr.responseText);
|
|
234
693
|
}
|
|
235
|
-
catch {
|
|
236
|
-
// テキストのまま
|
|
237
|
-
}
|
|
694
|
+
catch { /* テキストのまま */ }
|
|
238
695
|
}
|
|
239
|
-
this._setProgress(100);
|
|
240
|
-
this._setResponse(value, xhr.status);
|
|
241
|
-
this._setLoading(false);
|
|
696
|
+
this._commitStep(ticket, () => this._setProgress(100));
|
|
697
|
+
this._commitStep(ticket, () => this._setResponse(value, xhr.status));
|
|
698
|
+
this._commitStep(ticket, () => this._setLoading(false));
|
|
699
|
+
this._lane.finalize(ticket);
|
|
242
700
|
resolve(value);
|
|
243
701
|
}
|
|
244
702
|
else {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
this.
|
|
703
|
+
if (!this._lane.claimTerminal(ticket, "error")) {
|
|
704
|
+
resolve(null);
|
|
705
|
+
this._lane.finalize(ticket);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const error = { status: xhr.status, statusText: xhr.statusText, body: xhr.responseText };
|
|
709
|
+
this._commitStep(ticket, () => {
|
|
710
|
+
this._status = xhr.status; // HTTP error keeps status (no wcs-upload:response — value not reset)
|
|
711
|
+
this._setErrorInfo(WCS_UPLOAD_ERROR_CODE.HttpError, "execute", true, `HTTP ${xhr.status} ${xhr.statusText}`);
|
|
712
|
+
this._setError(error);
|
|
713
|
+
});
|
|
714
|
+
this._commitStep(ticket, () => this._setLoading(false));
|
|
715
|
+
this._lane.finalize(ticket);
|
|
252
716
|
resolve(null);
|
|
253
717
|
}
|
|
254
718
|
});
|
|
255
719
|
xhr.addEventListener("error", () => {
|
|
256
720
|
this._xhr = null;
|
|
257
|
-
if (
|
|
721
|
+
if (!this._lane.claimTerminal(ticket, "error")) {
|
|
258
722
|
resolve(null);
|
|
723
|
+
this._lane.finalize(ticket);
|
|
259
724
|
return;
|
|
260
725
|
}
|
|
261
|
-
|
|
262
|
-
this.
|
|
726
|
+
const message = "Network error";
|
|
727
|
+
this._commitStep(ticket, () => {
|
|
728
|
+
this._setErrorInfo(WCS_UPLOAD_ERROR_CODE.Network, "execute", true, message);
|
|
729
|
+
this._setError({ message });
|
|
730
|
+
});
|
|
731
|
+
this._commitStep(ticket, () => this._setLoading(false));
|
|
732
|
+
this._lane.finalize(ticket);
|
|
263
733
|
resolve(null);
|
|
264
734
|
});
|
|
265
735
|
xhr.addEventListener("abort", () => {
|
|
266
736
|
this._xhr = null;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
737
|
+
// abort is a routine cancellation, not a failure: claim the `aborted`
|
|
738
|
+
// terminal and clear loading only (no error/errorInfo). A superseded /
|
|
739
|
+
// disposed ticket fails the claim and drops.
|
|
740
|
+
if (this._lane.claimTerminal(ticket, "aborted")) {
|
|
741
|
+
this._commitStep(ticket, () => this._setLoading(false));
|
|
270
742
|
}
|
|
271
|
-
this.
|
|
743
|
+
this._lane.finalize(ticket);
|
|
272
744
|
resolve(null);
|
|
273
745
|
});
|
|
274
746
|
xhr.open(method, url);
|
|
@@ -488,6 +960,9 @@ class WcsUpload extends HTMLElement {
|
|
|
488
960
|
get status() {
|
|
489
961
|
return this._core.status;
|
|
490
962
|
}
|
|
963
|
+
get errorInfo() {
|
|
964
|
+
return this._core.errorInfo;
|
|
965
|
+
}
|
|
491
966
|
get promise() {
|
|
492
967
|
return this._core.promise;
|
|
493
968
|
}
|
|
@@ -631,5 +1106,5 @@ function bootstrapUpload(userConfig) {
|
|
|
631
1106
|
registerComponents();
|
|
632
1107
|
}
|
|
633
1108
|
|
|
634
|
-
export { UploadCore, WcsUpload, bootstrapUpload, getConfig };
|
|
1109
|
+
export { UploadCore, WCS_UPLOAD_ERROR_CODE, WcsUpload, bootstrapUpload, getConfig };
|
|
635
1110
|
//# sourceMappingURL=index.esm.js.map
|