@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 +19 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +1 -1
- package/dist/client.d.ts +24 -0
- package/dist/client.js +238 -2
- package/package.json +4 -3
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
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
50
|
-
"
|
|
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"
|