@loomup/astro 0.1.10 → 0.1.11

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/client.js +99 -40
  2. package/package.json +4 -3
package/dist/client.js CHANGED
@@ -27,6 +27,9 @@ function resolveBrowserUrl(explicit) {
27
27
  throw new Error("@loomup/astro: createBrowserClient requires `url` or PUBLIC_LOOMUP_URL (set via the loomup() integration)");
28
28
  }
29
29
  let coordinatorSequence = 0;
30
+ const SESSION_LOCK_DATABASE = "@loomup/astro:auth-locks";
31
+ const SESSION_LOCK_STORE = "leases";
32
+ const SESSION_LOCK_TTL_MS = 120_000;
30
33
  function delay(milliseconds) {
31
34
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
32
35
  }
@@ -57,9 +60,9 @@ function browserLockManager() {
57
60
  return undefined;
58
61
  }
59
62
  }
60
- function browserStorage() {
63
+ function browserIndexedDB() {
61
64
  try {
62
- return globalThis.localStorage;
65
+ return globalThis.indexedDB;
63
66
  }
64
67
  catch {
65
68
  return undefined;
@@ -72,24 +75,86 @@ function leaseOwner() {
72
75
  coordinatorSequence += 1;
73
76
  return `${Date.now()}-${coordinatorSequence}`;
74
77
  }
75
- function readLease(storage, key) {
78
+ function storedLease(value) {
79
+ if (!value || typeof value !== "object")
80
+ return null;
81
+ const lease = value;
82
+ if (typeof lease.owner !== "string" || typeof lease.expiresAt !== "number")
83
+ return null;
84
+ return { owner: lease.owner, expiresAt: lease.expiresAt };
85
+ }
86
+ function openLeaseDatabase(factory) {
87
+ return new Promise((resolve, reject) => {
88
+ const request = factory.open(SESSION_LOCK_DATABASE, 1);
89
+ request.onupgradeneeded = () => {
90
+ if (!request.result.objectStoreNames.contains(SESSION_LOCK_STORE)) {
91
+ request.result.createObjectStore(SESSION_LOCK_STORE);
92
+ }
93
+ };
94
+ request.onsuccess = () => resolve(request.result);
95
+ request.onerror = () => reject(request.error ?? new Error("could not open session lock database"));
96
+ request.onblocked = () => reject(new Error("session lock database is blocked"));
97
+ });
98
+ }
99
+ function mutateLease(database, key, decide) {
100
+ return new Promise((resolve, reject) => {
101
+ const transaction = database.transaction(SESSION_LOCK_STORE, "readwrite");
102
+ const store = transaction.objectStore(SESSION_LOCK_STORE);
103
+ const request = store.get(key);
104
+ let result = false;
105
+ request.onsuccess = () => {
106
+ const mutation = decide(storedLease(request.result));
107
+ result = mutation.result;
108
+ if (mutation.write === "put" && mutation.lease)
109
+ store.put(mutation.lease, key);
110
+ else if (mutation.write === "delete")
111
+ store.delete(key);
112
+ };
113
+ transaction.oncomplete = () => resolve(result);
114
+ transaction.onerror = () => reject(transaction.error ?? new Error("session lock transaction failed"));
115
+ transaction.onabort = () => reject(transaction.error ?? new Error("session lock transaction aborted"));
116
+ });
117
+ }
118
+ function acquireLease(database, key, owner) {
119
+ return mutateLease(database, key, (current) => {
120
+ if (current && current.expiresAt > Date.now()) {
121
+ return { result: false, write: "none" };
122
+ }
123
+ return {
124
+ result: true,
125
+ write: "put",
126
+ lease: { owner, expiresAt: Date.now() + SESSION_LOCK_TTL_MS },
127
+ };
128
+ });
129
+ }
130
+ function renewLease(database, key, owner) {
131
+ return mutateLease(database, key, (current) => current?.owner === owner
132
+ ? {
133
+ result: true,
134
+ write: "put",
135
+ lease: { owner, expiresAt: Date.now() + SESSION_LOCK_TTL_MS },
136
+ }
137
+ : { result: false, write: "none" });
138
+ }
139
+ function releaseLease(database, key, owner) {
140
+ return mutateLease(database, key, (current) => current?.owner === owner
141
+ ? { result: true, write: "delete" }
142
+ : { result: false, write: "none" });
143
+ }
144
+ async function withLease(name, operation) {
145
+ const factory = browserIndexedDB();
146
+ if (!factory) {
147
+ if (typeof window === "undefined")
148
+ return operation();
149
+ throw new LoomupError("browser storage cannot coordinate session refresh", "auth_lock_unavailable", 503);
150
+ }
151
+ let database;
76
152
  try {
77
- const raw = storage.getItem(key);
78
- if (!raw)
79
- return null;
80
- const value = JSON.parse(raw);
81
- if (typeof value.owner !== "string" || typeof value.expiresAt !== "number")
82
- return null;
83
- return { owner: value.owner, expiresAt: value.expiresAt };
153
+ database = await openLeaseDatabase(factory);
84
154
  }
85
155
  catch {
86
- return null;
156
+ throw new LoomupError("browser storage cannot coordinate session refresh", "auth_lock_unavailable", 503);
87
157
  }
88
- }
89
- async function withLease(name, operation) {
90
- const storage = browserStorage();
91
- if (!storage)
92
- return operation();
93
158
  const key = `@loomup/astro:auth-lock:${name}`;
94
159
  const owner = leaseOwner();
95
160
  const deadline = Date.now() + 30_000;
@@ -98,32 +163,25 @@ async function withLease(name, operation) {
98
163
  : undefined;
99
164
  try {
100
165
  while (Date.now() < deadline) {
101
- const current = readLease(storage, key);
102
- if (!current || current.expiresAt <= Date.now()) {
166
+ if (await acquireLease(database, key, owner)) {
167
+ let heartbeatInFlight = null;
168
+ const heartbeat = setInterval(() => {
169
+ if (heartbeatInFlight)
170
+ return;
171
+ heartbeatInFlight = renewLease(database, key, owner)
172
+ .catch(() => false)
173
+ .finally(() => { heartbeatInFlight = null; });
174
+ }, 5_000);
103
175
  try {
104
- storage.setItem(key, JSON.stringify({ owner, expiresAt: Date.now() + 15_000 }));
105
- }
106
- catch {
107
- return operation();
176
+ return await operation();
108
177
  }
109
- // localStorage has no compare-and-swap. A short settle and owner check
110
- // ensures only the last contender enters the critical section.
111
- await delay(20);
112
- if (readLease(storage, key)?.owner === owner) {
113
- const heartbeat = setInterval(() => {
114
- if (readLease(storage, key)?.owner === owner) {
115
- storage.setItem(key, JSON.stringify({ owner, expiresAt: Date.now() + 15_000 }));
116
- }
117
- }, 5_000);
118
- try {
119
- return await operation();
120
- }
121
- finally {
122
- clearInterval(heartbeat);
123
- if (readLease(storage, key)?.owner === owner)
124
- storage.removeItem(key);
125
- channel?.postMessage("released");
126
- }
178
+ finally {
179
+ clearInterval(heartbeat);
180
+ const pendingHeartbeat = heartbeatInFlight;
181
+ if (pendingHeartbeat)
182
+ await pendingHeartbeat;
183
+ await releaseLease(database, key, owner).catch(() => false);
184
+ channel?.postMessage("released");
127
185
  }
128
186
  }
129
187
  await delay(50);
@@ -132,6 +190,7 @@ async function withLease(name, operation) {
132
190
  }
133
191
  finally {
134
192
  channel?.close();
193
+ database.close();
135
194
  }
136
195
  }
137
196
  async function withBrowserSessionLock(name, operation) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomup/astro",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Astro integration and SSR helpers for Loomup Realtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,8 +46,9 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "typescript": "^5.7.0",
50
- "@types/node": "^22.0.0"
49
+ "@types/node": "^22.0.0",
50
+ "fake-indexeddb": "^6.2.5",
51
+ "typescript": "^5.7.0"
51
52
  },
52
53
  "engines": {
53
54
  "node": ">=18"