@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 +62 -6
- package/dist/index.d.cts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +62 -6
- package/package.json +1 -1
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
|
-
|
|
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 ${
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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.
|
|
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.
|
|
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 });
|