@loomup/astro 0.1.9 → 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.
package/README.md CHANGED
@@ -52,6 +52,25 @@ Package exports:
52
52
  - `@loomup/astro/middleware` — authentication middleware.
53
53
  - `@loomup/astro/auth` — lower-level cookie authentication helpers.
54
54
 
55
+ ## Coordinated browser sessions
56
+
57
+ `createAuthenticatedProject()` coordinates cookie refresh within a tab and
58
+ across same-origin tabs. Custom browser integrations can use the same primitive:
59
+
60
+ ```ts
61
+ import { createBrowserSessionCoordinator } from "@loomup/astro/client";
62
+
63
+ const session = createBrowserSessionCoordinator({
64
+ lockName: "/api/loomup",
65
+ loadSession: () => fetch("/api/loomup/session").then((response) => response.json()),
66
+ });
67
+ ```
68
+
69
+ For apps where every browser request uses that coordinator, configure the auth
70
+ handler with `dataProxyRefresh: "client-coordinated"`. This keeps single-use
71
+ refresh rotation on the session endpoint instead of racing parallel data proxy
72
+ requests. The compatibility default remains `"server"`.
73
+
55
74
  See the [Astro SDK guide](https://tryloomup.com/docs) for middleware,
56
75
  authenticated islands, object storage, and deployment guidance.
57
76
 
package/dist/auth.d.ts CHANGED
@@ -10,6 +10,12 @@ export type LoomupAuthHandlerOptions = CreateServerClientOptions & {
10
10
  param?: string;
11
11
  /** Exact application callback URL allowlisted in `$auth.redirect_urls`. */
12
12
  oauthCallbackUrl?: string;
13
+ /**
14
+ * Who rotates an absent access cookie for data requests. The compatibility
15
+ * default is `server`; browser-coordinated applications should use
16
+ * `client-coordinated` so only the session endpoint rotates refresh tokens.
17
+ */
18
+ dataProxyRefresh?: "server" | "client-coordinated";
13
19
  };
14
20
  /**
15
21
  * Create one Astro catch-all endpoint for login, logout, session hydration,
package/dist/auth.js CHANGED
@@ -169,7 +169,7 @@ async function proxyToLoomup(context, options, baseUrl, action) {
169
169
  if (method !== "GET" && method !== "HEAD")
170
170
  assertSameOrigin(context.request);
171
171
  let tokens = readTokens(context.cookies, options.cookies?.names);
172
- if (!tokens.access) {
172
+ if (!tokens.access && options.dataProxyRefresh !== "client-coordinated") {
173
173
  if (!tokens.refresh) {
174
174
  throw new LoomupError("authentication required", "unauthorized", 401);
175
175
  }
package/dist/client.d.ts CHANGED
@@ -14,6 +14,30 @@ export type CreateBrowserClientOptions = {
14
14
  refreshToken?: string;
15
15
  WebSocketImpl?: CreateClientOptions["WebSocketImpl"];
16
16
  };
17
+ export type BrowserSessionCoordinatorOptions<TSession> = {
18
+ /** Load the current same-origin session. This may rotate an expired session. */
19
+ loadSession: () => Promise<TSession>;
20
+ /** Explicitly rotate a still-valid session that is close to expiry. */
21
+ refreshSession?: () => Promise<TSession>;
22
+ /** Return the access JWT when the session exposes it. */
23
+ accessToken?: (session: TSession) => string | undefined;
24
+ /** Refresh this long before the JWT expiry. Default: 60 seconds. */
25
+ expirySkewMs?: number;
26
+ /** Same-origin lock shared by every tab using this session. */
27
+ lockName?: string;
28
+ };
29
+ export type BrowserSessionCoordinator<TSession> = {
30
+ getSession(): Promise<TSession>;
31
+ ensureFresh(): Promise<TSession>;
32
+ invalidate(): void;
33
+ };
34
+ /**
35
+ * Coordinate cookie-session refreshes within one tab and across same-origin
36
+ * tabs. The operation always reloads the session after acquiring the lock, so
37
+ * a waiter adopts cookies rotated by the winner instead of replaying a
38
+ * single-use refresh token.
39
+ */
40
+ export declare function createBrowserSessionCoordinator<TSession>(options: BrowserSessionCoordinatorOptions<TSession>): BrowserSessionCoordinator<TSession>;
17
41
  /**
18
42
  * Create a browser Loomup client for islands.
19
43
  *
package/dist/client.js CHANGED
@@ -26,6 +26,237 @@ function resolveBrowserUrl(explicit) {
26
26
  return fromEnv;
27
27
  throw new Error("@loomup/astro: createBrowserClient requires `url` or PUBLIC_LOOMUP_URL (set via the loomup() integration)");
28
28
  }
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;
33
+ function delay(milliseconds) {
34
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
35
+ }
36
+ function jwtExpiry(token) {
37
+ if (!token)
38
+ return undefined;
39
+ try {
40
+ const encoded = token.split(".")[1];
41
+ if (!encoded)
42
+ return undefined;
43
+ const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
44
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
45
+ const decode = globalThis.atob;
46
+ if (!decode)
47
+ return undefined;
48
+ const payload = JSON.parse(decode(padded));
49
+ return typeof payload.exp === "number" ? payload.exp * 1_000 : undefined;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ function browserLockManager() {
56
+ try {
57
+ return globalThis.navigator?.locks;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ function browserIndexedDB() {
64
+ try {
65
+ return globalThis.indexedDB;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ function leaseOwner() {
72
+ const cryptoApi = globalThis.crypto;
73
+ if (typeof cryptoApi?.randomUUID === "function")
74
+ return cryptoApi.randomUUID();
75
+ coordinatorSequence += 1;
76
+ return `${Date.now()}-${coordinatorSequence}`;
77
+ }
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;
152
+ try {
153
+ database = await openLeaseDatabase(factory);
154
+ }
155
+ catch {
156
+ throw new LoomupError("browser storage cannot coordinate session refresh", "auth_lock_unavailable", 503);
157
+ }
158
+ const key = `@loomup/astro:auth-lock:${name}`;
159
+ const owner = leaseOwner();
160
+ const deadline = Date.now() + 30_000;
161
+ const channel = typeof BroadcastChannel === "function"
162
+ ? new BroadcastChannel(`@loomup/astro:auth:${name}`)
163
+ : undefined;
164
+ try {
165
+ while (Date.now() < deadline) {
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);
175
+ try {
176
+ return await operation();
177
+ }
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");
185
+ }
186
+ }
187
+ await delay(50);
188
+ }
189
+ throw new LoomupError("timed out waiting for session refresh", "auth_lock_timeout", 503);
190
+ }
191
+ finally {
192
+ channel?.close();
193
+ database.close();
194
+ }
195
+ }
196
+ async function withBrowserSessionLock(name, operation) {
197
+ const locks = browserLockManager();
198
+ if (locks) {
199
+ return locks.request(`@loomup/astro:auth:${name}`, { mode: "exclusive" }, operation);
200
+ }
201
+ return withLease(name, operation);
202
+ }
203
+ function isTerminalSessionError(error) {
204
+ return error instanceof LoomupError && error.status === 401;
205
+ }
206
+ /**
207
+ * Coordinate cookie-session refreshes within one tab and across same-origin
208
+ * tabs. The operation always reloads the session after acquiring the lock, so
209
+ * a waiter adopts cookies rotated by the winner instead of replaying a
210
+ * single-use refresh token.
211
+ */
212
+ export function createBrowserSessionCoordinator(options) {
213
+ const lockName = options.lockName ?? "default";
214
+ const expirySkewMs = Math.max(0, options.expirySkewMs ?? 60_000);
215
+ let cached;
216
+ let inFlight = null;
217
+ const execute = (checkExpiry) => {
218
+ if (inFlight)
219
+ return inFlight;
220
+ const attempt = async () => withBrowserSessionLock(lockName, async () => {
221
+ const loaded = await options.loadSession();
222
+ if (!checkExpiry || !options.refreshSession || !options.accessToken)
223
+ return loaded;
224
+ const expiresAt = jwtExpiry(options.accessToken(loaded));
225
+ return expiresAt !== undefined && expiresAt - Date.now() <= expirySkewMs
226
+ ? options.refreshSession()
227
+ : loaded;
228
+ });
229
+ inFlight = (async () => {
230
+ try {
231
+ cached = await attempt();
232
+ }
233
+ catch (error) {
234
+ if (!isTerminalSessionError(error))
235
+ throw error;
236
+ // A tab that could not participate in the primary lock may have lost a
237
+ // rotation race. Reacquire and observe the cookie jar once before the
238
+ // caller treats the session as terminal.
239
+ await delay(100);
240
+ cached = await attempt();
241
+ }
242
+ return cached;
243
+ })().finally(() => {
244
+ inFlight = null;
245
+ });
246
+ return inFlight;
247
+ };
248
+ return {
249
+ getSession() {
250
+ return cached === undefined ? execute(false) : Promise.resolve(cached);
251
+ },
252
+ ensureFresh() {
253
+ return execute(true);
254
+ },
255
+ invalidate() {
256
+ cached = undefined;
257
+ },
258
+ };
259
+ }
29
260
  /**
30
261
  * Create a browser Loomup client for islands.
31
262
  *
@@ -71,14 +302,18 @@ async function authRequest(fetchImpl, endpoint, action, init) {
71
302
  export async function createAuthenticatedProject(options = {}) {
72
303
  const fetchImpl = options.fetch ?? globalThis.fetch;
73
304
  const endpoint = options.authEndpoint ?? "/api/loomup";
74
- const session = await authRequest(fetchImpl, endpoint, "session", { method: "GET" });
305
+ const coordinator = createBrowserSessionCoordinator({
306
+ lockName: endpoint,
307
+ loadSession: () => authRequest(fetchImpl, endpoint, "session", { method: "GET" }),
308
+ });
309
+ const session = await coordinator.getSession();
75
310
  if (!session.user) {
76
311
  throw new LoomupError("authenticated session required", "unauthorized", 401);
77
312
  }
78
313
  const db = createProject({
79
314
  url: options.dataEndpoint ?? `${endpoint.replace(/\/$/, "")}/data`,
80
315
  accessTokenProvider: async () => {
81
- await authRequest(fetchImpl, endpoint, "refresh", { method: "POST" });
316
+ await coordinator.ensureFresh();
82
317
  // The core client requires a truthy retry signal. This marker is sent
83
318
  // only to the same-origin gateway, which replaces Authorization with
84
319
  // the server-held access token.
@@ -90,6 +325,7 @@ export async function createAuthenticatedProject(options = {}) {
90
325
  user: session.user,
91
326
  async signOut() {
92
327
  await authRequest(fetchImpl, endpoint, "logout", { method: "POST" });
328
+ coordinator.invalidate();
93
329
  db.setToken(undefined);
94
330
  },
95
331
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomup/astro",
3
- "version": "0.1.9",
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"