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