@spectrum-ts/imessage 6.0.0 → 6.1.1

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.
Files changed (2) hide show
  1. package/dist/index.js +68 -34
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -219,6 +219,7 @@ const log$3 = createLogger("spectrum.imessage.auth");
219
219
  const RENEWAL_RATIO = .8;
220
220
  const EXPIRY_BUFFER_MS = 3e4;
221
221
  const RETRY_DELAY_MS = 3e4;
222
+ const FORCE_REFRESH_MIN_INTERVAL_MS = 5e3;
222
223
  const cloudAuthState = /* @__PURE__ */ new WeakMap();
223
224
  const requirePhone = (data, instanceId) => {
224
225
  const phone = data.numbers?.[instanceId];
@@ -231,6 +232,8 @@ async function createCloudClients(projectId, projectSecret) {
231
232
  let disposed = false;
232
233
  let renewalTimer;
233
234
  let refreshFailures = 0;
235
+ let refreshInFlight;
236
+ let lastRefreshAt = Date.now();
234
237
  const records = [];
235
238
  const syncPhones = (data) => {
236
239
  for (const { entry, instanceId } of records) entry.phone = requirePhone(data, instanceId);
@@ -249,39 +252,66 @@ async function createCloudClients(projectId, projectSecret) {
249
252
  ...errorAttrs(error)
250
253
  }, error);
251
254
  };
255
+ const refreshNow = async () => {
256
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
257
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
258
+ lastRefreshAt = Date.now();
259
+ if (tokenData.type === "dedicated") syncPhones(tokenData);
260
+ onRefreshSuccess();
261
+ scheduleRenewal();
262
+ };
263
+ const coalescedRefresh = () => {
264
+ if (!refreshInFlight) refreshInFlight = refreshNow().finally(() => {
265
+ refreshInFlight = void 0;
266
+ });
267
+ return refreshInFlight;
268
+ };
252
269
  const scheduleRenewal = () => {
253
270
  if (disposed) return;
271
+ if (renewalTimer !== void 0) {
272
+ clearTimeout(renewalTimer);
273
+ renewalTimer = void 0;
274
+ }
254
275
  const ttlMs = tokenData.expiresIn * 1e3;
255
276
  const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
256
- renewalTimer = setTimeout(async () => {
257
- try {
258
- tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
259
- tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
260
- if (tokenData.type === "dedicated") syncPhones(tokenData);
261
- onRefreshSuccess();
262
- scheduleRenewal();
263
- } catch (error) {
277
+ const runScheduledRefresh = () => {
278
+ coalescedRefresh().catch((error) => {
264
279
  onRefreshFailure(error);
265
- renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
280
+ if (disposed) return;
281
+ renewalTimer = setTimeout(runScheduledRefresh, RETRY_DELAY_MS);
266
282
  renewalTimer?.unref?.();
267
- }
268
- }, renewInMs);
283
+ });
284
+ };
285
+ renewalTimer = setTimeout(runScheduledRefresh, renewInMs);
269
286
  renewalTimer?.unref?.();
270
287
  };
271
288
  scheduleRenewal();
272
289
  const refreshIfNeeded = async () => {
273
290
  if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
274
- tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
275
- tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
276
- if (tokenData.type === "dedicated") syncPhones(tokenData);
277
- onRefreshSuccess();
278
- scheduleRenewal();
291
+ await coalescedRefresh();
292
+ };
293
+ const forceRefresh = async () => {
294
+ if (Date.now() - lastRefreshAt < FORCE_REFRESH_MIN_INTERVAL_MS) return;
295
+ await coalescedRefresh();
296
+ };
297
+ const dispose = () => {
298
+ disposed = true;
299
+ if (renewalTimer !== void 0) {
300
+ clearTimeout(renewalTimer);
301
+ renewalTimer = void 0;
302
+ }
303
+ };
304
+ const cloudAuth = {
305
+ dispose,
306
+ forceRefresh
279
307
  };
280
308
  if (tokenData.type === "shared") {
281
309
  const entries = [{
282
310
  phone: SHARED_PHONE,
283
311
  client: createClient({
284
312
  address: process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "imessage.spectrum.photon.codes:443",
313
+ autoIdempotency: true,
314
+ retry: true,
285
315
  tls: true,
286
316
  token: async () => {
287
317
  await refreshIfNeeded();
@@ -289,13 +319,7 @@ async function createCloudClients(projectId, projectSecret) {
289
319
  }
290
320
  })
291
321
  }];
292
- cloudAuthState.set(entries, { dispose: () => {
293
- disposed = true;
294
- if (renewalTimer !== void 0) {
295
- clearTimeout(renewalTimer);
296
- renewalTimer = void 0;
297
- }
298
- } });
322
+ cloudAuthState.set(entries, cloudAuth);
299
323
  return entries;
300
324
  }
301
325
  const dedicated = tokenData;
@@ -304,6 +328,8 @@ async function createCloudClients(projectId, projectSecret) {
304
328
  phone: requirePhone(dedicated, instanceId),
305
329
  client: createClient({
306
330
  address: `${instanceId}.imsg.photon.codes:443`,
331
+ autoIdempotency: true,
332
+ retry: true,
307
333
  tls: true,
308
334
  token: async () => {
309
335
  await refreshIfNeeded();
@@ -317,13 +343,7 @@ async function createCloudClients(projectId, projectSecret) {
317
343
  });
318
344
  }
319
345
  const entries = records.map((r) => r.entry);
320
- cloudAuthState.set(entries, { dispose: () => {
321
- disposed = true;
322
- if (renewalTimer !== void 0) {
323
- clearTimeout(renewalTimer);
324
- renewalTimer = void 0;
325
- }
326
- } });
346
+ cloudAuthState.set(entries, cloudAuth);
327
347
  return entries;
328
348
  }
329
349
  async function disposeCloudAuth(clients) {
@@ -333,6 +353,15 @@ async function disposeCloudAuth(clients) {
333
353
  cloudAuthState.delete(clients);
334
354
  }
335
355
  }
356
+ /**
357
+ * The recover hook for a cloud-backed client array: forces a token re-mint so a
358
+ * persistently-failing stream (server rejecting an unexpired token after a
359
+ * restart) gets a fresh bearer on its next reconnect. Returns undefined for
360
+ * explicitly-configured (static-token) clients, which have nothing to re-mint.
361
+ */
362
+ function getCloudRecover(clients) {
363
+ return cloudAuthState.get(clients)?.forceRefresh;
364
+ }
336
365
  //#endregion
337
366
  //#region src/cache.ts
338
367
  const DEFAULT_MAX = 1e3;
@@ -1776,29 +1805,32 @@ async function* afterCursor(stream, cursor) {
1776
1805
  const withClose = (source, cursor) => Object.assign(afterCursor(source, cursor), { close: async () => {
1777
1806
  await source.close?.();
1778
1807
  } });
1779
- const messageStream = (client, phone, onInbound) => resumableOrderedStream({
1808
+ const messageStream = (client, phone, onInbound, recover) => resumableOrderedStream({
1780
1809
  fetchMissed: (cursor) => catchUpEvents(client, cursor, isMessageEvent),
1781
1810
  isCursorRejectedError: isCursorRejectedIMessageError,
1782
1811
  label: streamLabel("messages", phone),
1812
+ recover,
1783
1813
  processLive: (event) => toMessageItem(client, event, phone, String(event.sequence), onInbound),
1784
1814
  processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toMessageItem(client, event, phone, String(event.sequence), onInbound),
1785
1815
  subscribeLive: (cursor) => withClose(client.messages.subscribeEvents(), cursor)
1786
1816
  });
1787
- const pollStream = (client, pollCache, phone) => resumableOrderedStream({
1817
+ const pollStream = (client, pollCache, phone, recover) => resumableOrderedStream({
1788
1818
  fetchMissed: (cursor) => catchUpEvents(client, cursor, isPollEvent),
1789
1819
  isCursorRejectedError: isCursorRejectedIMessageError,
1790
1820
  label: streamLabel("polls", phone),
1821
+ recover,
1791
1822
  processLive: (event) => toPollItem(client, pollCache, event, phone, String(event.sequence)),
1792
1823
  processMissed: (event) => event.type === "catchup.complete" ? Promise.resolve(toCatchUpCompleteItem(event)) : toPollItem(client, pollCache, event, phone, String(event.sequence)),
1793
1824
  subscribeLive: (cursor) => withClose(client.polls.subscribeEvents(), cursor)
1794
1825
  });
1795
- const clientStream = (client, pollCache, phone, onInbound) => mergeStreams([messageStream(client, phone, onInbound), pollStream(client, pollCache, phone)]);
1826
+ const clientStream = (client, pollCache, phone, onInbound, recover) => mergeStreams([messageStream(client, phone, onInbound, recover), pollStream(client, pollCache, phone, recover)]);
1796
1827
  const messages$1 = (clients, projectConfig) => {
1797
1828
  const pollCache = getPollCache(clients);
1798
1829
  const shareEnabled = projectConfig?.profile?.imessageSynced === true;
1830
+ const recover = getCloudRecover(clients);
1799
1831
  return mergeStreams(clients.map((entry) => {
1800
1832
  const tracker = shareEnabled ? getContactShareTracker(entry.client) : void 0;
1801
- return clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(chatGuid) : void 0);
1833
+ return clientStream(entry.client, pollCache, entry.phone, tracker ? (chatGuid) => tracker.maybeShare(chatGuid) : void 0, recover);
1802
1834
  }));
1803
1835
  };
1804
1836
  //#endregion
@@ -2056,6 +2088,8 @@ const imessage = definePlatform("iMessage", {
2056
2088
  phone: e.phone,
2057
2089
  client: createClient({
2058
2090
  address: e.address,
2091
+ autoIdempotency: true,
2092
+ retry: true,
2059
2093
  tls: true,
2060
2094
  token: e.token
2061
2095
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectrum-ts/imessage",
3
- "version": "6.0.0",
3
+ "version": "6.1.1",
4
4
  "description": "iMessage provider for spectrum-ts — local and remote (advanced) modes.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "label": "iMessage"
32
32
  },
33
33
  "dependencies": {
34
- "@photon-ai/advanced-imessage": "^0.11.0",
34
+ "@photon-ai/advanced-imessage": "^0.12.0",
35
35
  "@photon-ai/imessage-kit": "^3.0.0",
36
36
  "@photon-ai/otel": "^1.0.0",
37
37
  "lru-cache": "^11.0.0",