@wcstack/share 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
@@ -36,19 +36,398 @@ function setConfig(partialConfig) {
36
36
  frozenConfig = null;
37
37
  }
38
38
 
39
+ // ===========================================================================
40
+ // AUTO-GENERATED FILE - DO NOT EDIT.
41
+ // Generated from /io-core/operation-lane.ts by scripts/sync-io-core.mjs.
42
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
43
+ // ===========================================================================
44
+ /**
45
+ * 1 レーン = 独立した排他単位。Core が 1 つ以上所有する (module singleton にしない —
46
+ * 複数 <wcs-fetch> 間で漏れるため)。
47
+ */
48
+ class OperationLane {
49
+ laneKey;
50
+ policy;
51
+ _ownerGeneration = 0;
52
+ _latestEpoch = 0;
53
+ _nextOperationId = 1;
54
+ // latest / queue / exhaust の単一 active。queue は head を指す。
55
+ _activeOperationId = undefined;
56
+ // overlap の active set (§5: 内部 bookkeeping のみ・observable 公開はしない)。
57
+ _activeOperationIds = new Set();
58
+ // queue policy の FIFO。
59
+ _queue = [];
60
+ _inFlightCount = 0;
61
+ // opId → 終端状態 (absence = pending)。
62
+ _terminal = new Map();
63
+ // claimTerminal で確定した outcome (finalize が 'committing' を最終値へ移す)。
64
+ _claimedOutcome = new Map();
65
+ // opId → AbortController (identity は opId が保証。cross-op clobber は構造上起きない)。
66
+ _controllers = new Map();
67
+ // opId → attempt 数。
68
+ _attempts = new Map();
69
+ _withSignal;
70
+ _trace;
71
+ constructor(laneKey, policy, options = {}) {
72
+ this.laneKey = laneKey;
73
+ this.policy = policy;
74
+ this._withSignal = options.withSignal ?? false;
75
+ this._trace = options.trace;
76
+ }
77
+ get ownerGeneration() {
78
+ return this._ownerGeneration;
79
+ }
80
+ get inFlightCount() {
81
+ return this._inFlightCount;
82
+ }
83
+ get latestEpoch() {
84
+ return this._latestEpoch;
85
+ }
86
+ get activeOperationId() {
87
+ return this._activeOperationId;
88
+ }
89
+ /**
90
+ * 新しい要求の到着。arrival policy を適用し ticket + 最初の attempt を発行する。
91
+ * exhaust で実行中の場合だけ null を返す (新要求を ticket 化せず拒否 = 冪等 no-op)。
92
+ */
93
+ begin() {
94
+ let supersedeEpoch;
95
+ switch (this.policy) {
96
+ case "latest": {
97
+ // latestEpoch を進め、旧 active を abort (可能なら)。旧 ticket は settle 時に
98
+ // eligibility 不一致で stale となる。
99
+ supersedeEpoch = ++this._latestEpoch;
100
+ if (this._activeOperationId !== undefined) {
101
+ this._abortController(this._activeOperationId);
102
+ }
103
+ break;
104
+ }
105
+ case "exhaust": {
106
+ // 実行中なら新要求を拒否 (呼び出し側は既存結果へ合流)。
107
+ if (this._activeOperationId !== undefined) {
108
+ return null;
109
+ }
110
+ break;
111
+ }
112
+ }
113
+ const operationId = this._nextOperationId++;
114
+ const ticket = {
115
+ operationId,
116
+ ownerGeneration: this._ownerGeneration,
117
+ laneKey: this.laneKey,
118
+ policy: this.policy,
119
+ supersedeEpoch,
120
+ };
121
+ switch (this.policy) {
122
+ case "latest":
123
+ case "exhaust":
124
+ this._activeOperationId = operationId;
125
+ break;
126
+ case "queue":
127
+ this._queue.push(ticket);
128
+ // 先頭だけを active にする (先行が完了するまで待つ)。
129
+ if (this._activeOperationId === undefined) {
130
+ this._activeOperationId = operationId;
131
+ }
132
+ break;
133
+ case "overlap":
134
+ this._activeOperationIds.add(operationId);
135
+ break;
136
+ }
137
+ this._inFlightCount += 1;
138
+ this._attempts.set(operationId, 1);
139
+ const attempt = this._makeAttempt(operationId, 1);
140
+ if (this._trace !== undefined) {
141
+ this._trace({ type: "io:operation-started", operationId, laneKey: this.laneKey, policy: this.policy });
142
+ }
143
+ return { ticket, attempt };
144
+ }
145
+ /**
146
+ * retry: 同じ operationId に新しい attempt を作る。attempt number と resource signal
147
+ * だけを更新する (§5)。既に終端した operation には作れない (null)。
148
+ */
149
+ retry(ticket) {
150
+ if (ticket.ownerGeneration !== this._ownerGeneration)
151
+ return null;
152
+ if (this._terminal.has(ticket.operationId))
153
+ return null;
154
+ const previous = this._attempts.get(ticket.operationId);
155
+ if (previous === undefined)
156
+ return null;
157
+ const attemptNo = previous + 1;
158
+ this._attempts.set(ticket.operationId, attemptNo);
159
+ // 前の attempt の signal は破棄し、新しい controller を張る。
160
+ this._releaseController(ticket.operationId);
161
+ const attempt = this._makeAttempt(ticket.operationId, attemptNo);
162
+ if (this._trace !== undefined) {
163
+ this._trace({ type: "io:operation-retried", operationId: ticket.operationId, laneKey: this.laneKey, attempt: attemptNo });
164
+ }
165
+ return attempt;
166
+ }
167
+ /**
168
+ * CommitGuard (§5.1)。外部可視の setter / event dispatch の直前に呼ぶ。
169
+ * (1) owner lifecycle generation 一致 (2) terminal settle 前 (3) policy eligibility。
170
+ */
171
+ canCommit(ticket) {
172
+ if (ticket.ownerGeneration !== this._ownerGeneration)
173
+ return false;
174
+ const status = this._terminal.get(ticket.operationId);
175
+ // absence = pending / 'committing' = multi-setter commit 中。どちらも settle 前。
176
+ if (status !== undefined && status !== "committing")
177
+ return false;
178
+ return this._isEligible(ticket);
179
+ }
180
+ /**
181
+ * terminal CAS (§5.1): pending → committing を claim する。勝者だけが true。
182
+ * eligibility / owner gen を満たさない場合も false。claim 後は commit 中となり、
183
+ * canCommit は各 setter の直前で再検査する (setter が同期 supersede しても取りこぼさない)。
184
+ */
185
+ claimTerminal(ticket, outcome) {
186
+ if (ticket.ownerGeneration !== this._ownerGeneration)
187
+ return false;
188
+ if (this._terminal.has(ticket.operationId))
189
+ return false; // 既に committing / 終端
190
+ if (!this._isEligible(ticket))
191
+ return false;
192
+ this._terminal.set(ticket.operationId, "committing");
193
+ this._claimedOutcome.set(ticket.operationId, outcome);
194
+ return true;
195
+ }
196
+ /** claim 済み outcome (timer が claim → catch が読む等)。未 claim なら undefined。 */
197
+ claimedOutcome(ticket) {
198
+ return this._claimedOutcome.get(ticket.operationId);
199
+ }
200
+ /**
201
+ * operation の後始末。claim 済みなら outcome を確定し、未 claim なら stale-drop。
202
+ * controller を解放し in-flight を減らし、policy の bookkeeping を進める。冪等。
203
+ */
204
+ finalize(ticket) {
205
+ const operationId = ticket.operationId;
206
+ const status = this._terminal.get(operationId);
207
+ if (status !== undefined && status !== "committing") {
208
+ // 既に確定済み。冪等に return。
209
+ return;
210
+ }
211
+ let outcome;
212
+ if (status === "committing") {
213
+ outcome = this._claimedOutcome.get(operationId) ?? "stale";
214
+ }
215
+ else {
216
+ // 一度も claim されなかった (supersede / dispose で eligibility を失った)。
217
+ outcome = "stale";
218
+ }
219
+ this._terminal.set(operationId, outcome);
220
+ this._claimedOutcome.delete(operationId);
221
+ this._releaseController(operationId);
222
+ this._attempts.delete(operationId);
223
+ if (this._inFlightCount > 0)
224
+ this._inFlightCount -= 1;
225
+ this._advanceBookkeeping(operationId);
226
+ if (this._trace !== undefined) {
227
+ if (outcome === "stale") {
228
+ this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
229
+ }
230
+ else {
231
+ this._trace({ type: "io:operation-settled", operationId, laneKey: this.laneKey, outcome });
232
+ }
233
+ }
234
+ }
235
+ /** operation の signal (resource 解放用)。withSignal でなければ undefined。 */
236
+ signalOf(ticket) {
237
+ return this._controllers.get(ticket.operationId)?.signal;
238
+ }
239
+ /** best-effort な resource 中断。正しさは owner gen / eligibility / terminal CAS が担う。 */
240
+ abort(ticket) {
241
+ this._abortController(ticket.operationId);
242
+ }
243
+ /**
244
+ * 現在 active な operation を中断する (利用者による明示キャンセル)。epoch は進めない —
245
+ * 中断された operation は eligibility を保ったまま 'aborted' を claim できる
246
+ * (loading をクリアしつつ in-flight 状態を残す)。
247
+ */
248
+ abortActive() {
249
+ if (this._activeOperationId !== undefined) {
250
+ this._abortController(this._activeOperationId);
251
+ }
252
+ for (const operationId of this._activeOperationIds) {
253
+ this._abortController(operationId);
254
+ }
255
+ }
256
+ /**
257
+ * dispose (§4.1 world generation)。owner generation を bump して全 ticket を無効化し、
258
+ * 生きている controller を全て abort する。dispose 後に settle した operation は
259
+ * owner gen 不一致で外部 commit しない。retention gate (§10.3) のため live な
260
+ * 全 operation を即時に stale として finalize し、controller / attempt を解放する。
261
+ */
262
+ disposeOwner() {
263
+ this._ownerGeneration += 1;
264
+ for (const operationId of Array.from(this._controllers.keys())) {
265
+ this._abortController(operationId);
266
+ // finalize は dispose 後 (terminal='stale') に early-return するため controller を
267
+ // 解放しない。retention gate (§10.3) を満たすためここで明示的に解放する。
268
+ this._releaseController(operationId);
269
+ }
270
+ for (const operationId of Array.from(this._attempts.keys())) {
271
+ if (!this._terminal.has(operationId)) {
272
+ this._terminal.set(operationId, "stale");
273
+ }
274
+ this._claimedOutcome.delete(operationId);
275
+ this._attempts.delete(operationId);
276
+ if (this._trace !== undefined) {
277
+ this._trace({ type: "io:stale-dropped", operationId, laneKey: this.laneKey });
278
+ }
279
+ }
280
+ this._activeOperationId = undefined;
281
+ this._activeOperationIds.clear();
282
+ this._queue.length = 0;
283
+ this._inFlightCount = 0;
284
+ }
285
+ // --- internal ---
286
+ _makeAttempt(operationId, attemptNo) {
287
+ let signal;
288
+ // AbortController 不在環境(古い runtime / 一部 SSR)では degraded: signal なしで進む。
289
+ // 正しさは owner generation / eligibility / terminal CAS が担うため、native 中断が
290
+ // 無くても supersede / dispose は機能する(best-effort resource 中断が省かれるだけ)。
291
+ if (this._withSignal && typeof AbortController === "function") {
292
+ const controller = new AbortController();
293
+ this._controllers.set(operationId, controller);
294
+ signal = controller.signal;
295
+ }
296
+ return { operationId, attempt: attemptNo, signal };
297
+ }
298
+ _isEligible(ticket) {
299
+ switch (this.policy) {
300
+ case "latest":
301
+ return ticket.supersedeEpoch === this._latestEpoch;
302
+ case "queue":
303
+ case "exhaust":
304
+ return this._activeOperationId === ticket.operationId;
305
+ case "overlap":
306
+ return this._activeOperationIds.has(ticket.operationId);
307
+ }
308
+ }
309
+ _advanceBookkeeping(operationId) {
310
+ switch (this.policy) {
311
+ case "latest":
312
+ case "exhaust":
313
+ if (this._activeOperationId === operationId) {
314
+ this._activeOperationId = undefined;
315
+ }
316
+ break;
317
+ case "queue": {
318
+ // 完了した ticket を FIFO から取り除き、次の先頭を active にする。filter で
319
+ // 「先頭 / 非先頭 / 不在」を一様に扱う (finalize は冪等ガードを通った op のみ到達)。
320
+ const remaining = this._queue.filter((t) => t.operationId !== operationId);
321
+ this._queue.length = 0;
322
+ this._queue.push(...remaining);
323
+ this._activeOperationId = this._queue.length > 0 ? this._queue[0].operationId : undefined;
324
+ break;
325
+ }
326
+ case "overlap":
327
+ this._activeOperationIds.delete(operationId);
328
+ break;
329
+ }
330
+ }
331
+ _abortController(operationId) {
332
+ const controller = this._controllers.get(operationId);
333
+ if (controller !== undefined && !controller.signal.aborted) {
334
+ controller.abort();
335
+ }
336
+ }
337
+ _releaseController(operationId) {
338
+ this._controllers.delete(operationId);
339
+ }
340
+ }
341
+
342
+ // ===========================================================================
343
+ // AUTO-GENERATED FILE - DO NOT EDIT.
344
+ // Generated from /io-core/platform-capability.ts by scripts/sync-io-core.mjs.
345
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
346
+ // ===========================================================================
347
+ function isSecureContext() {
348
+ return globalThis.isSecureContext === true;
349
+ }
350
+ /**
351
+ * capability を利用直前に評価して PlatformAssessment を作る。
352
+ * required が 1 つでも欠ければ readiness は "idle"(開始不可)、
353
+ * required 揃い + optional 欠けは "degraded"、全揃いは "ready"。
354
+ */
355
+ function assessCapabilities(registry, options) {
356
+ const availability = new Map();
357
+ const evaluate = (id) => {
358
+ const spec = registry.get(id);
359
+ if (spec === undefined)
360
+ return "unknown";
361
+ return spec.probe() ? "available" : "missing";
362
+ };
363
+ let requiredAllAvailable = true;
364
+ for (const id of options.required) {
365
+ const a = evaluate(id);
366
+ availability.set(id, a);
367
+ if (a !== "available")
368
+ requiredAllAvailable = false;
369
+ }
370
+ let optionalAllAvailable = true;
371
+ for (const id of options.optional ?? []) {
372
+ const a = evaluate(id);
373
+ availability.set(id, a);
374
+ if (a !== "available")
375
+ optionalAllAvailable = false;
376
+ }
377
+ const readiness = !requiredAllAvailable ? "idle" : (optionalAllAvailable ? "ready" : "degraded");
378
+ // preconditions: 対象 capability のいずれかが要求する場合だけ評価する。
379
+ const allIds = [...options.required, ...(options.optional ?? [])];
380
+ const needsSecure = allIds.some((id) => registry.get(id)?.requiresSecureContext === true);
381
+ const needsActivation = allIds.some((id) => registry.get(id)?.requiresUserActivation === true);
382
+ const secureContext = needsSecure ? (isSecureContext() ? "satisfied" : "required") : "not-applicable";
383
+ const userActivation = needsActivation ? "required" : "not-applicable";
384
+ return {
385
+ availability,
386
+ permission: options.permission ?? "not-applicable",
387
+ readiness,
388
+ activity: options.activity ?? "inactive",
389
+ preconditions: { secureContext, userActivation },
390
+ epoch: options.epoch ?? 0,
391
+ lastError: options.lastError,
392
+ };
393
+ }
394
+ /** availability から「required がすべて available か」を判定するヘルパ(supported の最低条件)。 */
395
+ function requiredCapabilitiesAvailable(assessment, required) {
396
+ return required.every((id) => assessment.availability.get(id) === "available");
397
+ }
398
+
399
+ /**
400
+ * shareCapabilities.ts
401
+ *
402
+ * Web Share node 固有の capability registry と error code。汎用の assess 機構・型は
403
+ * `./platformCapability.js`(/io-core/ から copy-distribution される生成ファイル)から
404
+ * import する。node 固有の宣言はこのハンドライトファイルに置き、生成コピーとは分離する。
405
+ */
406
+ /** 安定した share error code(taxonomy)。値は公開キーとして固定。 */
407
+ const WCS_SHARE_ERROR_CODE = {
408
+ CapabilityMissing: "capability-missing",
409
+ ShareFailed: "share-failed",
410
+ };
411
+ /** share node の capability registry。文字列 ID を eval せず明示 probe を持つ。 */
412
+ const SHARE_CAPABILITIES = new Map([
413
+ ["web.share", { probe: () => typeof globalThis.navigator?.share === "function", compatKey: "api.Navigator.share" }],
414
+ ]);
415
+
39
416
  /**
40
417
  * Headless Web Share primitive. A thin, framework-agnostic wrapper around
41
418
  * `navigator.share(data)` exposed through the wc-bindable protocol.
42
419
  *
43
- * This is a simplified derivative of `FetchCore._doFetch`
44
- * (docs/web-share-tag-design.md §2): it keeps the single `_gen` generation
45
- * guard, the same-value-guarded private setters, and the never-throw
46
- * try/catch wrapper, but drops `AbortController`/`abort()` entirely —
47
- * `navigator.share()` accepts no `AbortSignal` and there is no platform
48
- * mechanism for a caller to cancel an in-flight share dialog. A share dialog
49
- * is also a single system-modal surface (at most one open at a time), so the
50
- * "a new call supersedes the previous one" plumbing that `FetchCore` needs
51
- * has no counterpart here either.
420
+ * Concurrency is owned by the shared `OperationLane` (io-core) with the `exhaust`
421
+ * policy: a share dialog is a single system-modal surface, so while one share() is
422
+ * in flight a new call is rejected as an idempotent no-op instead of starting a
423
+ * second `navigator.share()`. This replaces the earlier dispose-only `_gen` guard,
424
+ * which relied on the platform rejecting the second call with `InvalidStateError`
425
+ * — but that let the rejected second call reset/overwrite the still-pending first
426
+ * call's `error`/`loading` state. The lane's owner generation still invalidates any
427
+ * in-flight share() on dispose() (a late resolve fails the commit guard).
428
+ *
429
+ * `navigator.share()` accepts no `AbortSignal` and there is no platform mechanism
430
+ * to cancel an in-flight share dialog, so the lane runs with `withSignal: false`.
52
431
  */
