@loomup/astro 0.1.8 → 0.1.10

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,178 @@ 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
+ function delay(milliseconds) {
31
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
32
+ }
33
+ function jwtExpiry(token) {
34
+ if (!token)
35
+ return undefined;
36
+ try {
37
+ const encoded = token.split(".")[1];
38
+ if (!encoded)
39
+ return undefined;
40
+ const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
41
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
42
+ const decode = globalThis.atob;
43
+ if (!decode)
44
+ return undefined;
45
+ const payload = JSON.parse(decode(padded));
46
+ return typeof payload.exp === "number" ? payload.exp * 1_000 : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ function browserLockManager() {
53
+ try {
54
+ return globalThis.navigator?.locks;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ function browserStorage() {
61
+ try {
62
+ return globalThis.localStorage;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ function leaseOwner() {
69
+ const cryptoApi = globalThis.crypto;
70
+ if (typeof cryptoApi?.randomUUID === "function")
71
+ return cryptoApi.randomUUID();
72
+ coordinatorSequence += 1;
73
+ return `${Date.now()}-${coordinatorSequence}`;
74
+ }
75
+ function readLease(storage, key) {
76
+ 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 };
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ async function withLease(name, operation) {
90
+ const storage = browserStorage();
91
+ if (!storage)
92
+ return operation();
93
+ const key = `@loomup/astro:auth-lock:${name}`;
94
+ const owner = leaseOwner();
95
+ const deadline = Date.now() + 30_000;
96
+ const channel = typeof BroadcastChannel === "function"
97
+ ? new BroadcastChannel(`@loomup/astro:auth:${name}`)
98
+ : undefined;
99
+ try {
100
+ while (Date.now() < deadline) {
101
+ const current = readLease(storage, key);
102
+ if (!current || current.expiresAt <= Date.now()) {
103
+ try {
104
+ storage.setItem(key, JSON.stringify({ owner, expiresAt: Date.now() + 15_000 }));
105
+ }
106
+ catch {
107
+ return operation();
108
+ }
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
+ }
127
+ }
128
+ }
129
+ await delay(50);
130
+ }
131
+ throw new LoomupError("timed out waiting for session refresh", "auth_lock_timeout", 503);
132
+ }
133
+ finally {
134
+ channel?.close();
135
+ }
136
+ }
137
+ async function withBrowserSessionLock(name, operation) {
138
+ const locks = browserLockManager();
139
+ if (locks) {
140
+ return locks.request(`@loomup/astro:auth:${name}`, { mode: "exclusive" }, operation);
141
+ }
142
+ return withLease(name, operation);
143
+ }
144
+ function isTerminalSessionError(error) {
145
+ return error instanceof LoomupError && error.status === 401;
146
+ }
147
+ /**
148
+ * Coordinate cookie-session refreshes within one tab and across same-origin
149
+ * tabs. The operation always reloads the session after acquiring the lock, so
150
+ * a waiter adopts cookies rotated by the winner instead of replaying a
151
+ * single-use refresh token.
152
+ */
153
+ export function createBrowserSessionCoordinator(options) {
154
+ const lockName = options.lockName ?? "default";
155
+ const expirySkewMs = Math.max(0, options.expirySkewMs ?? 60_000);
156
+ let cached;
157
+ let inFlight = null;
158
+ const execute = (checkExpiry) => {
159
+ if (inFlight)
160
+ return inFlight;
161
+ const attempt = async () => withBrowserSessionLock(lockName, async () => {
162
+ const loaded = await options.loadSession();
163
+ if (!checkExpiry || !options.refreshSession || !options.accessToken)
164
+ return loaded;
165
+ const expiresAt = jwtExpiry(options.accessToken(loaded));
166
+ return expiresAt !== undefined && expiresAt - Date.now() <= expirySkewMs
167
+ ? options.refreshSession()
168
+ : loaded;
169
+ });
170
+ inFlight = (async () => {
171
+ try {
172
+ cached = await attempt();
173
+ }
174
+ catch (error) {
175
+ if (!isTerminalSessionError(error))
176
+ throw error;
177
+ // A tab that could not participate in the primary lock may have lost a
178
+ // rotation race. Reacquire and observe the cookie jar once before the
179
+ // caller treats the session as terminal.
180
+ await delay(100);
181
+ cached = await attempt();
182
+ }
183
+ return cached;
184
+ })().finally(() => {
185
+ inFlight = null;
186
+ });
187
+ return inFlight;
188
+ };
189
+ return {
190
+ getSession() {
191
+ return cached === undefined ? execute(false) : Promise.resolve(cached);
192
+ },
193
+ ensureFresh() {
194
+ return execute(true);
195
+ },
196
+ invalidate() {
197
+ cached = undefined;
198
+ },
199
+ };
200
+ }
29
201
  /**
30
202
  * Create a browser Loomup client for islands.
31
203
  *
@@ -71,14 +243,18 @@ async function authRequest(fetchImpl, endpoint, action, init) {
71
243
  export async function createAuthenticatedProject(options = {}) {
72
244
  const fetchImpl = options.fetch ?? globalThis.fetch;
73
245
  const endpoint = options.authEndpoint ?? "/api/loomup";
74
- const session = await authRequest(fetchImpl, endpoint, "session", { method: "GET" });
246
+ const coordinator = createBrowserSessionCoordinator({
247
+ lockName: endpoint,
248
+ loadSession: () => authRequest(fetchImpl, endpoint, "session", { method: "GET" }),
249
+ });
250
+ const session = await coordinator.getSession();
75
251
  if (!session.user) {
76
252
  throw new LoomupError("authenticated session required", "unauthorized", 401);
77
253
  }
78
254
  const db = createProject({
79
255
  url: options.dataEndpoint ?? `${endpoint.replace(/\/$/, "")}/data`,
80
256
  accessTokenProvider: async () => {
81
- await authRequest(fetchImpl, endpoint, "refresh", { method: "POST" });
257
+ await coordinator.ensureFresh();
82
258
  // The core client requires a truthy retry signal. This marker is sent
83
259
  // only to the same-origin gateway, which replaces Authorization with
84
260
  // the server-held access token.
@@ -90,6 +266,7 @@ export async function createAuthenticatedProject(options = {}) {
90
266
  user: session.user,
91
267
  async signOut() {
92
268
  await authRequest(fetchImpl, endpoint, "logout", { method: "POST" });
269
+ coordinator.invalidate();
93
270
  db.setToken(undefined);
94
271
  },
95
272
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomup/astro",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Astro integration and SSR helpers for Loomup Realtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",