@livestore/adapter-web 0.4.0-dev.21 → 0.4.0-dev.23

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.
Files changed (54) hide show
  1. package/README.md +5 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/in-memory/in-memory-adapter.d.ts.map +1 -1
  4. package/dist/in-memory/in-memory-adapter.js +8 -4
  5. package/dist/in-memory/in-memory-adapter.js.map +1 -1
  6. package/dist/index.d.ts +11 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +11 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/single-tab/mod.d.ts +15 -0
  11. package/dist/single-tab/mod.d.ts.map +1 -0
  12. package/dist/single-tab/mod.js +15 -0
  13. package/dist/single-tab/mod.js.map +1 -0
  14. package/dist/single-tab/single-tab-adapter.d.ts +108 -0
  15. package/dist/single-tab/single-tab-adapter.d.ts.map +1 -0
  16. package/dist/single-tab/single-tab-adapter.js +271 -0
  17. package/dist/single-tab/single-tab-adapter.js.map +1 -0
  18. package/dist/web-worker/client-session/client-session-devtools.d.ts +1 -1
  19. package/dist/web-worker/client-session/client-session-devtools.d.ts.map +1 -1
  20. package/dist/web-worker/client-session/client-session-devtools.js +7 -7
  21. package/dist/web-worker/client-session/client-session-devtools.js.map +1 -1
  22. package/dist/web-worker/client-session/persisted-adapter.d.ts +18 -0
  23. package/dist/web-worker/client-session/persisted-adapter.d.ts.map +1 -1
  24. package/dist/web-worker/client-session/persisted-adapter.js +95 -36
  25. package/dist/web-worker/client-session/persisted-adapter.js.map +1 -1
  26. package/dist/web-worker/client-session/sqlite-loader.d.ts.map +1 -1
  27. package/dist/web-worker/client-session/sqlite-loader.js +1 -1
  28. package/dist/web-worker/client-session/sqlite-loader.js.map +1 -1
  29. package/dist/web-worker/common/persisted-sqlite.d.ts.map +1 -1
  30. package/dist/web-worker/common/persisted-sqlite.js +13 -11
  31. package/dist/web-worker/common/persisted-sqlite.js.map +1 -1
  32. package/dist/web-worker/common/worker-schema.d.ts +34 -31
  33. package/dist/web-worker/common/worker-schema.d.ts.map +1 -1
  34. package/dist/web-worker/common/worker-schema.js +18 -19
  35. package/dist/web-worker/common/worker-schema.js.map +1 -1
  36. package/dist/web-worker/leader-worker/make-leader-worker.d.ts +2 -2
  37. package/dist/web-worker/leader-worker/make-leader-worker.d.ts.map +1 -1
  38. package/dist/web-worker/leader-worker/make-leader-worker.js +59 -25
  39. package/dist/web-worker/leader-worker/make-leader-worker.js.map +1 -1
  40. package/dist/web-worker/shared-worker/make-shared-worker.d.ts.map +1 -1
  41. package/dist/web-worker/shared-worker/make-shared-worker.js +15 -15
  42. package/dist/web-worker/shared-worker/make-shared-worker.js.map +1 -1
  43. package/package.json +56 -17
  44. package/src/in-memory/in-memory-adapter.ts +9 -5
  45. package/src/index.ts +15 -1
  46. package/src/single-tab/mod.ts +15 -0
  47. package/src/single-tab/single-tab-adapter.ts +499 -0
  48. package/src/web-worker/client-session/client-session-devtools.ts +26 -27
  49. package/src/web-worker/client-session/persisted-adapter.ts +126 -64
  50. package/src/web-worker/client-session/sqlite-loader.ts +1 -1
  51. package/src/web-worker/common/persisted-sqlite.ts +13 -10
  52. package/src/web-worker/common/worker-schema.ts +19 -18
  53. package/src/web-worker/leader-worker/make-leader-worker.ts +94 -52
  54. package/src/web-worker/shared-worker/make-shared-worker.ts +26 -48
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Single-tab adapter for browsers without SharedWorker support (e.g. Android Chrome).
3
+ *
4
+ * This adapter is a fallback for browsers that don't support the SharedWorker API.
5
+ * It provides the same OPFS persistence as the regular persisted adapter, but without
6
+ * multi-tab synchronization capabilities.
7
+ *
8
+ * **IMPORTANT**: This code is intended to be removed once SharedWorker support is
9
+ * available in Android Chrome. Track progress at:
10
+ * - LiveStore issue: https://github.com/livestorejs/livestore/issues/321
11
+ * - Chromium bug: https://issues.chromium.org/issues/40290702
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import type { Adapter, BootWarningReason, ClientSession, LockStatus } from '@livestore/common'
17
+ import {
18
+ IntentionalShutdownCause,
19
+ isWorkerTransportError,
20
+ makeClientSession,
21
+ StoreInterrupted,
22
+ sessionChangesetMetaTable,
23
+ UnknownError,
24
+ } from '@livestore/common'
25
+ import { EventSequenceNumber } from '@livestore/common/schema'
26
+ import { sqliteDbFactory } from '@livestore/sqlite-wasm/browser'
27
+ import { shouldNeverHappen, tryAsFunctionAndNew } from '@livestore/utils'
28
+ import {
29
+ Cause,
30
+ Effect,
31
+ Exit,
32
+ Fiber,
33
+ Layer,
34
+ Option,
35
+ Queue,
36
+ Schema,
37
+ Stream,
38
+ Subscribable,
39
+ SubscriptionRef,
40
+ Worker,
41
+ } from '@livestore/utils/effect'
42
+ import { BrowserWorker, Opfs, WebError } from '@livestore/utils/effect/browser'
43
+ import { nanoid } from '@livestore/utils/nanoid'
44
+
45
+ import { loadSqlite3 } from '../web-worker/client-session/sqlite-loader.ts'
46
+ import {
47
+ readPersistedStateDbFromClientSession,
48
+ resetPersistedDataFromClientSession,
49
+ } from '../web-worker/common/persisted-sqlite.ts'
50
+ import { makeShutdownChannel } from '../web-worker/common/shutdown-channel.ts'
51
+ import * as WorkerSchema from '../web-worker/common/worker-schema.ts'
52
+
53
+ /**
54
+ * Options for the single-tab adapter.
55
+ *
56
+ * This adapter is designed for browsers without SharedWorker support (e.g. Android Chrome).
57
+ * It provides OPFS persistence but without multi-tab synchronization.
58
+ *
59
+ * @see https://github.com/livestorejs/livestore/issues/321
60
+ * @see https://issues.chromium.org/issues/40290702
61
+ */
62
+ export type SingleTabAdapterOptions = {
63
+ /**
64
+ * The dedicated web worker that runs the LiveStore leader thread.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * import LiveStoreWorker from './livestore.worker.ts?worker'
69
+ *
70
+ * const adapter = makeSingleTabAdapter({
71
+ * worker: LiveStoreWorker,
72
+ * storage: { type: 'opfs' },
73
+ * })
74
+ * ```
75
+ */
76
+ worker: ((options: { name: string }) => globalThis.Worker) | (new (options: { name: string }) => globalThis.Worker)
77
+
78
+ /**
79
+ * Storage configuration. Currently only OPFS is supported.
80
+ */
81
+ storage: WorkerSchema.StorageTypeEncoded
82
+
83
+ /**
84
+ * Warning: This will reset both the app and eventlog database.
85
+ * This should only be used during development.
86
+ *
87
+ * @default false
88
+ */
89
+ resetPersistence?: boolean
90
+
91
+ /**
92
+ * By default the adapter will initially generate a random clientId (via `nanoid(5)`),
93
+ * store it in `localStorage` and restore it for subsequent client sessions.
94
+ */
95
+ clientId?: string
96
+
97
+ /**
98
+ * By default the adapter will initially generate a random sessionId (via `nanoid(5)`),
99
+ * store it in `sessionStorage` and restore it for subsequent client sessions in the same tab.
100
+ */
101
+ sessionId?: string
102
+
103
+ experimental?: {
104
+ /**
105
+ * When set to `true`, the adapter will always start with a snapshot from the leader
106
+ * instead of trying to load a snapshot from storage.
107
+ *
108
+ * @default false
109
+ */
110
+ disableFastPath?: boolean
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Creates a single-tab web adapter with OPFS persistence.
116
+ *
117
+ * **This adapter is a fallback for browsers without SharedWorker support** (notably Android Chrome).
118
+ * It provides the same persistence capabilities as `makePersistedAdapter`, but without multi-tab
119
+ * synchronization. Each browser tab runs its own independent leader worker.
120
+ *
121
+ * In most cases, you should use `makePersistedAdapter` instead, which automatically falls back
122
+ * to this adapter when SharedWorker is unavailable.
123
+ *
124
+ * **Limitations**:
125
+ * - No multi-tab synchronization (each tab operates independently)
126
+ * - No devtools support (requires SharedWorker)
127
+ * - Opening multiple tabs with the same storeId may cause data conflicts
128
+ *
129
+ * @see https://github.com/livestorejs/livestore/issues/321 - LiveStore tracking issue
130
+ * @see https://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * import { makeSingleTabAdapter } from '@livestore/adapter-web'
135
+ * import LiveStoreWorker from './livestore.worker.ts?worker'
136
+ *
137
+ * // Only use this directly if you specifically need single-tab mode.
138
+ * // Prefer makePersistedAdapter which auto-detects SharedWorker support.
139
+ * const adapter = makeSingleTabAdapter({
140
+ * worker: LiveStoreWorker,
141
+ * storage: { type: 'opfs' },
142
+ * })
143
+ * ```
144
+ */
145
+ export const makeSingleTabAdapter =
146
+ (options: SingleTabAdapterOptions): Adapter =>
147
+ (adapterArgs) =>
148
+ Effect.gen(function* () {
149
+ const { schema, storeId, bootStatusQueue, shutdown, syncPayloadEncoded } = adapterArgs
150
+ // Note: devtoolsEnabled is ignored in single-tab mode (devtools require SharedWorker)
151
+
152
+ yield* ensureBrowserRequirements
153
+
154
+ yield* Queue.offer(bootStatusQueue, { stage: 'loading' })
155
+
156
+ const sqlite3 = yield* Effect.promise(() => loadSqlite3())
157
+
158
+ const storageOptions = yield* Schema.decode(WorkerSchema.StorageType)(options.storage)
159
+
160
+ const shutdownChannel = yield* makeShutdownChannel(storeId)
161
+
162
+ // Check OPFS availability early and notify user if storage is unavailable (e.g. private browsing)
163
+ const opfsWarning = yield* checkOpfsAvailability
164
+ if (opfsWarning !== undefined) {
165
+ yield* Effect.logWarning('[@livestore/adapter-web:single-tab] OPFS unavailable', opfsWarning)
166
+ }
167
+
168
+ if (options.resetPersistence === true && opfsWarning === undefined) {
169
+ yield* shutdownChannel.send(IntentionalShutdownCause.make({ reason: 'adapter-reset' }))
170
+ yield* resetPersistedDataFromClientSession({ storageOptions, storeId })
171
+ } else if (options.resetPersistence === true) {
172
+ yield* Effect.logWarning(
173
+ '[@livestore/adapter-web:single-tab] Skipping persistence reset because storage is unavailable',
174
+ opfsWarning,
175
+ )
176
+ }
177
+
178
+ // Fast-path: try to load snapshot directly from storage
179
+ const dataFromFile =
180
+ options.experimental?.disableFastPath === true || opfsWarning !== undefined
181
+ ? undefined
182
+ : yield* readPersistedStateDbFromClientSession({ storageOptions, storeId, schema }).pipe(
183
+ Effect.tapError((error) =>
184
+ Effect.logDebug('[@livestore/adapter-web:single-tab] Could not read persisted state db', error, {
185
+ storeId,
186
+ }),
187
+ ),
188
+ Effect.orElseSucceed(() => undefined),
189
+ )
190
+
191
+ const clientId = options.clientId ?? getPersistedId(`clientId:${storeId}`, 'local')
192
+ const sessionId = options.sessionId ?? getPersistedId(`sessionId:${storeId}`, 'session')
193
+
194
+ yield* shutdownChannel.listen.pipe(
195
+ Stream.flatten(),
196
+ Stream.tap((cause) =>
197
+ shutdown(cause._tag === 'IntentionalShutdownCause' ? Exit.succeed(cause) : Exit.fail(cause)),
198
+ ),
199
+ Stream.runDrain,
200
+ Effect.interruptible,
201
+ Effect.tapCauseLogPretty,
202
+ Effect.forkScoped,
203
+ )
204
+
205
+ // In single-tab mode, we always have the lock (we're always the leader)
206
+ const lockStatus = yield* SubscriptionRef.make<LockStatus>('has-lock')
207
+
208
+ // Create MessageChannel for direct communication with the dedicated worker
209
+ const mc = new MessageChannel()
210
+
211
+ // Create the dedicated worker directly (no SharedWorker proxy)
212
+ const worker = tryAsFunctionAndNew(options.worker, { name: `livestore-worker-${storeId}-${sessionId}` })
213
+
214
+ // Set up communication with the dedicated worker via the outer protocol
215
+ const _dedicatedWorkerFiber = yield* Worker.makeSerialized<WorkerSchema.LeaderWorkerOuterRequest>({
216
+ initialMessage: () => new WorkerSchema.LeaderWorkerOuterInitialMessage({ port: mc.port1, storeId, clientId }),
217
+ }).pipe(
218
+ Effect.provide(BrowserWorker.layer(() => worker)),
219
+ UnknownError.mapToUnknownError,
220
+ Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
221
+ Effect.withSpan('@livestore/adapter-web:single-tab:setupDedicatedWorker'),
222
+ Effect.tapCauseLogPretty,
223
+ Effect.forkScoped,
224
+ )
225
+
226
+ // Set up the inner worker communication via port2 (like SharedWorker would do)
227
+ // BrowserWorker.layer accepts a MessagePort as well as a Worker
228
+ const innerWorkerContext = yield* Layer.build(BrowserWorker.layer(() => mc.port2 as unknown as globalThis.Worker))
229
+ const innerWorkerFiber = yield* Worker.makePoolSerialized<WorkerSchema.LeaderWorkerInnerRequest>({
230
+ size: 1,
231
+ concurrency: 100,
232
+ initialMessage: () =>
233
+ new WorkerSchema.LeaderWorkerInnerInitialMessage({
234
+ storageOptions,
235
+ storeId,
236
+ clientId,
237
+ // Devtools disabled in single-tab mode (requires SharedWorker)
238
+ devtoolsEnabled: false,
239
+ debugInstanceId: adapterArgs.debugInstanceId,
240
+ syncPayloadEncoded,
241
+ }),
242
+ }).pipe(
243
+ Effect.provide(innerWorkerContext),
244
+ Effect.tapCauseLogPretty,
245
+ Effect.orDie,
246
+ Effect.tapErrorCause((cause) => shutdown(Exit.failCause(cause))),
247
+ Effect.withSpan('@livestore/adapter-web:single-tab:setupInnerWorker'),
248
+ Effect.forkScoped,
249
+ )
250
+
251
+ // Helper to run requests against the worker
252
+ const runInWorker = <A, I, E, EI, R>(
253
+ req: WorkerSchema.LeaderWorkerInnerRequest & Schema.WithResult<A, I, E, EI, R>,
254
+ ): Effect.Effect<A, E, R> =>
255
+ Fiber.join(innerWorkerFiber).pipe(
256
+ Effect.flatMap((worker) => worker.executeEffect(req)),
257
+ Effect.catchIf(isWorkerTransportError, (e) => Effect.die(e)),
258
+ Effect.logWarnIfTakesLongerThan({
259
+ label: `@livestore/adapter-web:single-tab:runInWorker:${req._tag}`,
260
+ duration: 2000,
261
+ }),
262
+ Effect.withSpan(`@livestore/adapter-web:single-tab:runInWorker:${req._tag}`),
263
+ )
264
+
265
+ const runInWorkerStream = <A, I, E, EI, R>(
266
+ req: WorkerSchema.LeaderWorkerInnerRequest & Schema.WithResult<A, I, E, EI, R>,
267
+ ): Stream.Stream<A, E, R> =>
268
+ Effect.gen(function* () {
269
+ const innerWorker = yield* Fiber.join(innerWorkerFiber)
270
+ return innerWorker.execute(req).pipe(
271
+ Stream.refineOrDie((e) => isWorkerTransportError(e) === true ? Option.none() : Option.some(e)),
272
+ Stream.withSpan(`@livestore/adapter-web:single-tab:runInWorkerStream:${req._tag}`),
273
+ )
274
+ }).pipe(Stream.unwrap)
275
+
276
+ // Forward boot status from worker
277
+ const bootStatusFiber = yield* runInWorkerStream(new WorkerSchema.LeaderWorkerInnerBootStatusStream()).pipe(
278
+ Stream.tap((_) => Queue.offer(bootStatusQueue, _)),
279
+ Stream.runDrain,
280
+ Effect.tapErrorCause((cause) =>
281
+ Cause.isInterruptedOnly(cause) === true ? Effect.void : shutdown(Exit.failCause(cause)),
282
+ ),
283
+ Effect.interruptible,
284
+ Effect.tapCauseLogPretty,
285
+ Effect.forkScoped,
286
+ )
287
+
288
+ yield* Queue.awaitShutdown(bootStatusQueue).pipe(
289
+ Effect.andThen(Fiber.interrupt(bootStatusFiber)),
290
+ Effect.tapCauseLogPretty,
291
+ Effect.forkScoped,
292
+ )
293
+
294
+ // Get initial snapshot (either from fast-path or from worker)
295
+ const initialResult =
296
+ dataFromFile === undefined
297
+ ? yield* runInWorker(new WorkerSchema.LeaderWorkerInnerGetRecreateSnapshot()).pipe(
298
+ Effect.map(({ snapshot, migrationsReport }) => ({
299
+ _tag: 'from-leader-worker' as const,
300
+ snapshot,
301
+ migrationsReport,
302
+ })),
303
+ )
304
+ : { _tag: 'fast-path' as const, snapshot: dataFromFile }
305
+
306
+ const migrationsReport =
307
+ initialResult._tag === 'from-leader-worker' ? initialResult.migrationsReport : { migrations: [] }
308
+
309
+ const makeSqliteDb = sqliteDbFactory({ sqlite3 })
310
+ const sqliteDb = yield* makeSqliteDb({ _tag: 'in-memory' })
311
+
312
+ sqliteDb.import(initialResult.snapshot)
313
+
314
+ const numberOfTables =
315
+ sqliteDb.select<{ count: number }>(`select count(*) as count from sqlite_master`)[0]?.count ?? 0
316
+ if (numberOfTables === 0) {
317
+ return yield* UnknownError.make({
318
+ cause: `Encountered empty or corrupted database`,
319
+ payload: { snapshotByteLength: initialResult.snapshot.byteLength, storageOptions: options.storage },
320
+ })
321
+ }
322
+
323
+ // Restore leader head from SESSION_CHANGESET_META_TABLE
324
+ const initialLeaderHeadRes = sqliteDb.select(
325
+ sessionChangesetMetaTable
326
+ .select('seqNumClient', 'seqNumGlobal', 'seqNumRebaseGeneration')
327
+ .orderBy([
328
+ { col: 'seqNumGlobal', direction: 'desc' },
329
+ { col: 'seqNumClient', direction: 'desc' },
330
+ ])
331
+ .first(),
332
+ )
333
+
334
+ const initialLeaderHead =
335
+ initialLeaderHeadRes !== undefined
336
+ ? EventSequenceNumber.Client.Composite.make({
337
+ global: initialLeaderHeadRes.seqNumGlobal,
338
+ client: initialLeaderHeadRes.seqNumClient,
339
+ rebaseGeneration: initialLeaderHeadRes.seqNumRebaseGeneration,
340
+ })
341
+ : EventSequenceNumber.Client.ROOT
342
+
343
+ yield* Effect.addFinalizer((ex) =>
344
+ Effect.gen(function* () {
345
+ if (
346
+ Exit.isFailure(ex) === true &&
347
+ Exit.isInterrupted(ex) === false &&
348
+ Schema.is(IntentionalShutdownCause)(Cause.squash(ex.cause)) === false &&
349
+ Schema.is(StoreInterrupted)(Cause.squash(ex.cause)) === false
350
+ ) {
351
+ yield* Effect.logError('[@livestore/adapter-web:single-tab] client-session shutdown', ex.cause)
352
+ } else {
353
+ yield* Effect.logDebug('[@livestore/adapter-web:single-tab] client-session shutdown', ex)
354
+ }
355
+ }).pipe(Effect.tapCauseLogPretty, Effect.orDie),
356
+ )
357
+
358
+ const leaderThread: ClientSession['leaderThread'] = {
359
+ export: runInWorker(new WorkerSchema.LeaderWorkerInnerExport()).pipe(
360
+ Effect.timeoutOrDie(10_000),
361
+ Effect.withSpan('@livestore/adapter-web:single-tab:export'),
362
+ ),
363
+
364
+ events: {
365
+ pull: ({ cursor }) =>
366
+ runInWorkerStream(new WorkerSchema.LeaderWorkerInnerPullStream({ cursor })).pipe(Stream.orDie),
367
+ push: (batch) =>
368
+ runInWorker(new WorkerSchema.LeaderWorkerInnerPushToLeader({ batch })).pipe(
369
+ Effect.withSpan('@livestore/adapter-web:single-tab:pushToLeader', {
370
+ attributes: { batchSize: batch.length },
371
+ }),
372
+ ),
373
+ stream: (options) =>
374
+ runInWorkerStream(new WorkerSchema.LeaderWorkerInnerStreamEvents(options)).pipe(
375
+ Stream.withSpan('@livestore/adapter-web:single-tab:streamEvents'),
376
+ Stream.orDie,
377
+ ),
378
+ },
379
+
380
+ initialState: {
381
+ leaderHead: initialLeaderHead,
382
+ migrationsReport,
383
+ storageMode: opfsWarning === undefined ? 'persisted' : 'in-memory',
384
+ },
385
+
386
+ getEventlogData: runInWorker(new WorkerSchema.LeaderWorkerInnerExportEventlog()).pipe(
387
+ Effect.timeoutOrDie(10_000),
388
+ Effect.withSpan('@livestore/adapter-web:single-tab:getEventlogData'),
389
+ ),
390
+
391
+ syncState: Subscribable.make({
392
+ get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetLeaderSyncState()).pipe(
393
+ Effect.withSpan('@livestore/adapter-web:single-tab:getLeaderSyncState'),
394
+ ),
395
+ changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerSyncStateStream()).pipe(Stream.orDie),
396
+ }),
397
+
398
+ sendDevtoolsMessage: (_message) =>
399
+ // Devtools not supported in single-tab mode
400
+ Effect.void,
401
+
402
+ networkStatus: Subscribable.make({
403
+ get: runInWorker(new WorkerSchema.LeaderWorkerInnerGetNetworkStatus()).pipe(Effect.orDie),
404
+ changes: runInWorkerStream(new WorkerSchema.LeaderWorkerInnerNetworkStatusStream()).pipe(Stream.orDie),
405
+ }),
406
+ }
407
+
408
+ const clientSession = yield* makeClientSession({
409
+ ...adapterArgs,
410
+ sqliteDb,
411
+ lockStatus,
412
+ clientId,
413
+ sessionId,
414
+ isLeader: true, // Always leader in single-tab mode
415
+ leaderThread,
416
+ webmeshMode: 'direct',
417
+ origin: globalThis.location?.origin,
418
+ // No webmesh connection in single-tab mode (devtools disabled)
419
+ connectWebmeshNode: () => Effect.void,
420
+ registerBeforeUnload: (onBeforeUnload) => {
421
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
422
+ window.addEventListener('beforeunload', onBeforeUnload)
423
+ return () => window.removeEventListener('beforeunload', onBeforeUnload)
424
+ }
425
+ return () => {}
426
+ },
427
+ })
428
+
429
+ return clientSession
430
+ }).pipe(Effect.provide(Opfs.Opfs.Default), UnknownError.mapToUnknownError)
431
+
432
+ /** Persists clientId/sessionId to storage */
433
+ const getPersistedId = (key: string, storageType: 'session' | 'local') => {
434
+ const makeId = () => nanoid(5)
435
+
436
+ const storage =
437
+ typeof window === 'undefined'
438
+ ? undefined
439
+ : storageType === 'session'
440
+ ? sessionStorage
441
+ : storageType === 'local'
442
+ ? localStorage
443
+ : shouldNeverHappen(`[@livestore/adapter-web] Invalid storage type: ${String(storageType)}`)
444
+
445
+ if (storage === undefined) {
446
+ return makeId()
447
+ }
448
+
449
+ const fullKey = `livestore:${key}`
450
+ const storedKey = storage.getItem(fullKey)
451
+
452
+ if (storedKey !== null) return storedKey
453
+
454
+ const newKey = makeId()
455
+ storage.setItem(fullKey, newKey)
456
+
457
+ return newKey
458
+ }
459
+
460
+ const ensureBrowserRequirements = Effect.gen(function* () {
461
+ const validate = (condition: boolean, label: string) =>
462
+ Effect.gen(function* () {
463
+ if (condition === true) {
464
+ return yield* UnknownError.make({
465
+ cause: `[@livestore/adapter-web] Browser not supported. The LiveStore web adapter needs '${label}' to work properly`,
466
+ })
467
+ }
468
+ })
469
+
470
+ yield* Effect.all([
471
+ validate(typeof navigator === 'undefined', 'navigator'),
472
+ validate(navigator.locks === undefined, 'navigator.locks'),
473
+ validate(navigator.storage === undefined, 'navigator.storage'),
474
+ validate(crypto.randomUUID === undefined, 'crypto.randomUUID'),
475
+ validate(typeof window === 'undefined', 'window'),
476
+ validate(typeof sessionStorage === 'undefined', 'sessionStorage'),
477
+ ])
478
+ })
479
+
480
+ /**
481
+ * Attempts to access OPFS and returns a warning if unavailable.
482
+ */
483
+ const checkOpfsAvailability = Effect.gen(function* () {
484
+ const opfs = yield* Opfs.Opfs
485
+ return yield* opfs.getRootDirectoryHandle.pipe(
486
+ Effect.as(undefined),
487
+ Effect.catchAll((error) => {
488
+ const reason: BootWarningReason =
489
+ Schema.is(WebError.SecurityError)(error) === true || Schema.is(WebError.NotAllowedError)(error) === true
490
+ ? 'private-browsing'
491
+ : 'storage-unavailable'
492
+ const message =
493
+ reason === 'private-browsing'
494
+ ? 'Storage unavailable in private browsing mode. LiveStore will continue without persistence.'
495
+ : 'Storage access denied. LiveStore will continue without persistence.'
496
+ return Effect.succeed({ reason, message } as const)
497
+ }),
498
+ )
499
+ })
@@ -7,7 +7,7 @@ import { Effect, Stream } from '@livestore/utils/effect'
7
7
  import { WebChannelBrowser } from '@livestore/utils/effect/browser'
8
8
  import * as Webmesh from '@livestore/webmesh'
9
9
 
10
- export const logDevtoolsUrl = ({
10
+ export const logDevtoolsUrl = Effect.fn('@livestore/adapter-web:client-session:devtools:logDevtoolsUrl')(function* ({
11
11
  schema,
12
12
  storeId,
13
13
  clientId,
@@ -17,37 +17,36 @@ export const logDevtoolsUrl = ({
17
17
  storeId: string
18
18
  clientId: string
19
19
  sessionId: string
20
- }) =>
21
- Effect.gen(function* () {
22
- if (isDevEnv()) {
23
- const devtoolsPath = globalThis.LIVESTORE_DEVTOOLS_PATH ?? `/_livestore`
24
- const devtoolsBaseUrl = `${location.origin}${devtoolsPath}`
20
+ }) {
21
+ if (isDevEnv() === true) {
22
+ const devtoolsPath = (globalThis as unknown as { LIVESTORE_DEVTOOLS_PATH?: string }).LIVESTORE_DEVTOOLS_PATH ?? `/_livestore`
23
+ const devtoolsBaseUrl = `${location.origin}${devtoolsPath}`
25
24
 
26
- // Check whether devtools are available and then log the URL
27
- const response = yield* Effect.promise(() => fetch(devtoolsBaseUrl))
28
- if (response.ok) {
29
- const text = yield* Effect.promise(() => response.text())
30
- if (text.includes('<meta name="livestore-devtools" content="true" />')) {
31
- const url = `${devtoolsBaseUrl}/web/${storeId}/${clientId}/${sessionId}/${schema.devtools.alias}`
32
- yield* Effect.log(`[@livestore/adapter-web] Devtools ready on ${url}`)
33
- }
25
+ // Check whether devtools are available and then log the URL
26
+ const response = yield* Effect.promise(() => fetch(devtoolsBaseUrl))
27
+ if (response.ok === true) {
28
+ const text = yield* Effect.promise(() => response.text())
29
+ if (text.includes('<meta name="livestore-devtools" content="true" />') === true) {
30
+ const url = `${devtoolsBaseUrl}/web/${storeId}/${clientId}/${sessionId}/${schema.devtools.alias}`
31
+ yield* Effect.log(`[@livestore/adapter-web] Devtools ready on ${url}`)
32
+ }
34
33
 
35
- // Check for DevTools Chrome extension presence via iframe container the extension injects
36
- const hasExt = document.querySelector('[id^="livestore-devtools-iframe-"]') !== null
37
- if (!hasExt) {
38
- const g = globalThis as { __livestoreDevtoolsChromeNoticeShown?: boolean }
39
- if (g.__livestoreDevtoolsChromeNoticeShown !== true) {
40
- g.__livestoreDevtoolsChromeNoticeShown = true
34
+ // Check for DevTools Chrome extension presence via iframe container the extension injects
35
+ const hasExt = document.querySelector('[id^="livestore-devtools-iframe-"]') !== null
36
+ if (hasExt === false) {
37
+ const g = globalThis as { __livestoreDevtoolsChromeNoticeShown?: boolean }
38
+ if (g.__livestoreDevtoolsChromeNoticeShown !== true) {
39
+ g.__livestoreDevtoolsChromeNoticeShown = true
41
40
 
42
- const urlToLog = `https://github.com/livestorejs/livestore/releases/download/v${liveStoreVersion}/livestore-devtools-chrome-${liveStoreVersion}.zip`
43
- yield* Effect.log(
44
- `[@livestore/adapter-web] LiveStore DevTools Chrome extension not detected. Install v${liveStoreVersion}: ${urlToLog}`,
45
- )
46
- }
41
+ const urlToLog = `https://github.com/livestorejs/livestore/releases/download/v${liveStoreVersion}/livestore-devtools-chrome-${liveStoreVersion}.zip`
42
+ yield* Effect.log(
43
+ `[@livestore/adapter-web] LiveStore DevTools Chrome extension not detected. Install v${liveStoreVersion}: ${urlToLog}`,
44
+ )
47
45
  }
48
46
  }
49
47
  }
50
- }).pipe(Effect.withSpan('@livestore/adapter-web:client-session:devtools:logDevtoolsUrl'))
48
+ }
49
+ })
51
50
 
52
51
  export const connectWebmeshNodeClientSession = Effect.fn(function* ({
53
52
  webmeshNode,
@@ -62,7 +61,7 @@ export const connectWebmeshNodeClientSession = Effect.fn(function* ({
62
61
  devtoolsEnabled: boolean
63
62
  schema: LiveStoreSchema
64
63
  }) {
65
- if (devtoolsEnabled) {
64
+ if (devtoolsEnabled === true) {
66
65
  const { clientId, sessionId, storeId } = sessionInfo
67
66
  yield* logDevtoolsUrl({ clientId, sessionId, schema, storeId })
68
67