@lumibase/sdk 0.20.0 → 0.22.0

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/dist/index.cjs CHANGED
@@ -111,12 +111,52 @@ function toErrorBody(status, body) {
111
111
  function createLumiClient(opts) {
112
112
  const fetcher = opts.fetcher ?? globalThis.fetch;
113
113
  const base = opts.url.replace(/\/$/, "");
114
- async function rawRequest(path, init = {}) {
114
+ let currentToken = opts.token;
115
+ let currentRefreshToken = opts.refreshToken;
116
+ let refreshInFlight = null;
117
+ async function attemptRefresh() {
118
+ if (!currentRefreshToken) return false;
119
+ if (!refreshInFlight) {
120
+ refreshInFlight = (async () => {
121
+ try {
122
+ const res = await fetcher(`${base}/api/v1/auth/refresh`, {
123
+ method: "POST",
124
+ headers: {
125
+ "content-type": "application/json",
126
+ "x-lumi-site": opts.siteId
127
+ },
128
+ // Body transport is CSRF-exempt (the token isn't ambient).
129
+ body: JSON.stringify({ refreshToken: currentRefreshToken })
130
+ });
131
+ if (!res.ok) return false;
132
+ const data = parseResponseBody(await res.text())?.data;
133
+ if (!data?.token || !data.refreshToken) return false;
134
+ currentToken = data.token;
135
+ currentRefreshToken = data.refreshToken;
136
+ try {
137
+ opts.onTokensRefreshed?.({
138
+ token: data.token,
139
+ refreshToken: data.refreshToken,
140
+ refreshTokenExpiresAt: data.refreshTokenExpiresAt
141
+ });
142
+ } catch {
143
+ }
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ } finally {
148
+ refreshInFlight = null;
149
+ }
150
+ })();
151
+ }
152
+ return refreshInFlight;
153
+ }
154
+ async function rawRequest(path, init = {}, retried = false) {
115
155
  const headers = new Headers(init.headers);
116
156
  for (const [key, value] of Object.entries(opts.headers ?? {})) {
117
157
  headers.set(key, value);
118
158
  }
119
- headers.set("authorization", `Bearer ${opts.token}`);
159
+ headers.set("authorization", `Bearer ${currentToken}`);
120
160
  headers.set("x-lumi-site", opts.siteId);
121
161
  if (!headers.has("content-type") && init.body) {
122
162
  headers.set("content-type", "application/json");
@@ -125,6 +165,11 @@ function createLumiClient(opts) {
125
165
  const text = await res.text();
126
166
  const body = parseResponseBody(text);
127
167
  if (!res.ok) {
168
+ if (res.status === 401 && !retried && currentRefreshToken && path !== "/api/v1/auth/refresh") {
169
+ if (await attemptRefresh()) {
170
+ return rawRequest(path, init, true);
171
+ }
172
+ }
128
173
  if (res.status === 401 && opts.onUnauthorized) {
129
174
  try {
130
175
  opts.onUnauthorized();
@@ -137,7 +182,9 @@ function createLumiClient(opts) {
137
182
  }
138
183
  const baseClient = {
139
184
  url: opts.url,
140
- token: opts.token,
185
+ get token() {
186
+ return currentToken;
187
+ },
141
188
  siteId: opts.siteId,
142
189
  fetcher,
143
190
  rawRequest,
@@ -323,6 +370,7 @@ var RealtimeClient = class {
323
370
  opts;
324
371
  ws = null;
325
372
  subscriptions = /* @__PURE__ */ new Map();
373
+ subscriptionFilters = /* @__PURE__ */ new Map();
326
374
  presenceListeners = /* @__PURE__ */ new Set();
327
375
  currentPresence = {};
328
376
  sessionId = null;
@@ -355,14 +403,20 @@ var RealtimeClient = class {
355
403
  * Subscribe to item mutation events for a specific collection.
356
404
  * Multiple handlers per collection are supported.
357
405
  *
406
+ * `opts.filter` is an optional Directus-style condition rule evaluated
407
+ * server-side over the event envelope (`collection`/`action`/`itemId`) —
408
+ * studio broadcasts are signal-only, so row data is never filterable. The
409
+ * filter is per-collection: the most recent subscribe call's filter wins.
410
+ *
358
411
  * @returns Unsubscribe function.
359
412
  */
360
- subscribe(collection, callback) {
413
+ subscribe(collection, callback, opts = {}) {
361
414
  if (!this.subscriptions.has(collection)) {
362
415
  this.subscriptions.set(collection, /* @__PURE__ */ new Set());
363
416
  }
364
417
  this.subscriptions.get(collection).add(callback);
365
- this._send({ type: "subscribe", collection });
418
+ if (opts.filter) this.subscriptionFilters.set(collection, opts.filter);
419
+ this._send({ type: "subscribe", collection, ...opts.filter ? { filter: opts.filter } : {} });
366
420
  return () => this.unsubscribe(collection, callback);
367
421
  }
368
422
  /** Remove a specific handler from a collection subscription. */
@@ -372,6 +426,7 @@ var RealtimeClient = class {
372
426
  set.delete(callback);
373
427
  if (set.size === 0) {
374
428
  this.subscriptions.delete(collection);
429
+ this.subscriptionFilters.delete(collection);
375
430
  this._send({ type: "unsubscribe", collection });
376
431
  }
377
432
  }
@@ -436,7 +491,8 @@ var RealtimeClient = class {
436
491
  ws.addEventListener("open", () => {
437
492
  this.backoffMs = this.opts.initialBackoffMs ?? 1e3;
438
493
  for (const collection of this.subscriptions.keys()) {
439
- this._sendRaw({ type: "subscribe", collection });
494
+ const filter = this.subscriptionFilters.get(collection);
495
+ this._sendRaw({ type: "subscribe", collection, ...filter ? { filter } : {} });
440
496
  }
441
497
  if (Object.keys(this.currentPresence).length > 0) {
442
498
  this._sendRaw({ type: "presence", ...this.currentPresence });
package/dist/index.d.cts CHANGED
@@ -1371,8 +1371,29 @@ interface LumiClientOptions {
1371
1371
  * is thrown so a host (e.g. Studio) can clear the stale token and route
1372
1372
  * the operator back to the login screen. Errors thrown by the callback
1373
1373
  * are swallowed so they never mask the original `LumiError`.
1374
+ *
1375
+ * When auto-refresh is enabled (see `refreshToken`), `onUnauthorized`
1376
+ * fires only AFTER a refresh attempt has also failed.
1374
1377
  */
1375
1378
  onUnauthorized?: () => void;
1379
+ /**
1380
+ * Rotating refresh token (body transport). When set, a `401` triggers a
1381
+ * single `POST /api/v1/auth/refresh` with this token; on success the new
1382
+ * access token is adopted and the original request is retried once. The
1383
+ * refresh token rotates each call, so the new pair is surfaced via
1384
+ * {@link onTokensRefreshed} for the host to persist. Omit to keep the
1385
+ * legacy behaviour (no retry; `onUnauthorized` fires immediately).
1386
+ */
1387
+ refreshToken?: string;
1388
+ /**
1389
+ * Called after a successful silent refresh with the rotated token pair so
1390
+ * the host can persist them (the old refresh token is now revoked).
1391
+ */
1392
+ onTokensRefreshed?: (tokens: {
1393
+ token: string;
1394
+ refreshToken: string;
1395
+ refreshTokenExpiresAt?: string;
1396
+ }) => void;
1376
1397
  }
1377
1398
  interface LumiResponse<T> {
1378
1399
  data: T;
@@ -1542,6 +1563,7 @@ declare class RealtimeClient {
1542
1563
  private readonly opts;
1543
1564
  private ws;
1544
1565
  private readonly subscriptions;
1566
+ private readonly subscriptionFilters;
1545
1567
  private presenceListeners;
1546
1568
  private currentPresence;
1547
1569
  private sessionId;
@@ -1557,9 +1579,16 @@ declare class RealtimeClient {
1557
1579
  * Subscribe to item mutation events for a specific collection.
1558
1580
  * Multiple handlers per collection are supported.
1559
1581
  *
1582
+ * `opts.filter` is an optional Directus-style condition rule evaluated
1583
+ * server-side over the event envelope (`collection`/`action`/`itemId`) —
1584
+ * studio broadcasts are signal-only, so row data is never filterable. The
1585
+ * filter is per-collection: the most recent subscribe call's filter wins.
1586
+ *
1560
1587
  * @returns Unsubscribe function.
1561
1588
  */
1562
- subscribe(collection: string, callback: RealtimeEventCallback): () => void;
1589
+ subscribe(collection: string, callback: RealtimeEventCallback, opts?: {
1590
+ filter?: Record<string, unknown>;
1591
+ }): () => void;
1563
1592
  /** Remove a specific handler from a collection subscription. */
1564
1593
  unsubscribe(collection: string, callback: RealtimeEventCallback): void;
1565
1594
  /** Update the current user's presence. Sent immediately and on reconnect. */
package/dist/index.d.ts CHANGED
@@ -1371,8 +1371,29 @@ interface LumiClientOptions {
1371
1371
  * is thrown so a host (e.g. Studio) can clear the stale token and route
1372
1372
  * the operator back to the login screen. Errors thrown by the callback
1373
1373
  * are swallowed so they never mask the original `LumiError`.
1374
+ *
1375
+ * When auto-refresh is enabled (see `refreshToken`), `onUnauthorized`
1376
+ * fires only AFTER a refresh attempt has also failed.
1374
1377
  */
1375
1378
  onUnauthorized?: () => void;
1379
+ /**
1380
+ * Rotating refresh token (body transport). When set, a `401` triggers a
1381
+ * single `POST /api/v1/auth/refresh` with this token; on success the new
1382
+ * access token is adopted and the original request is retried once. The
1383
+ * refresh token rotates each call, so the new pair is surfaced via
1384
+ * {@link onTokensRefreshed} for the host to persist. Omit to keep the
1385
+ * legacy behaviour (no retry; `onUnauthorized` fires immediately).
1386
+ */
1387
+ refreshToken?: string;
1388
+ /**
1389
+ * Called after a successful silent refresh with the rotated token pair so
1390
+ * the host can persist them (the old refresh token is now revoked).
1391
+ */
1392
+ onTokensRefreshed?: (tokens: {
1393
+ token: string;
1394
+ refreshToken: string;
1395
+ refreshTokenExpiresAt?: string;
1396
+ }) => void;
1376
1397
  }
1377
1398
  interface LumiResponse<T> {
1378
1399
  data: T;
@@ -1542,6 +1563,7 @@ declare class RealtimeClient {
1542
1563
  private readonly opts;
1543
1564
  private ws;
1544
1565
  private readonly subscriptions;
1566
+ private readonly subscriptionFilters;
1545
1567
  private presenceListeners;
1546
1568
  private currentPresence;
1547
1569
  private sessionId;
@@ -1557,9 +1579,16 @@ declare class RealtimeClient {
1557
1579
  * Subscribe to item mutation events for a specific collection.
1558
1580
  * Multiple handlers per collection are supported.
1559
1581
  *
1582
+ * `opts.filter` is an optional Directus-style condition rule evaluated
1583
+ * server-side over the event envelope (`collection`/`action`/`itemId`) —
1584
+ * studio broadcasts are signal-only, so row data is never filterable. The
1585
+ * filter is per-collection: the most recent subscribe call's filter wins.
1586
+ *
1560
1587
  * @returns Unsubscribe function.
1561
1588
  */
1562
- subscribe(collection: string, callback: RealtimeEventCallback): () => void;
1589
+ subscribe(collection: string, callback: RealtimeEventCallback, opts?: {
1590
+ filter?: Record<string, unknown>;
1591
+ }): () => void;
1563
1592
  /** Remove a specific handler from a collection subscription. */
1564
1593
  unsubscribe(collection: string, callback: RealtimeEventCallback): void;
1565
1594
  /** Update the current user's presence. Sent immediately and on reconnect. */
package/dist/index.js CHANGED
@@ -34,12 +34,52 @@ function toErrorBody(status, body) {
34
34
  function createLumiClient(opts) {
35
35
  const fetcher = opts.fetcher ?? globalThis.fetch;
36
36
  const base = opts.url.replace(/\/$/, "");
37
- async function rawRequest(path, init = {}) {
37
+ let currentToken = opts.token;
38
+ let currentRefreshToken = opts.refreshToken;
39
+ let refreshInFlight = null;
40
+ async function attemptRefresh() {
41
+ if (!currentRefreshToken) return false;
42
+ if (!refreshInFlight) {
43
+ refreshInFlight = (async () => {
44
+ try {
45
+ const res = await fetcher(`${base}/api/v1/auth/refresh`, {
46
+ method: "POST",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ "x-lumi-site": opts.siteId
50
+ },
51
+ // Body transport is CSRF-exempt (the token isn't ambient).
52
+ body: JSON.stringify({ refreshToken: currentRefreshToken })
53
+ });
54
+ if (!res.ok) return false;
55
+ const data = parseResponseBody(await res.text())?.data;
56
+ if (!data?.token || !data.refreshToken) return false;
57
+ currentToken = data.token;
58
+ currentRefreshToken = data.refreshToken;
59
+ try {
60
+ opts.onTokensRefreshed?.({
61
+ token: data.token,
62
+ refreshToken: data.refreshToken,
63
+ refreshTokenExpiresAt: data.refreshTokenExpiresAt
64
+ });
65
+ } catch {
66
+ }
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ } finally {
71
+ refreshInFlight = null;
72
+ }
73
+ })();
74
+ }
75
+ return refreshInFlight;
76
+ }
77
+ async function rawRequest(path, init = {}, retried = false) {
38
78
  const headers = new Headers(init.headers);
39
79
  for (const [key, value] of Object.entries(opts.headers ?? {})) {
40
80
  headers.set(key, value);
41
81
  }
42
- headers.set("authorization", `Bearer ${opts.token}`);
82
+ headers.set("authorization", `Bearer ${currentToken}`);
43
83
  headers.set("x-lumi-site", opts.siteId);
44
84
  if (!headers.has("content-type") && init.body) {
45
85
  headers.set("content-type", "application/json");
@@ -48,6 +88,11 @@ function createLumiClient(opts) {
48
88
  const text = await res.text();
49
89
  const body = parseResponseBody(text);
50
90
  if (!res.ok) {
91
+ if (res.status === 401 && !retried && currentRefreshToken && path !== "/api/v1/auth/refresh") {
92
+ if (await attemptRefresh()) {
93
+ return rawRequest(path, init, true);
94
+ }
95
+ }
51
96
  if (res.status === 401 && opts.onUnauthorized) {
52
97
  try {
53
98
  opts.onUnauthorized();
@@ -60,7 +105,9 @@ function createLumiClient(opts) {
60
105
  }
61
106
  const baseClient = {
62
107
  url: opts.url,
63
- token: opts.token,
108
+ get token() {
109
+ return currentToken;
110
+ },
64
111
  siteId: opts.siteId,
65
112
  fetcher,
66
113
  rawRequest,
@@ -246,6 +293,7 @@ var RealtimeClient = class {
246
293
  opts;
247
294
  ws = null;
248
295
  subscriptions = /* @__PURE__ */ new Map();
296
+ subscriptionFilters = /* @__PURE__ */ new Map();
249
297
  presenceListeners = /* @__PURE__ */ new Set();
250
298
  currentPresence = {};
251
299
  sessionId = null;
@@ -278,14 +326,20 @@ var RealtimeClient = class {
278
326
  * Subscribe to item mutation events for a specific collection.
279
327
  * Multiple handlers per collection are supported.
280
328
  *
329
+ * `opts.filter` is an optional Directus-style condition rule evaluated
330
+ * server-side over the event envelope (`collection`/`action`/`itemId`) —
331
+ * studio broadcasts are signal-only, so row data is never filterable. The
332
+ * filter is per-collection: the most recent subscribe call's filter wins.
333
+ *
281
334
  * @returns Unsubscribe function.
282
335
  */
283
- subscribe(collection, callback) {
336
+ subscribe(collection, callback, opts = {}) {
284
337
  if (!this.subscriptions.has(collection)) {
285
338
  this.subscriptions.set(collection, /* @__PURE__ */ new Set());
286
339
  }
287
340
  this.subscriptions.get(collection).add(callback);
288
- this._send({ type: "subscribe", collection });
341
+ if (opts.filter) this.subscriptionFilters.set(collection, opts.filter);
342
+ this._send({ type: "subscribe", collection, ...opts.filter ? { filter: opts.filter } : {} });
289
343
  return () => this.unsubscribe(collection, callback);
290
344
  }
291
345
  /** Remove a specific handler from a collection subscription. */
@@ -295,6 +349,7 @@ var RealtimeClient = class {
295
349
  set.delete(callback);
296
350
  if (set.size === 0) {
297
351
  this.subscriptions.delete(collection);
352
+ this.subscriptionFilters.delete(collection);
298
353
  this._send({ type: "unsubscribe", collection });
299
354
  }
300
355
  }
@@ -359,7 +414,8 @@ var RealtimeClient = class {
359
414
  ws.addEventListener("open", () => {
360
415
  this.backoffMs = this.opts.initialBackoffMs ?? 1e3;
361
416
  for (const collection of this.subscriptions.keys()) {
362
- this._sendRaw({ type: "subscribe", collection });
417
+ const filter = this.subscriptionFilters.get(collection);
418
+ this._sendRaw({ type: "subscribe", collection, ...filter ? { filter } : {} });
363
419
  }
364
420
  if (Object.keys(this.currentPresence).length > 0) {
365
421
  this._sendRaw({ type: "presence", ...this.currentPresence });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumibase/sdk",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "LumiBase JS SDK (REST + WebSocket) + typegen core. Isomorphic ESM.",
5
5
  "type": "module",
6
6
  "repository": {