53
432
  class ShareCore extends EventTarget {
54
433
  static wcBindable = {
@@ -59,29 +438,29 @@ class ShareCore extends EventTarget {
59
438
  { name: "loading", event: "wcs-share:loading-changed" },
60
439
  { name: "error", event: "wcs-share:error" },
61
440
  { name: "cancelled", event: "wcs-share:cancelled-changed" },
441
+ // Serializable failure taxonomy (stable code / phase / recoverable), or null.
442
+ // Additive bindable output; the existing `error` property/event are unchanged.
443
+ // Fires its own `wcs-share:error-info-changed` event; no getter, so the bound
444
+ // value is the event detail (mirrors `error` / `loading` / `cancelled`).
445
+ { name: "errorInfo", event: "wcs-share:error-info-changed" },
62
446
  ],
63
447
  commands: [
64
448
  { name: "share", async: true },
65
449
  ],
66
450
  };
451
+ // Required capability (probed at call time, never at module eval). `web.share`
452
+ // is the only required API; there is no optional/degraded surface for share.
453
+ static REQUIRED_CAPABILITIES = ["web.share"];
67
454
  _target;
68
455
  _value = null;
69
456
  _loading = false;
70
457
  _error = null;
71
458
  _cancelled = false;
72
- // Generation guard (§3.4 of the guidelines): bumped ONLY by dispose(). A
73
- // share() that settles after dispose() has a stale `gen` and MUST NOT write
74
- // state to a torn-down element. Unlike FetchCore/EyedropperCore, share()
75
- // itself does NOT bump `_gen` on each call: docs/web-share-tag-design.md §2
76
- // deliberately drops the "a new call supersedes the previous one" plumbing
77
- // those cores need, because the platform allows only one open share dialog
78
- // at a time (a second concurrent share() rejects with InvalidStateError on
79
- // its own). Bumping `_gen` per call would instead let a fast-failing second
80
- // call incorrectly invalidate a still-pending first call's eventual
81
- // success. Also not bumped on the unsupported early-return — no
82
- // asynchronous work is started, so there is no generation to protect
83
- // (docs/web-share-tag-design.md §8).
84
- _gen = 0;
459
+ _errorInfo = null;
460
+ // Concurrency lane (io-core). `exhaust`: only one share dialog at a time — a new
461
+ // begin() while active returns null (idempotent no-op). `withSignal: false`:
462
+ // navigator.share() has no AbortSignal. dispose() bumps the owner generation.
463
+ _lane = new OperationLane("share", "exhaust", { withSignal: false });
85
464
  // SSR (§3.8): no asynchronous probe to await, so readiness is immediate.
86
465
  _ready = Promise.resolve();
87
466
  constructor(target) {
@@ -103,15 +482,42 @@ class ShareCore extends EventTarget {
103
482
  get cancelled() {
104
483
  return this._cancelled;
105
484
  }
106
- // Lifecycle (§3.5). Share is command-driven with no subscription to
107
- // establish, so observe() is an idempotent no-op that resolves once ready;
108
- // dispose() only invalidates any in-flight share() (there is nothing to
109
- // abort or unsubscribe).
485
+ /**
486
+ * The last failure's serializable `WcsIoErrorInfo` (stable `code` / `phase` /
487
+ * `recoverable` / `capabilityId`), or null. Exposed as an additive wc-bindable
488
+ * property (event `wcs-share:error-info-changed`); the existing `error`
489
+ * property/event are unchanged.
490
+ */
491
+ get errorInfo() {
492
+ return this._errorInfo;
493
+ }
494
+ /**
495
+ * Whether the required platform capability (`web.share`) is available right now —
496
+ * decided by call-time feature detection, not User-Agent. Core-only, additive.
497
+ */
498
+ get supported() {
499
+ return requiredCapabilitiesAvailable(this.platformAssessment, ShareCore.REQUIRED_CAPABILITIES);
500
+ }
501
+ /**
502
+ * Full platform assessment (availability / readiness / preconditions), probed at
503
+ * call time. Core-only opt-in dev / sidecar view.
504
+ */
505
+ get platformAssessment() {
506
+ return assessCapabilities(SHARE_CAPABILITIES, {
507
+ required: ShareCore.REQUIRED_CAPABILITIES,
508
+ activity: this._loading ? "active" : "inactive",
509
+ lastError: this._errorInfo ?? undefined,
510
+ });
511
+ }
512
+ // Lifecycle (§3.5). Share is command-driven with no subscription to establish,
513
+ // so observe() is an idempotent no-op that resolves once ready; dispose() bumps
514
+ // the lane's owner generation, invalidating any in-flight share() (a late resolve
515
+ // then fails the commit guard). There is nothing to abort or unsubscribe.
110
516
  observe() {
111
517
  return this._ready;
112
518
  }
113
519
  dispose() {
114
- this._gen++;
520
+ this._lane.disposeOwner();
115
521
  }
116
522
  _setLoading(loading) {
117
523
  if (this._loading === loading)
@@ -156,61 +562,88 @@ class ShareCore extends EventTarget {
156
562
  bubbles: true,
157
563
  }));
158
564
  }
159
- // API resolution is call-time, never cached (§3.7): lets tests install/remove
160
- // navigator.share freely and lets an unsupported environment be detected
161
- // correctly on every call.
162
- _api() {
163
- const nav = globalThis.navigator;
164
- return typeof nav?.share === "function" ? nav.share.bind(nav) : undefined;
565
+ // Single mutation point for `errorInfo`, mirroring `_setError`'s same-value guard
566
+ // and event dispatch so the additive `errorInfo` wc-bindable property stays in
567
+ // sync with `error`. Each failure builds a fresh object (reference guard passes);
568
+ // the clear path passes null (suppresses a redundant null→null per share start).
569
+ _setErrorInfo(code, phase, recoverable, message, capabilityId) {
570
+ this._commitErrorInfo({ code, phase, recoverable, message, ...(capabilityId === undefined ? {} : { capabilityId }) });
571
+ }
572
+ _commitErrorInfo(info) {
573
+ if (this._errorInfo === info)
574
+ return;
575
+ this._errorInfo = info;
576
+ this._target.dispatchEvent(new CustomEvent("wcs-share:error-info-changed", {
577
+ detail: info,
578
+ bubbles: true,
579
+ }));
165
580
  }
166
581
  async share(data) {
167
- // never-throw + unsupported (§8): resolve API at call time and bail out
168
- // immediately if absent. No _gen bump — no asynchronous work is started,
169
- // so there is no generation to protect, and navigator.share() itself is
170
- // never invoked.
171
- const shareFn = this._api();
172
- if (!shareFn) {
173
- this._setError({ message: "Web Share API is not supported in this browser." });
582
+ // never-throw + unsupported (§8 / §7.2): probe the required capability at call
583
+ // time. If `web.share` is absent, do NOT start — surface a stable
584
+ // `capability-missing` taxonomy and the existing error message shape.
585
+ const assessment = this.platformAssessment;
586
+ if (!requiredCapabilitiesAvailable(assessment, ShareCore.REQUIRED_CAPABILITIES)) {
587
+ const missing = ShareCore.REQUIRED_CAPABILITIES.find((id) => assessment.availability.get(id) !== "available");
588
+ const message = "Web Share API is not supported in this browser.";
589
+ this._setErrorInfo(WCS_SHARE_ERROR_CODE.CapabilityMissing, "start", false, message, missing);
590
+ this._setError({ message });
591
+ return null;
592
+ }
593
+ // exhaust: a share dialog is already open → reject this call as an idempotent
594
+ // no-op instead of racing a second navigator.share() (which would reject and
595
+ // corrupt the in-flight call's result). begin() returns null when active.
596
+ const started = this._lane.begin();
597
+ if (started === null) {
174
598
  return null;
175
599
  }
176
- // Captured, not bumped (see the `_gen` field docs above): share() does
177
- // not supersede a prior in-flight call, only dispose() invalidates.
178
- const gen = this._gen;
600
+ const { ticket } = started;
601
+ // Capability probed above → navigator.share is present. Resolve + bind at call
602
+ // time (never cached, §3.7) so tests can install/remove it freely.
603
+ const nav = globalThis.navigator;
604
+ const shareFn = nav.share.bind(nav);
605
+ // Start phase runs synchronously on a fresh ticket (no dispose can interleave
606
+ // before the first await), so these setters are unconditional. Post-await
607
+ // setters are gated by the lane's terminal CAS (claimTerminal), which fails on
608
+ // a stale/disposed ticket — share (exhaust, no supersede/timeout) needs no
609
+ // per-setter commit guard (unlike FetchCore's `latest` lane).
179
610
  this._setLoading(true);
180
611
  // Reset the previous outcome before starting a new share so a stale
181
- // cancelled/error does not linger into this call's result
182
- // (docs/web-share-tag-design.md §3).
612
+ // cancelled/error/errorInfo does not linger into this call's result (§3).
613
+ this._commitErrorInfo(null);
183
614
  this._setError(null);
184
615
  this._setCancelled(false);
185
616
  try {
186
617
  await shareFn(data);
187
- // Stale completion (dispose() ran while the share dialog was open).
188
- // Drop the result without writing state.
189
- if (gen !== this._gen) {
618
+ // Terminal CAS: a stale (dispose-invalidated) completion loses the claim.
619
+ if (!this._lane.claimTerminal(ticket, "success")) {
190
620
  return null;
191
621
  }
192
- // navigator.share() resolves `Promise<void>` — there is no payload to
193
- // read off the API, so `value` is synthesized as an echo of the caller's
194
- // `data`, signalling "this share completed successfully"
195
- // (docs/web-share-tag-design.md §4).
622
+ // navigator.share() resolves `Promise<void>` — there is no payload to read
623
+ // off the API, so `value` is synthesized as an echo of the caller's `data`,
624
+ // signalling "this share completed successfully" (§4).
196
625
  this._setValue(data ?? null);
197
626
  this._setLoading(false);
627
+ this._lane.finalize(ticket);
198
628
  return data ?? null;
199
629
  }
200
630
  catch (e) {
201
- // Stale completion (dispose() ran while the share dialog was open).
202
- if (gen !== this._gen) {
631
+ const cancelled = e?.name === "AbortError";
632
+ if (!this._lane.claimTerminal(ticket, cancelled ? "aborted" : "error")) {
203
633
  return null;
204
634
  }
205
- if (e?.name === "AbortError") {
635
+ if (cancelled) {
206
636
  // The user dismissed the share sheet — a routine cancellation, not a
207
- // platform failure. Kept out of `error` (docs/web-share-tag-design.md §3).
637
+ // platform failure. Kept out of `error`/`errorInfo` (§3).
208
638
  this._setCancelled(true);
209
639
  }
210
640
  else {
211
- this._setError(e);
641
+ const message = String(e?.message ?? "Share failed.");
642
+ this._setErrorInfo(WCS_SHARE_ERROR_CODE.ShareFailed, "execute", true, message);
643
+ this._setError(e ?? { message });
212
644
  }
213
645
  this._setLoading(false);
646
+ this._lane.finalize(ticket);
214
647
  return null;
215
648
  }
216
649
  }
@@ -308,6 +741,9 @@ class WcsShare extends HTMLElement {
308
741
  get cancelled() {
309
742
  return this._core.cancelled;
310
743
  }
744
+ get errorInfo() {
745
+ return this._core.errorInfo;
746
+ }
311
747
  get connectedCallbackPromise() {
312
748
  return this._connectedCallbackPromise;
313
749
  }
@@ -356,5 +792,5 @@ function bootstrapShare(userConfig) {
356
792
  registerComponents();
357
793
  }
358
794
 
359
- export { ShareCore, WcsShare, bootstrapShare, getConfig };
795
+ export { ShareCore, WCS_SHARE_ERROR_CODE, WcsShare, bootstrapShare, getConfig };
360
796
  //# sourceMappingURL=index.esm.js.map