@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/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
- // Generation guard: bumped on dispose() (and each upload start). An in-flight
82
- // request that settles after dispose / a superseding start has a stale `gen`
83
- // and MUST NOT write state to a torn-down element. A boolean flag is
84
- // insufficient (dispose→observe would let stale work slip through).
85
- _gen = 0;
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
- // establish, so observe() is an idempotent no-op that resolves once ready;
97
- // dispose() invalidates any in-flight request and aborts it.
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._gen++;
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
- // `_xhr` send() の直前に同期で代入されるため、send 前に外部から abort
171
- // 割り込む余地はない(割り込み点となる await が存在しない)。よって XHR.abort()
172
- // は常に進行中のリクエストに対して呼ばれ、abort イベントが発火して loading
173
- // 解除する。loading の解除を abort イベントハンドラに集約しているのは、
174
- // ネットワークエラー/HTTP エラー/正常完了/中断のすべてで解除経路を一本化し、
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._setLoading(true);
202
- this._setProgress(0);
203
- this._setError(null);
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
- if (gen !== this._gen)
215
- return;
216
- if (event.lengthComputable) {
217
- const percent = Math.round((event.loaded / event.total) * 100);
218
- this._setProgress(percent);
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
- const error = {
246
- status: xhr.status,
247
- statusText: xhr.statusText,
248
- body: xhr.responseText,
249
- };
250
- this._setError(error);
251
- this._setLoading(false);
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 (gen !== this._gen) {
721
+ if (!this._lane.claimTerminal(ticket, "error")) {
258
722
  resolve(null);
723
+ this._lane.finalize(ticket);
259
724
  return;
260
725
  }
261
- this._setError({ message: "Network error" });
262
- this._setLoading(false);
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
- if (gen !== this._gen) {
268
- resolve(null);
269
- return;
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._setLoading(false);
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