@koolbase/js 10.1.0 → 10.2.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/CHANGELOG.md +19 -0
- package/README.md +7 -5
- package/dist/cjs/platform.js +70 -5
- package/dist/esm/platform.js +70 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ is based on [Keep a Changelog][kac], and this project adheres to
|
|
|
7
7
|
[kac]: https://keepachangelog.com/en/1.1.0/
|
|
8
8
|
[semver]: https://semver.org/
|
|
9
9
|
|
|
10
|
+
## 10.2.0
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Multiple tabs are coordinated.** Tabs on one origin share one IndexedDB
|
|
15
|
+
and one offline queue. Two locks, through the Web Locks API, now keep them
|
|
16
|
+
honest: a short exclusive lock around every read-modify-write of the
|
|
17
|
+
offline state, so two tabs enqueueing at once cannot overwrite each other;
|
|
18
|
+
and a lease on replaying the queue, held for a whole flush including its
|
|
19
|
+
HTTP calls, so two tabs coming online together send each queued write once
|
|
20
|
+
rather than once each. A tab that cannot take the lease skips its pass and
|
|
21
|
+
rechecks once the holder is done, so a write queued during another tab's
|
|
22
|
+
flush is not stranded. A tab that closes mid-flush releases its lock
|
|
23
|
+
automatically. In a browser without Web Locks — none current — offline
|
|
24
|
+
queueing refuses with an explicit error rather than risking a double send.
|
|
25
|
+
|
|
26
|
+
Proven with a contended fake lock manager: two tabs, one queued write, one
|
|
27
|
+
HTTP request. Exercised in Chrome with real Web Locks.
|
|
28
|
+
|
|
10
29
|
## 10.1.0
|
|
11
30
|
|
|
12
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -168,11 +168,13 @@ becomes a conflict you resolve: `resolveWithLocal()`, `resolveWithServer()`,
|
|
|
168
168
|
`resolveWithMerge({...})`, or `abandon()`. Conflicts survive reloads and do not
|
|
169
169
|
expire; surface them if you support offline editing.
|
|
170
170
|
|
|
171
|
-
**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
**Multiple tabs are coordinated.** Tabs share one IndexedDB and one queue, and
|
|
172
|
+
the SDK uses the Web Locks API so that only one tab replays a queued write and
|
|
173
|
+
state changes from different tabs cannot overwrite each other. A tab that
|
|
174
|
+
closes mid-flush releases its lock automatically; the next tab picks up
|
|
175
|
+
whatever remains. Every current browser has Web Locks; in one that does not,
|
|
176
|
+
offline queueing is disabled with an explicit error rather than risking a
|
|
177
|
+
write being sent twice.
|
|
176
178
|
|
|
177
179
|
---
|
|
178
180
|
|
package/dist/cjs/platform.js
CHANGED
|
@@ -11,11 +11,9 @@ const auth_storage_js_1 = require("./auth-storage.js");
|
|
|
11
11
|
//
|
|
12
12
|
// What this adapter does NOT do, stated so it is not discovered later:
|
|
13
13
|
//
|
|
14
|
-
// -
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// a conflict resolved in one tab can be re-resolved in another. v1 is
|
|
18
|
-
// single-tab; a leader election is the fix and belongs in its own change.
|
|
14
|
+
// - Coordinate across origins. Tabs on one origin share a queue and are
|
|
15
|
+
// coordinated through Web Locks (see webLocks below); nothing coordinates
|
|
16
|
+
// two different origins, which is correct — they are different apps.
|
|
19
17
|
//
|
|
20
18
|
// - Secure token storage. There is no keychain in a browser. Anything
|
|
21
19
|
// JavaScript can read, a script injected by XSS can read. The core's
|
|
@@ -148,6 +146,64 @@ function makeResolver() {
|
|
|
148
146
|
return resolving.then((r) => { resolved = r; return r; });
|
|
149
147
|
};
|
|
150
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Web Locks. One holder per name across every tab on the origin, with
|
|
151
|
+
* ownership released when a tab closes or crashes — which is why this and
|
|
152
|
+
* not a BroadcastChannel election with heartbeats and a dead-leader timeout.
|
|
153
|
+
*
|
|
154
|
+
* Where the API is absent there is deliberately no fallback to running the
|
|
155
|
+
* function unlocked: that is exactly the uncoordinated behaviour the lock
|
|
156
|
+
* exists to prevent, and doing it silently would be worse than refusing.
|
|
157
|
+
* Every browser current enough to run this SDK has Web Locks (Safari 15.4+,
|
|
158
|
+
* Chrome 69+, Firefox 96+), so this is a guard, not a limitation.
|
|
159
|
+
*/
|
|
160
|
+
function webLocks() {
|
|
161
|
+
// Tabs are what need coordinating, and a tab has a window. No window means
|
|
162
|
+
// server rendering, a build step, a Node tool, a worker — nothing else can
|
|
163
|
+
// be sharing this store, so run the work. Not navigator: Node 22 ships a
|
|
164
|
+
// navigator object without locks, and testing for it would refuse every
|
|
165
|
+
// server environment the memory fallback exists to support.
|
|
166
|
+
if (typeof window === 'undefined') {
|
|
167
|
+
return {
|
|
168
|
+
exclusive: (_name, fn) => fn(),
|
|
169
|
+
tryExclusive: async (_name, fn) => { await fn(); return { ran: true }; },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// A browser that has navigator but no locks is a browser too old to
|
|
173
|
+
// coordinate. That one refuses: tabs exist, and running unlocked is the
|
|
174
|
+
// duplicate-replay risk this lock was added to remove.
|
|
175
|
+
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
|
|
176
|
+
const refuse = () => {
|
|
177
|
+
throw new Error('[Koolbase] This browser has no Web Locks API, so the SDK cannot ' +
|
|
178
|
+
'coordinate offline writes between tabs. Offline queueing is ' +
|
|
179
|
+
'disabled rather than risking a write being replayed twice.');
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
exclusive: async () => refuse(),
|
|
183
|
+
tryExclusive: async () => refuse(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const locks = navigator.locks;
|
|
187
|
+
return {
|
|
188
|
+
// request() resolves with whatever the callback resolves to; the DOM
|
|
189
|
+
// typings describe the callback's return as the result, so an async
|
|
190
|
+
// callback types as Promise<Promise<T>>. The runtime awaits it.
|
|
191
|
+
exclusive: (name, fn) => locks.request(name, fn),
|
|
192
|
+
tryExclusive: async (name, fn) => {
|
|
193
|
+
let ran = false;
|
|
194
|
+
await locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
195
|
+
// A null lock means another tab holds it. Skip: it is already
|
|
196
|
+
// replaying the same queue, and waiting would only duplicate the
|
|
197
|
+
// wait, not the work.
|
|
198
|
+
if (!lock)
|
|
199
|
+
return;
|
|
200
|
+
ran = true;
|
|
201
|
+
await fn();
|
|
202
|
+
});
|
|
203
|
+
return { ran };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
151
207
|
function browserPlatform() {
|
|
152
208
|
const resolve = makeResolver();
|
|
153
209
|
// Every call resolves first, so a browser that only reveals its refusal on
|
|
@@ -157,6 +213,14 @@ function browserPlatform() {
|
|
|
157
213
|
setItem: async (k, v) => { await (await resolve()).store.setItem(k, v); },
|
|
158
214
|
removeItem: async (k) => { await (await resolve()).store.removeItem(k); },
|
|
159
215
|
getAllKeys: async () => (await resolve()).store.getAllKeys(),
|
|
216
|
+
// Reaches through to the resolved tier. Only IndexedDB holds a
|
|
217
|
+
// connection worth closing; the test harness needs it released before
|
|
218
|
+
// it can delete the database between cases, and without this the
|
|
219
|
+
// façade hid the handle and every browser test hung on its hook.
|
|
220
|
+
close: async () => {
|
|
221
|
+
const r = await resolve();
|
|
222
|
+
await r.store.close?.();
|
|
223
|
+
},
|
|
160
224
|
};
|
|
161
225
|
// Which tier this adapter settled on: 'indexeddb', 'localstorage' or
|
|
162
226
|
// 'memory'. An app that cares can warn the user before they lose a session.
|
|
@@ -204,6 +268,7 @@ function browserPlatform() {
|
|
|
204
268
|
os: 'web',
|
|
205
269
|
version: browserVersion(),
|
|
206
270
|
},
|
|
271
|
+
locks: webLocks(),
|
|
207
272
|
// IndexedDB-backed; see auth-storage.ts for what that does and does not
|
|
208
273
|
// protect against.
|
|
209
274
|
authStorage: () => new auth_storage_js_1.BrowserAuthStorage(),
|
package/dist/esm/platform.js
CHANGED
|
@@ -8,11 +8,9 @@ import { BrowserAuthStorage } from './auth-storage.js';
|
|
|
8
8
|
//
|
|
9
9
|
// What this adapter does NOT do, stated so it is not discovered later:
|
|
10
10
|
//
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// a conflict resolved in one tab can be re-resolved in another. v1 is
|
|
15
|
-
// single-tab; a leader election is the fix and belongs in its own change.
|
|
11
|
+
// - Coordinate across origins. Tabs on one origin share a queue and are
|
|
12
|
+
// coordinated through Web Locks (see webLocks below); nothing coordinates
|
|
13
|
+
// two different origins, which is correct — they are different apps.
|
|
16
14
|
//
|
|
17
15
|
// - Secure token storage. There is no keychain in a browser. Anything
|
|
18
16
|
// JavaScript can read, a script injected by XSS can read. The core's
|
|
@@ -145,6 +143,64 @@ function makeResolver() {
|
|
|
145
143
|
return resolving.then((r) => { resolved = r; return r; });
|
|
146
144
|
};
|
|
147
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Web Locks. One holder per name across every tab on the origin, with
|
|
148
|
+
* ownership released when a tab closes or crashes — which is why this and
|
|
149
|
+
* not a BroadcastChannel election with heartbeats and a dead-leader timeout.
|
|
150
|
+
*
|
|
151
|
+
* Where the API is absent there is deliberately no fallback to running the
|
|
152
|
+
* function unlocked: that is exactly the uncoordinated behaviour the lock
|
|
153
|
+
* exists to prevent, and doing it silently would be worse than refusing.
|
|
154
|
+
* Every browser current enough to run this SDK has Web Locks (Safari 15.4+,
|
|
155
|
+
* Chrome 69+, Firefox 96+), so this is a guard, not a limitation.
|
|
156
|
+
*/
|
|
157
|
+
function webLocks() {
|
|
158
|
+
// Tabs are what need coordinating, and a tab has a window. No window means
|
|
159
|
+
// server rendering, a build step, a Node tool, a worker — nothing else can
|
|
160
|
+
// be sharing this store, so run the work. Not navigator: Node 22 ships a
|
|
161
|
+
// navigator object without locks, and testing for it would refuse every
|
|
162
|
+
// server environment the memory fallback exists to support.
|
|
163
|
+
if (typeof window === 'undefined') {
|
|
164
|
+
return {
|
|
165
|
+
exclusive: (_name, fn) => fn(),
|
|
166
|
+
tryExclusive: async (_name, fn) => { await fn(); return { ran: true }; },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// A browser that has navigator but no locks is a browser too old to
|
|
170
|
+
// coordinate. That one refuses: tabs exist, and running unlocked is the
|
|
171
|
+
// duplicate-replay risk this lock was added to remove.
|
|
172
|
+
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
|
|
173
|
+
const refuse = () => {
|
|
174
|
+
throw new Error('[Koolbase] This browser has no Web Locks API, so the SDK cannot ' +
|
|
175
|
+
'coordinate offline writes between tabs. Offline queueing is ' +
|
|
176
|
+
'disabled rather than risking a write being replayed twice.');
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
exclusive: async () => refuse(),
|
|
180
|
+
tryExclusive: async () => refuse(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const locks = navigator.locks;
|
|
184
|
+
return {
|
|
185
|
+
// request() resolves with whatever the callback resolves to; the DOM
|
|
186
|
+
// typings describe the callback's return as the result, so an async
|
|
187
|
+
// callback types as Promise<Promise<T>>. The runtime awaits it.
|
|
188
|
+
exclusive: (name, fn) => locks.request(name, fn),
|
|
189
|
+
tryExclusive: async (name, fn) => {
|
|
190
|
+
let ran = false;
|
|
191
|
+
await locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
192
|
+
// A null lock means another tab holds it. Skip: it is already
|
|
193
|
+
// replaying the same queue, and waiting would only duplicate the
|
|
194
|
+
// wait, not the work.
|
|
195
|
+
if (!lock)
|
|
196
|
+
return;
|
|
197
|
+
ran = true;
|
|
198
|
+
await fn();
|
|
199
|
+
});
|
|
200
|
+
return { ran };
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
148
204
|
export function browserPlatform() {
|
|
149
205
|
const resolve = makeResolver();
|
|
150
206
|
// Every call resolves first, so a browser that only reveals its refusal on
|
|
@@ -154,6 +210,14 @@ export function browserPlatform() {
|
|
|
154
210
|
setItem: async (k, v) => { await (await resolve()).store.setItem(k, v); },
|
|
155
211
|
removeItem: async (k) => { await (await resolve()).store.removeItem(k); },
|
|
156
212
|
getAllKeys: async () => (await resolve()).store.getAllKeys(),
|
|
213
|
+
// Reaches through to the resolved tier. Only IndexedDB holds a
|
|
214
|
+
// connection worth closing; the test harness needs it released before
|
|
215
|
+
// it can delete the database between cases, and without this the
|
|
216
|
+
// façade hid the handle and every browser test hung on its hook.
|
|
217
|
+
close: async () => {
|
|
218
|
+
const r = await resolve();
|
|
219
|
+
await r.store.close?.();
|
|
220
|
+
},
|
|
157
221
|
};
|
|
158
222
|
// Which tier this adapter settled on: 'indexeddb', 'localstorage' or
|
|
159
223
|
// 'memory'. An app that cares can warn the user before they lose a session.
|
|
@@ -201,6 +265,7 @@ export function browserPlatform() {
|
|
|
201
265
|
os: 'web',
|
|
202
266
|
version: browserVersion(),
|
|
203
267
|
},
|
|
268
|
+
locks: webLocks(),
|
|
204
269
|
// IndexedDB-backed; see auth-storage.ts for what that does and does not
|
|
205
270
|
// protect against.
|
|
206
271
|
authStorage: () => new BrowserAuthStorage(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koolbase/js",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.2.0",
|
|
4
4
|
"description": "Koolbase SDK for the browser \u2014 auth, database, storage, realtime, functions, flags and offline sync in one package.",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"types": "./dist/esm/index.d.ts",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"url": "https://github.com/koolbase/koolbase-react-native"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@koolbase/core": "10.
|
|
28
|
+
"@koolbase/core": "10.2.0"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|