@dl2app/react-native 0.0.0-stage → 0.1.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.
@@ -0,0 +1,833 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SDK_VERSION = void 0;
4
+ exports.parseUrl = parseUrl;
5
+ exports.extractClipboardToken = extractClipboardToken;
6
+ exports.createDL2 = createDL2;
7
+ exports.SDK_VERSION = "0.1.0";
8
+ const K_ID = "install_id";
9
+ const K_DONE = "first_launch_done";
10
+ const K_ATTEMPTS = "open_attempts";
11
+ const K_QUEUE = "event_queue";
12
+ const K_REFERRER = "install_referrer";
13
+ const RESOLVE_TIMEOUT_MS = 3000;
14
+ const EVENT_TIMEOUT_MS = 5000;
15
+ const REFERRER_TIMEOUT_MS = 1500;
16
+ const DEDUPE_MS = 2000;
17
+ const MAX_QUEUED_EVENTS = 100;
18
+ const MAX_PENDING_LINKS = 20;
19
+ const MAX_OPEN_ATTEMPTS = 3;
20
+ const RETRY_BASE_MS = 2000;
21
+ const RETRY_MAX_MS = 300000;
22
+ function mkErr(code, message, cause, status) {
23
+ const e = { code, message };
24
+ if (cause !== undefined)
25
+ e.cause = cause;
26
+ if (status !== undefined)
27
+ e.status = status;
28
+ return e;
29
+ }
30
+ function isErr(e) {
31
+ return !!e && typeof e === "object" && typeof e.code === "string" && typeof e.message === "string";
32
+ }
33
+ function toErr(e, message) {
34
+ return isErr(e) ? e : mkErr("unknown", message, e);
35
+ }
36
+ function decode(s) {
37
+ try {
38
+ return decodeURIComponent(s.replace(/\+/g, " "));
39
+ }
40
+ catch {
41
+ return s;
42
+ }
43
+ }
44
+ /** Small URL parser: React Native's built-in `URL` is incomplete on some versions. */
45
+ function parseUrl(url) {
46
+ if (typeof url !== "string")
47
+ return null;
48
+ const m = /^([a-z][a-z0-9+.-]*):(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?/i.exec(url.trim());
49
+ if (!m)
50
+ return null;
51
+ const scheme = m[1].toLowerCase();
52
+ const web = scheme === "http" || scheme === "https";
53
+ const authority = (m[2] || "").replace(/^[^@]*@/, "");
54
+ const host = authority.replace(/:\d+$/, "").toLowerCase();
55
+ // myapp://product/42 means the path /product/42: a custom scheme has no real host.
56
+ let path = web ? m[3] : (authority ? "/" + authority : "") + m[3];
57
+ if (path.charAt(0) !== "/")
58
+ path = "/" + path;
59
+ const params = {};
60
+ for (const pair of (m[4] || "").split("&")) {
61
+ if (!pair)
62
+ continue;
63
+ const i = pair.indexOf("=");
64
+ params[decode(i < 0 ? pair : pair.slice(0, i))] = i < 0 ? "" : decode(pair.slice(i + 1));
65
+ }
66
+ return { scheme, host: web ? host : "", path, params };
67
+ }
68
+ /** Finds a DL2 clipboard token (`dl2:{clickId}:{signature}`) in pasted text. */
69
+ function extractClipboardToken(text) {
70
+ if (typeof text !== "string")
71
+ return null;
72
+ const m = /dl2:[A-Za-z0-9_-]{6,64}:[A-Za-z0-9_-]{6,128}/.exec(text);
73
+ return m ? m[0] : null;
74
+ }
75
+ function fallbackUuid(random) {
76
+ const c = globalThis.crypto;
77
+ if (c && typeof c.randomUUID === "function")
78
+ return c.randomUUID();
79
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
80
+ const r = (random() * 16) | 0;
81
+ return (ch === "x" ? r : (r & 3) | 8).toString(16);
82
+ });
83
+ }
84
+ function createDL2(deps) {
85
+ const now = deps.now || (() => Date.now());
86
+ const random = deps.random || Math.random;
87
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
88
+ let cfg = null;
89
+ let gen = 0;
90
+ let installId = null;
91
+ let firstLaunch = false;
92
+ let storageOk = true;
93
+ let matched = false;
94
+ let info = null;
95
+ let ready = null;
96
+ let initial = null;
97
+ let sub = null;
98
+ const listeners = new Set();
99
+ let pending = [];
100
+ let lastUrl = "";
101
+ let lastAt = 0;
102
+ let lastHandled = null;
103
+ let queue = [];
104
+ let flushing = null;
105
+ let retries = 0;
106
+ let retryTimer = null;
107
+ let lastOpenResponse = null;
108
+ let lastResolveResponse = null;
109
+ let lastError = null;
110
+ // Kept across reset() so a handler passed once keeps receiving errors.
111
+ let onError;
112
+ let debug = false;
113
+ function log(...args) {
114
+ if (!debug)
115
+ return;
116
+ try {
117
+ console.log("[DL2]", ...args);
118
+ }
119
+ catch {
120
+ /* ignore */
121
+ }
122
+ }
123
+ function report(e, context) {
124
+ const err = toErr(e, context);
125
+ lastError = err;
126
+ log("error:", context, err.code, err.message);
127
+ try {
128
+ if (onError)
129
+ onError(err);
130
+ }
131
+ catch {
132
+ /* the app's handler threw: nothing more to do */
133
+ }
134
+ }
135
+ /** Runs a public method body. Nothing thrown inside ever reaches the host app. */
136
+ function guard(context, fallback, fn) {
137
+ try {
138
+ return fn();
139
+ }
140
+ catch (e) {
141
+ report(e, context);
142
+ return fallback;
143
+ }
144
+ }
145
+ async function guardAsync(context, fallback, fn) {
146
+ try {
147
+ return await fn();
148
+ }
149
+ catch (e) {
150
+ report(e, context);
151
+ return fallback;
152
+ }
153
+ }
154
+ // ---- storage -----------------------------------------------------------------------------
155
+ const memory = {};
156
+ async function getItem(key) {
157
+ if (!deps.native)
158
+ return key in memory ? memory[key] : null;
159
+ try {
160
+ const v = await deps.native.getItem(key);
161
+ return typeof v === "string" ? v : null;
162
+ }
163
+ catch (e) {
164
+ storageOk = false;
165
+ report(mkErr("unknown", "Could not read from native storage", e), "storage");
166
+ return key in memory ? memory[key] : null;
167
+ }
168
+ }
169
+ async function setItem(key, value) {
170
+ memory[key] = value;
171
+ if (!deps.native)
172
+ return;
173
+ try {
174
+ await deps.native.setItem(key, value);
175
+ }
176
+ catch (e) {
177
+ storageOk = false;
178
+ report(mkErr("unknown", "Could not write to native storage", e), "storage");
179
+ }
180
+ }
181
+ async function removeItem(key) {
182
+ delete memory[key];
183
+ if (!deps.native)
184
+ return;
185
+ try {
186
+ await deps.native.removeItem(key);
187
+ }
188
+ catch (e) {
189
+ report(mkErr("unknown", "Could not remove from native storage", e), "storage");
190
+ }
191
+ }
192
+ // ---- network -----------------------------------------------------------------------------
193
+ function httpError(status, body) {
194
+ const raw = body?.error;
195
+ const serverCode = raw && typeof raw.code === "string" ? raw.code : "";
196
+ const known = {
197
+ invalid_key: "invalid_key",
198
+ unauthorized: "invalid_key",
199
+ app_mismatch: "app_mismatch",
200
+ link_not_found: "link_not_found",
201
+ not_found: "link_not_found",
202
+ rate_limited: "rate_limited",
203
+ };
204
+ const byStatus = {
205
+ 401: "invalid_key",
206
+ 404: "link_not_found",
207
+ 422: "app_mismatch",
208
+ 429: "rate_limited",
209
+ };
210
+ const code = known[serverCode] || byStatus[status] || "unknown";
211
+ const message = (raw && typeof raw.message === "string" && raw.message) || `Request failed with status ${status}`;
212
+ return mkErr(code, message, serverCode || undefined, status);
213
+ }
214
+ /**
215
+ * POSTs JSON to the SDK API. `timeoutMs` is the budget for the whole call including the one
216
+ * retry, which happens only when the request never reached the server (network error).
217
+ */
218
+ function call(path, body, timeoutMs) {
219
+ const c = cfg;
220
+ if (!c)
221
+ return Promise.reject(mkErr("not_configured", "Call DL2.configure() first"));
222
+ return new Promise((resolve, reject) => {
223
+ const ctrl = typeof AbortController === "function" ? new AbortController() : null;
224
+ let done = false;
225
+ const timer = setTimeout(() => {
226
+ done = true;
227
+ reject(mkErr("timeout", `No response from ${path} within ${timeoutMs} ms`));
228
+ try {
229
+ if (ctrl)
230
+ ctrl.abort();
231
+ }
232
+ catch {
233
+ /* ignore */
234
+ }
235
+ }, timeoutMs);
236
+ const attempt = async (n) => {
237
+ let res;
238
+ try {
239
+ res = await deps.fetch(c.base + path, {
240
+ method: "POST",
241
+ headers: { "Content-Type": "application/json", "X-DL2-Key": c.key },
242
+ body: JSON.stringify(body),
243
+ signal: ctrl ? ctrl.signal : undefined,
244
+ });
245
+ }
246
+ catch (e) {
247
+ if (done || n >= 1)
248
+ throw mkErr("network", "Network request failed", e);
249
+ await sleep(200 + random() * 400);
250
+ if (done)
251
+ throw mkErr("network", "Network request failed", e);
252
+ return attempt(n + 1);
253
+ }
254
+ let json = null;
255
+ try {
256
+ json = await res.json();
257
+ }
258
+ catch {
259
+ /* empty or non-JSON body */
260
+ }
261
+ if (!res.ok)
262
+ throw httpError(res.status, json);
263
+ return json;
264
+ };
265
+ attempt(0).then((v) => {
266
+ clearTimeout(timer);
267
+ if (!done)
268
+ resolve(v);
269
+ }, (e) => {
270
+ clearTimeout(timer);
271
+ if (!done)
272
+ reject(e);
273
+ });
274
+ });
275
+ }
276
+ // ---- link events -------------------------------------------------------------------------
277
+ function emit(event) {
278
+ log("link event", event);
279
+ if (listeners.size === 0) {
280
+ pending.push(event);
281
+ if (pending.length > MAX_PENDING_LINKS)
282
+ pending.shift();
283
+ return;
284
+ }
285
+ for (const l of Array.from(listeners)) {
286
+ try {
287
+ l(event);
288
+ }
289
+ catch (e) {
290
+ report(mkErr("unknown", "An onLink listener threw", e), "onLink");
291
+ }
292
+ }
293
+ }
294
+ function sourceOf(p) {
295
+ if (p.scheme !== "http" && p.scheme !== "https")
296
+ return "uri_scheme";
297
+ return deps.platform === "android" ? "app_link" : "universal_link";
298
+ }
299
+ function isOurs(p) {
300
+ if (!cfg)
301
+ return false;
302
+ if (p.scheme === "http" || p.scheme === "https")
303
+ return cfg.domains.indexOf(p.host) >= 0;
304
+ return !!cfg.scheme && p.scheme === cfg.scheme;
305
+ }
306
+ function toEvent(payload, url, source, isFirstOpen) {
307
+ const local = url ? parseUrl(url) : null;
308
+ const remote = payload && payload.url ? parseUrl(payload.url) : null;
309
+ const event = {
310
+ url: url || (payload && payload.url) || null,
311
+ path: (payload && payload.path) || (local ? local.path : "/"),
312
+ params: { ...(remote ? remote.params : {}), ...(local ? local.params : {}) },
313
+ data: (payload && payload.data && typeof payload.data === "object" ? payload.data : {}),
314
+ source,
315
+ isFirstOpen,
316
+ };
317
+ if (payload) {
318
+ if (payload.match_method)
319
+ event.matchMethod = payload.match_method;
320
+ if (typeof payload.confidence === "number")
321
+ event.confidence = payload.confidence;
322
+ if (payload.link_id)
323
+ event.linkId = payload.link_id;
324
+ if (payload.utm)
325
+ event.utm = payload.utm;
326
+ }
327
+ return event;
328
+ }
329
+ async function deviceInfo() {
330
+ if (info)
331
+ return info;
332
+ let native = {};
333
+ try {
334
+ if (deps.native)
335
+ native = (await deps.native.getDeviceInfo()) || {};
336
+ }
337
+ catch (e) {
338
+ report(mkErr("unknown", "Could not read device info", e), "deviceInfo");
339
+ }
340
+ let lang = native.lang;
341
+ let tz = native.tz;
342
+ try {
343
+ const o = Intl.DateTimeFormat().resolvedOptions();
344
+ lang = lang || o.locale;
345
+ tz = tz || o.timeZone;
346
+ }
347
+ catch {
348
+ /* Intl missing on this engine */
349
+ }
350
+ info = { osVersion: native.osVersion || "", appId: native.appId || "", appVersion: native.appVersion || "", lang: lang || "", tz: tz || "" };
351
+ return info;
352
+ }
353
+ /** Request body of POST /v1/sdk/open. Every field is always present. */
354
+ async function openBody(extra) {
355
+ const d = await deviceInfo();
356
+ let screen = { w: 0, h: 0, dpr: 1 };
357
+ try {
358
+ screen = deps.screen();
359
+ }
360
+ catch {
361
+ /* keep defaults */
362
+ }
363
+ return {
364
+ install_id: installId,
365
+ is_first_launch: firstLaunch,
366
+ platform: deps.platform,
367
+ os_version: d.osVersion,
368
+ app_id_hint: d.appId,
369
+ app_version: d.appVersion,
370
+ sdk_version: exports.SDK_VERSION,
371
+ screen,
372
+ lang: d.lang,
373
+ tz: d.tz,
374
+ install_referrer: extra.install_referrer || null,
375
+ clipboard_token: extra.clipboard_token || null,
376
+ launch_url: extra.launch_url || null,
377
+ };
378
+ }
379
+ /** True when the server answered, so there is no point asking again on the next launch. */
380
+ function answered(e) {
381
+ return !(isErr(e) && (e.code === "network" || e.code === "timeout"));
382
+ }
383
+ async function finishFirstLaunch(serverAnswered) {
384
+ if (!firstLaunch || !storageOk)
385
+ return;
386
+ const attempts = (parseInt((await getItem(K_ATTEMPTS)) || "0", 10) || 0) + 1;
387
+ if (serverAnswered || attempts >= MAX_OPEN_ATTEMPTS) {
388
+ await setItem(K_DONE, "1");
389
+ await removeItem(K_ATTEMPTS);
390
+ }
391
+ else {
392
+ // Offline on first launch: ask again next launch, a few times at most.
393
+ await setItem(K_ATTEMPTS, String(attempts));
394
+ }
395
+ }
396
+ /**
397
+ * Handles a URL that opened the app. Returns undefined when the URL is not a DL2 link, the
398
+ * event otherwise (the same event for a duplicate delivery within 2 seconds).
399
+ */
400
+ function handleUrl(url, isInitial) {
401
+ const p = parseUrl(url);
402
+ if (!p || !isOurs(p))
403
+ return undefined;
404
+ const t = now();
405
+ if (url === lastUrl && t - lastAt < DEDUPE_MS && lastHandled)
406
+ return lastHandled;
407
+ lastUrl = url;
408
+ lastAt = t;
409
+ const g = gen;
410
+ const source = sourceOf(p);
411
+ lastHandled = (async () => {
412
+ if (ready)
413
+ await ready;
414
+ const first = isInitial && firstLaunch;
415
+ let payload = null;
416
+ let ok = false;
417
+ try {
418
+ if (first) {
419
+ const r = await call("/v1/sdk/open", await openBody({ launch_url: url }), RESOLVE_TIMEOUT_MS);
420
+ lastOpenResponse = r;
421
+ payload = (r && r.match) || null;
422
+ ok = true;
423
+ }
424
+ else {
425
+ payload = await call("/v1/sdk/resolve", { url, source }, RESOLVE_TIMEOUT_MS);
426
+ lastResolveResponse = payload;
427
+ }
428
+ }
429
+ catch (e) {
430
+ ok = answered(e);
431
+ report(e, "resolve");
432
+ }
433
+ if (g !== gen)
434
+ return null;
435
+ if (first)
436
+ await finishFirstLaunch(ok);
437
+ // On any failure the event is built from the URL itself so the app still navigates.
438
+ const event = toEvent(payload, url, source, first);
439
+ emit(event);
440
+ return event;
441
+ })().catch((e) => {
442
+ report(e, "handleUrl");
443
+ return null;
444
+ });
445
+ return lastHandled;
446
+ }
447
+ async function init(g) {
448
+ let id = await getItem(K_ID);
449
+ if (!id) {
450
+ try {
451
+ id = deps.native && deps.native.uuid ? await deps.native.uuid() : null;
452
+ }
453
+ catch {
454
+ id = null;
455
+ }
456
+ id = id || fallbackUuid(random);
457
+ await setItem(K_ID, id);
458
+ }
459
+ const done = await getItem(K_DONE);
460
+ let stored = null;
461
+ try {
462
+ stored = JSON.parse((await getItem(K_QUEUE)) || "[]");
463
+ }
464
+ catch {
465
+ /* corrupt queue: start empty */
466
+ }
467
+ if (g !== gen)
468
+ return;
469
+ installId = id;
470
+ // Without working storage every launch would look like the first one and could claim
471
+ // someone else's link, so deferred matching is skipped.
472
+ firstLaunch = storageOk && !!deps.native && done !== "1";
473
+ if (Array.isArray(stored))
474
+ queue = stored.concat(queue).slice(-MAX_QUEUED_EVENTS);
475
+ if (!deps.native) {
476
+ report(mkErr("unknown", "DL2 native module not found. Rebuild the app (Expo Go is not supported); deferred links are off."), "configure");
477
+ }
478
+ }
479
+ async function runInitial(g) {
480
+ await ready;
481
+ if (g !== gen || !cfg)
482
+ return null;
483
+ let url = null;
484
+ try {
485
+ url = await deps.linking.getInitialURL();
486
+ }
487
+ catch (e) {
488
+ report(mkErr("unknown", "Linking.getInitialURL failed", e), "configure");
489
+ }
490
+ if (g !== gen || !cfg)
491
+ return null;
492
+ const handled = url ? handleUrl(url, true) : undefined;
493
+ if (handled)
494
+ return handled;
495
+ if (!firstLaunch)
496
+ return null;
497
+ if (!cfg.deferred) {
498
+ await finishFirstLaunch(true);
499
+ return null;
500
+ }
501
+ let referrer = null;
502
+ if (deps.platform === "android" && deps.native) {
503
+ try {
504
+ referrer = await Promise.race([
505
+ deps.native.getInstallReferrer(),
506
+ sleep(REFERRER_TIMEOUT_MS).then(() => null),
507
+ ]);
508
+ }
509
+ catch (e) {
510
+ report(mkErr("unknown", "Could not read the install referrer", e), "installReferrer");
511
+ }
512
+ }
513
+ let event = null;
514
+ let ok = false;
515
+ try {
516
+ const r = await call("/v1/sdk/open", await openBody({ install_referrer: referrer }), cfg.timeoutMs);
517
+ lastOpenResponse = r;
518
+ ok = true;
519
+ if (g === gen && r && r.match) {
520
+ matched = true;
521
+ event = toEvent(r.match, null, "deferred", true);
522
+ }
523
+ }
524
+ catch (e) {
525
+ ok = answered(e);
526
+ report(e, "deferred");
527
+ }
528
+ if (g !== gen)
529
+ return null;
530
+ await finishFirstLaunch(ok);
531
+ if (event)
532
+ emit(event);
533
+ return event;
534
+ }
535
+ // ---- events ------------------------------------------------------------------------------
536
+ function persistQueue() {
537
+ const g = gen;
538
+ // After init has merged the stored queue, or an early write would be read back and doubled.
539
+ (ready || Promise.resolve())
540
+ .then(() => (g === gen ? setItem(K_QUEUE, JSON.stringify(queue)) : undefined))
541
+ .catch(() => undefined);
542
+ }
543
+ function retryable(e) {
544
+ if (!isErr(e))
545
+ return false;
546
+ return e.code === "network" || e.code === "timeout" || e.code === "rate_limited" || (e.status || 0) >= 500;
547
+ }
548
+ function flush() {
549
+ if (flushing)
550
+ return flushing;
551
+ if (!cfg)
552
+ return Promise.resolve();
553
+ if (retryTimer) {
554
+ clearTimeout(retryTimer);
555
+ retryTimer = null;
556
+ }
557
+ const g = gen;
558
+ flushing = (async () => {
559
+ // Wait for the launch check so the server knows this install before its first event.
560
+ if (initial)
561
+ await initial;
562
+ while (queue.length && g === gen && cfg) {
563
+ const e = queue[0];
564
+ try {
565
+ await call("/v1/sdk/event", { install_id: installId, ...e }, EVENT_TIMEOUT_MS);
566
+ }
567
+ catch (err) {
568
+ if (g !== gen)
569
+ return;
570
+ if (retryable(err)) {
571
+ // Keep the event and try again later: 2 s, 4 s, 8 s ... capped at 5 minutes.
572
+ const delay = Math.min(RETRY_BASE_MS * Math.pow(2, retries), RETRY_MAX_MS) * (0.5 + random() / 2);
573
+ retries++;
574
+ log("event delivery failed, retrying in", Math.round(delay), "ms");
575
+ retryTimer = setTimeout(() => {
576
+ retryTimer = null;
577
+ flush().catch(() => undefined);
578
+ }, delay);
579
+ return;
580
+ }
581
+ report(err, "track"); // rejected by the server: drop it
582
+ }
583
+ if (g !== gen)
584
+ return;
585
+ const i = queue.indexOf(e);
586
+ if (i >= 0)
587
+ queue.splice(i, 1);
588
+ persistQueue();
589
+ }
590
+ retries = 0;
591
+ })()
592
+ .catch((e) => report(e, "flush"))
593
+ .then(() => {
594
+ flushing = null;
595
+ });
596
+ return flushing;
597
+ }
598
+ // ---- public API --------------------------------------------------------------------------
599
+ function configure(config) {
600
+ guard("configure", undefined, () => {
601
+ if (config && typeof config.onError === "function")
602
+ onError = config.onError;
603
+ if (config && typeof config.debug === "boolean")
604
+ debug = config.debug;
605
+ if (cfg) {
606
+ cfg.onError = onError;
607
+ cfg.debug = debug;
608
+ log("configure() called again: ignored");
609
+ return;
610
+ }
611
+ if (!config || typeof config.publishableKey !== "string" || !config.publishableKey.trim()) {
612
+ throw mkErr("not_configured", "configure() needs a publishableKey (pk_live_...)");
613
+ }
614
+ if (!Array.isArray(config.linkDomains) || !config.linkDomains.length) {
615
+ throw mkErr("not_configured", "configure() needs linkDomains, for example ['myapp.dl2.app', 'myapp-alt.dl2.app']");
616
+ }
617
+ const d = config.deferred || {};
618
+ cfg = {
619
+ key: config.publishableKey.trim(),
620
+ domains: config.linkDomains
621
+ .filter((h) => typeof h === "string")
622
+ .map((h) => h.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/[/:].*$/, "")),
623
+ scheme: (config.uriScheme || "").toLowerCase().replace(/:\/\/.*$/, ""),
624
+ base: (config.apiBaseUrl || "https://api.dl2.app").replace(/\/+$/, ""),
625
+ deferred: d.enabled !== false,
626
+ timeoutMs: typeof d.timeoutMs === "number" && d.timeoutMs > 0 ? d.timeoutMs : 4000,
627
+ clipboard: d.useClipboard === true,
628
+ debug,
629
+ onError,
630
+ };
631
+ const g = ++gen;
632
+ // Subscribe before anything asynchronous so a warm link cannot slip past.
633
+ try {
634
+ const s = deps.linking.addEventListener("url", (e) => {
635
+ guard("url event", undefined, () => {
636
+ if (e && typeof e.url === "string")
637
+ handleUrl(e.url, false);
638
+ });
639
+ });
640
+ sub = s && typeof s.remove === "function" ? s : null;
641
+ }
642
+ catch (e) {
643
+ report(mkErr("unknown", "Linking.addEventListener failed", e), "configure");
644
+ }
645
+ ready = init(g).catch((e) => report(e, "configure"));
646
+ initial = runInitial(g).catch((e) => {
647
+ report(e, "configure");
648
+ return null;
649
+ });
650
+ initial.then(() => flush()).catch(() => undefined);
651
+ });
652
+ }
653
+ function onLink(listener) {
654
+ return guard("onLink", () => undefined, () => {
655
+ if (typeof listener !== "function")
656
+ throw mkErr("unknown", "onLink() needs a function");
657
+ listeners.add(listener);
658
+ if (pending.length) {
659
+ // Links that arrived before anyone listened go to the first listener.
660
+ const queued = pending;
661
+ pending = [];
662
+ Promise.resolve().then(() => {
663
+ for (const event of queued) {
664
+ if (!listeners.has(listener)) {
665
+ pending.push(event);
666
+ continue;
667
+ }
668
+ try {
669
+ listener(event);
670
+ }
671
+ catch (e) {
672
+ report(mkErr("unknown", "An onLink listener threw", e), "onLink");
673
+ }
674
+ }
675
+ });
676
+ }
677
+ return () => {
678
+ listeners.delete(listener);
679
+ };
680
+ });
681
+ }
682
+ function getInitialLink() {
683
+ return guardAsync("getInitialLink", null, async () => {
684
+ if (!cfg || !initial)
685
+ throw mkErr("not_configured", "Call DL2.configure() before getInitialLink()");
686
+ return initial;
687
+ });
688
+ }
689
+ function resolve(url) {
690
+ return guardAsync("resolve", null, async () => {
691
+ if (!cfg)
692
+ throw mkErr("not_configured", "Call DL2.configure() before resolve()");
693
+ const p = parseUrl(url);
694
+ if (!p)
695
+ throw mkErr("link_not_found", "resolve() needs a full URL such as https://myapp.dl2.app/abc");
696
+ const source = sourceOf(p);
697
+ const payload = await call("/v1/sdk/resolve", { url, source }, RESOLVE_TIMEOUT_MS);
698
+ lastResolveResponse = payload;
699
+ return toEvent(payload, url, source, false);
700
+ });
701
+ }
702
+ function track(name, options) {
703
+ guard("track", undefined, () => {
704
+ if (!cfg)
705
+ throw mkErr("not_configured", "Call DL2.configure() before track()");
706
+ if (typeof name !== "string" || !name.trim())
707
+ throw mkErr("unknown", "track() needs an event name");
708
+ const o = options || {};
709
+ const e = { name: name.trim() };
710
+ if (typeof o.value === "number" && isFinite(o.value))
711
+ e.value = o.value;
712
+ if (typeof o.currency === "string" && o.currency)
713
+ e.currency = o.currency.toUpperCase();
714
+ if (o.props && typeof o.props === "object")
715
+ e.props = JSON.parse(JSON.stringify(o.props));
716
+ queue.push(e);
717
+ if (queue.length > MAX_QUEUED_EVENTS)
718
+ queue.shift(); // bounded: the oldest event goes first
719
+ persistQueue();
720
+ flush().catch(() => undefined);
721
+ });
722
+ }
723
+ function createLink(input) {
724
+ return guardAsync("createLink", null, async () => {
725
+ if (!cfg)
726
+ throw mkErr("not_configured", "Call DL2.configure() before createLink()");
727
+ if (!input || typeof input.path !== "string")
728
+ throw mkErr("unknown", "createLink() needs a path");
729
+ const r = await call("/v1/sdk/links", input, EVENT_TIMEOUT_MS);
730
+ if (!r || typeof r.url !== "string")
731
+ throw mkErr("unknown", "The server did not return a link URL");
732
+ return { url: r.url };
733
+ });
734
+ }
735
+ /**
736
+ * iOS clipboard matching. Call with text the user pasted through the system paste button
737
+ * (`DL2PasteButton` does this for you). The SDK never reads the clipboard by itself.
738
+ */
739
+ function matchClipboard(text) {
740
+ return guardAsync("matchClipboard", null, async () => {
741
+ if (!cfg)
742
+ throw mkErr("not_configured", "Call DL2.configure() before matchClipboard()");
743
+ if (!cfg.clipboard) {
744
+ log("matchClipboard ignored: deferred.useClipboard is off");
745
+ return null;
746
+ }
747
+ if (ready)
748
+ await ready;
749
+ if (initial)
750
+ await initial;
751
+ const token = extractClipboardToken(text);
752
+ if (!token || !firstLaunch || matched)
753
+ return null;
754
+ const r = await call("/v1/sdk/open", await openBody({ clipboard_token: token }), cfg.timeoutMs);
755
+ lastOpenResponse = r;
756
+ if (!r || !r.match)
757
+ return null;
758
+ matched = true;
759
+ const event = toEvent(r.match, null, "deferred", true);
760
+ emit(event);
761
+ return event;
762
+ });
763
+ }
764
+ /** Development and tests: forget this install. Call configure() again afterwards. */
765
+ function reset() {
766
+ return guardAsync("reset", undefined, async () => {
767
+ gen++;
768
+ try {
769
+ if (sub)
770
+ sub.remove();
771
+ }
772
+ catch {
773
+ /* ignore */
774
+ }
775
+ if (retryTimer)
776
+ clearTimeout(retryTimer);
777
+ sub = null;
778
+ retryTimer = null;
779
+ cfg = null;
780
+ ready = null;
781
+ initial = null;
782
+ installId = null;
783
+ firstLaunch = false;
784
+ storageOk = true;
785
+ matched = false;
786
+ info = null;
787
+ pending = [];
788
+ queue = [];
789
+ retries = 0;
790
+ lastUrl = "";
791
+ lastHandled = null;
792
+ lastOpenResponse = null;
793
+ lastResolveResponse = null;
794
+ for (const k of [K_ID, K_DONE, K_ATTEMPTS, K_QUEUE, K_REFERRER])
795
+ await removeItem(k);
796
+ });
797
+ }
798
+ function getInstallId() {
799
+ return guardAsync("getInstallId", null, async () => {
800
+ if (ready)
801
+ await ready;
802
+ return installId;
803
+ });
804
+ }
805
+ function getDebugInfo() {
806
+ return {
807
+ sdkVersion: exports.SDK_VERSION,
808
+ configured: !!cfg,
809
+ nativeModule: !!deps.native,
810
+ installId,
811
+ isFirstLaunch: firstLaunch,
812
+ queuedEvents: queue.length,
813
+ lastOpenResponse,
814
+ lastResolveResponse,
815
+ lastError,
816
+ };
817
+ }
818
+ return {
819
+ configure,
820
+ onLink,
821
+ getInitialLink,
822
+ resolve,
823
+ track,
824
+ /** Sends queued events now. Resolves when the attempt is over; never rejects. */
825
+ flush: () => guardAsync("flush", undefined, () => flush()),
826
+ createLink,
827
+ matchClipboard,
828
+ reset,
829
+ getInstallId,
830
+ getDebugInfo,
831
+ version: exports.SDK_VERSION,
832
+ };
833
+ }