@livestore/adapter-node 0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f

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 (65) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/client-session/index.d.ts +23 -0
  3. package/dist/client-session/index.d.ts.map +1 -0
  4. package/dist/client-session/index.js +140 -0
  5. package/dist/client-session/index.js.map +1 -0
  6. package/dist/devtools/devtools-server.d.ts +16 -0
  7. package/dist/devtools/devtools-server.d.ts.map +1 -0
  8. package/dist/devtools/devtools-server.js +58 -0
  9. package/dist/devtools/devtools-server.js.map +1 -0
  10. package/dist/devtools/mod.d.ts +3 -0
  11. package/dist/devtools/mod.d.ts.map +1 -0
  12. package/dist/devtools/mod.js +2 -0
  13. package/dist/devtools/mod.js.map +1 -0
  14. package/dist/devtools/vite-dev-server.d.ts +7 -0
  15. package/dist/devtools/vite-dev-server.d.ts.map +1 -0
  16. package/dist/devtools/vite-dev-server.js +107 -0
  17. package/dist/devtools/vite-dev-server.js.map +1 -0
  18. package/dist/in-memory/index.d.ts +11 -0
  19. package/dist/in-memory/index.d.ts.map +1 -0
  20. package/dist/in-memory/index.js +71 -0
  21. package/dist/in-memory/index.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/leader-thread-lazy.d.ts +2 -0
  27. package/dist/leader-thread-lazy.d.ts.map +1 -0
  28. package/dist/leader-thread-lazy.js +10 -0
  29. package/dist/leader-thread-lazy.js.map +1 -0
  30. package/dist/make-leader-worker.d.ts +20 -0
  31. package/dist/make-leader-worker.d.ts.map +1 -0
  32. package/dist/make-leader-worker.js +151 -0
  33. package/dist/make-leader-worker.js.map +1 -0
  34. package/dist/shutdown-channel.d.ts +6 -0
  35. package/dist/shutdown-channel.d.ts.map +1 -0
  36. package/dist/shutdown-channel.js +7 -0
  37. package/dist/shutdown-channel.js.map +1 -0
  38. package/dist/thread-polyfill.d.ts +2 -0
  39. package/dist/thread-polyfill.d.ts.map +1 -0
  40. package/dist/thread-polyfill.js +3 -0
  41. package/dist/thread-polyfill.js.map +1 -0
  42. package/dist/webchannel.d.ts +6 -0
  43. package/dist/webchannel.d.ts.map +1 -0
  44. package/dist/webchannel.js +33 -0
  45. package/dist/webchannel.js.map +1 -0
  46. package/dist/worker-schema.d.ts +196 -0
  47. package/dist/worker-schema.d.ts.map +1 -0
  48. package/dist/worker-schema.js +161 -0
  49. package/dist/worker-schema.js.map +1 -0
  50. package/package.json +54 -0
  51. package/rollup.config.mjs +24 -0
  52. package/src/client-session/index.ts +295 -0
  53. package/src/devtools/devtools-server.ts +88 -0
  54. package/src/devtools/mod.ts +2 -0
  55. package/src/devtools/types.d.ts +33 -0
  56. package/src/devtools/vite-dev-server.ts +122 -0
  57. package/src/in-memory/index.ts +133 -0
  58. package/src/index.ts +2 -0
  59. package/src/leader-thread-lazy.ts +9 -0
  60. package/src/make-leader-worker.ts +285 -0
  61. package/src/shutdown-channel.ts +9 -0
  62. package/src/thread-polyfill.ts +1 -0
  63. package/src/webchannel.ts +54 -0
  64. package/src/worker-schema.ts +175 -0
  65. package/tsconfig.json +17 -0
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@livestore/adapter-node",
3
+ "version": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./devtools": {
12
+ "types": "./dist/devtools/mod.d.ts",
13
+ "default": "./dist/devtools/mod.js"
14
+ },
15
+ "./worker": {
16
+ "types": "./dist/make-leader-worker.d.ts",
17
+ "default": "./dist/make-leader-worker.js"
18
+ }
19
+ },
20
+ "types": "./dist/index.d.ts",
21
+ "typesVersions": {
22
+ "*": {
23
+ "./devtools": [
24
+ "./dist/devtools/mod.d.ts"
25
+ ]
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "@livestore/devtools-vite": "0.3.0-dev.15",
30
+ "@opentelemetry/api": "1.9.0",
31
+ "@opentelemetry/otlp-exporter-base": "0.57.2",
32
+ "vite": "6.1.0",
33
+ "ws": "8.18.0",
34
+ "@livestore/adapter-web": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f",
35
+ "@livestore/common": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f",
36
+ "@livestore/devtools-node-common": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f",
37
+ "@livestore/sqlite-wasm": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f",
38
+ "@livestore/utils": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f",
39
+ "@livestore/webmesh": "0.0.0-snapshot-a953343ad2d7468c6573bcb5e26f0eab4302078f"
40
+ },
41
+ "devDependencies": {
42
+ "@rollup/plugin-commonjs": "^28.0.1",
43
+ "@rollup/plugin-node-resolve": "^16.0.0",
44
+ "@rollup/plugin-terser": "^0.4.4",
45
+ "@types/ws": "^8.5.12",
46
+ "rollup": "^4.27.4"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "test": "echo No tests yet"
53
+ }
54
+ }
@@ -0,0 +1,24 @@
1
+ import commonjs from '@rollup/plugin-commonjs'
2
+ import { nodeResolve } from '@rollup/plugin-node-resolve'
3
+ import terser from '@rollup/plugin-terser'
4
+
5
+ export default {
6
+ input: 'dist/leader-thread.js',
7
+ output: {
8
+ file: 'dist/leader-thread.bundle.js',
9
+ // dir: 'dist/leader-thread-bundle',
10
+ format: 'esm',
11
+ // inlineDynamicImports: true,
12
+ },
13
+ external: ['@livestore/sqlite-wasm', '@opentelemetry/otlp-exporter-base'],
14
+ plugins: [
15
+ nodeResolve({
16
+ // esnext is needed for @opentelemetry/* packages
17
+ mainFields: ['esnext', 'module', 'main'],
18
+ }),
19
+ commonjs(),
20
+ terser(),
21
+ ],
22
+ // Needed for @opentelemetry/* packages
23
+ // inlineDynamicImports: true,
24
+ }
@@ -0,0 +1,295 @@
1
+ import { hostname } from 'node:os'
2
+ import * as WT from 'node:worker_threads'
3
+
4
+ import type {
5
+ Adapter,
6
+ ClientSession,
7
+ ClientSessionLeaderThreadProxy,
8
+ IntentionalShutdownCause,
9
+ LockStatus,
10
+ NetworkStatus,
11
+ } from '@livestore/common'
12
+ import { Devtools, UnexpectedError } from '@livestore/common'
13
+ import type { MutationEvent } from '@livestore/common/schema'
14
+ import { makeNodeDevtoolsChannel } from '@livestore/devtools-node-common/web-channel'
15
+ import { loadSqlite3Wasm } from '@livestore/sqlite-wasm/load-wasm'
16
+ import { sqliteDbFactory } from '@livestore/sqlite-wasm/node'
17
+ import type { Cause } from '@livestore/utils/effect'
18
+ import {
19
+ BucketQueue,
20
+ Effect,
21
+ Fiber,
22
+ ParseResult,
23
+ Schema,
24
+ Stream,
25
+ SubscriptionRef,
26
+ Worker,
27
+ WorkerError,
28
+ } from '@livestore/utils/effect'
29
+ import { PlatformNode } from '@livestore/utils/node'
30
+
31
+ import * as WorkerSchema from '../worker-schema.js'
32
+
33
+ export interface NodeAdapterOptions {
34
+ /**
35
+ * Example: `new URL('./livestore.worker.js', import.meta.url)`
36
+ */
37
+ workerUrl: URL
38
+ /** Needed for the worker and the devtools */
39
+ schemaPath: string
40
+ /** Where to store the database files */
41
+ baseDirectory?: string
42
+ /** The default is the hostname of the current machine */
43
+ clientId?: string
44
+ devtools?: {
45
+ /**
46
+ * Where to run the devtools server (via Vite)
47
+ *
48
+ * @default 4242
49
+ */
50
+ port: number
51
+ }
52
+ }
53
+
54
+ export const makeNodeAdapter = ({
55
+ workerUrl,
56
+ schemaPath,
57
+ baseDirectory,
58
+ devtools: devtoolsOptions = { port: 4242 },
59
+ clientId = hostname(),
60
+ }: NodeAdapterOptions): Adapter =>
61
+ (({ storeId, devtoolsEnabled, shutdown, connectDevtoolsToStore }) =>
62
+ Effect.gen(function* () {
63
+ // TODO make this dynamic and actually support multiple sessions
64
+ const sessionId = 'static'
65
+
66
+ const sqlite3 = yield* Effect.promise(() => loadSqlite3Wasm())
67
+ const makeSqliteDb = yield* sqliteDbFactory({ sqlite3 })
68
+
69
+ // TODO consider bringing back happy-path initialisation boost
70
+ // const fileData = yield* fs.readFile(dbFilePath).pipe(Effect.either)
71
+ // if (fileData._tag === 'Right') {
72
+ // syncInMemoryDb.import(fileData.right)
73
+ // } else {
74
+ // yield* Effect.logWarning('Failed to load database file', fileData.left)
75
+ // }
76
+
77
+ const syncInMemoryDb = yield* makeSqliteDb({ _tag: 'in-memory' }).pipe(Effect.orDie)
78
+
79
+ // TODO actually implement this multi-session support
80
+ const lockStatus = yield* SubscriptionRef.make<LockStatus>('has-lock')
81
+
82
+ const { leaderThread, initialSnapshot } = yield* makeLeaderThread({
83
+ shutdown,
84
+ storeId,
85
+ clientId,
86
+ sessionId,
87
+ workerUrl,
88
+ baseDirectory,
89
+ devtoolsEnabled,
90
+ devtoolsOptions,
91
+ schemaPath,
92
+ })
93
+
94
+ syncInMemoryDb.import(initialSnapshot)
95
+
96
+ if (devtoolsEnabled) {
97
+ yield* Effect.gen(function* () {
98
+ const storeDevtoolsChannel = yield* makeNodeDevtoolsChannel({
99
+ nodeName: `client-session-${storeId}-${clientId}-${sessionId}`,
100
+ target: `devtools`,
101
+ url: `ws://localhost:${devtoolsOptions.port}`,
102
+ schema: {
103
+ listen: Devtools.ClientSession.MessageToApp,
104
+ send: Devtools.ClientSession.MessageFromApp,
105
+ },
106
+ })
107
+
108
+ yield* connectDevtoolsToStore(storeDevtoolsChannel)
109
+ }).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
110
+ }
111
+
112
+ const devtools: ClientSession['devtools'] = devtoolsEnabled
113
+ ? { enabled: true, pullLatch: yield* Effect.makeLatch(true), pushLatch: yield* Effect.makeLatch(true) }
114
+ : { enabled: false }
115
+
116
+ const clientSession = {
117
+ sqliteDb: syncInMemoryDb,
118
+ leaderThread,
119
+ devtools,
120
+ lockStatus,
121
+ clientId,
122
+ sessionId,
123
+ shutdown,
124
+ } satisfies ClientSession
125
+
126
+ return clientSession
127
+ }).pipe(
128
+ Effect.withSpan('@livestore/adapter-node:adapter'),
129
+ Effect.parallelFinalizers,
130
+ Effect.provide(PlatformNode.NodeFileSystem.layer),
131
+ )) satisfies Adapter
132
+
133
+ const makeLeaderThread = ({
134
+ shutdown,
135
+ storeId,
136
+ clientId,
137
+ sessionId,
138
+ workerUrl,
139
+ baseDirectory,
140
+ devtoolsEnabled,
141
+ devtoolsOptions,
142
+ schemaPath,
143
+ }: {
144
+ shutdown: (cause: Cause.Cause<UnexpectedError | IntentionalShutdownCause>) => Effect.Effect<void>
145
+ storeId: string
146
+ clientId: string
147
+ sessionId: string
148
+ workerUrl: URL
149
+ baseDirectory: string | undefined
150
+ devtoolsEnabled: boolean
151
+ devtoolsOptions: { port: number }
152
+ schemaPath: string
153
+ }) =>
154
+ Effect.gen(function* () {
155
+ const nodeWorker = new WT.Worker(workerUrl, {
156
+ execArgv: process.env.DEBUG_WORKER ? ['--inspect --enable-source-maps'] : ['--enable-source-maps'],
157
+ argv: [Schema.encodeSync(WorkerSchema.WorkerArgv)({ storeId, clientId, sessionId })],
158
+ })
159
+
160
+ const leaderThreadFiber = yield* Worker.makePoolSerialized<typeof WorkerSchema.LeaderWorkerInner.Request.Type>({
161
+ size: 1,
162
+ concurrency: 100,
163
+ initialMessage: () =>
164
+ new WorkerSchema.LeaderWorkerInner.InitialMessage({
165
+ storeId,
166
+ clientId,
167
+ baseDirectory,
168
+ devtools: { enabled: devtoolsEnabled, port: devtoolsOptions.port },
169
+ schemaPath,
170
+ }),
171
+ }).pipe(
172
+ Effect.provide(PlatformNode.NodeWorker.layer(() => nodeWorker)),
173
+ UnexpectedError.mapToUnexpectedError,
174
+ Effect.tapErrorCause(shutdown),
175
+ Effect.withSpan('@livestore/adapter-node:adapter:setupLeaderThread'),
176
+ Effect.tapCauseLogPretty,
177
+ Effect.forkScoped,
178
+ )
179
+
180
+ yield* Effect.addFinalizer(() =>
181
+ Effect.gen(function* () {
182
+ // We first try to gracefully shutdown the leader worker and then forcefully terminate it
183
+ yield* Effect.raceFirst(
184
+ runInWorker(new WorkerSchema.LeaderWorkerInner.Shutdown()).pipe(Effect.andThen(() => nodeWorker.terminate())),
185
+
186
+ Effect.sync(() => {
187
+ console.warn('[@livestore/adapter-node:adapter] Worker did not gracefully shutdown in time, terminating it')
188
+ nodeWorker.terminate()
189
+ }).pipe(Effect.delay(1000)),
190
+ ).pipe(Effect.exit) // The disconnect is to prevent the interrupt to bubble out
191
+ }).pipe(Effect.withSpan('@livestore/adapter-node:adapter:shutdown'), Effect.tapCauseLogPretty, Effect.orDie),
192
+ )
193
+
194
+ const runInWorker = <TReq extends typeof WorkerSchema.LeaderWorkerInner.Request.Type>(
195
+ req: TReq,
196
+ ): TReq extends Schema.WithResult<infer A, infer _I, infer _E, infer _EI, infer R>
197
+ ? Effect.Effect<A, UnexpectedError, R>
198
+ : never =>
199
+ Fiber.join(leaderThreadFiber).pipe(
200
+ Effect.flatMap((worker) => worker.executeEffect(req) as any),
201
+ Effect.logWarnIfTakesLongerThan({
202
+ label: `@livestore/adapter-node:client-session:runInWorker:${req._tag}`,
203
+ duration: 2000,
204
+ }),
205
+ Effect.withSpan(`@livestore/adapter-node:client-session:runInWorker:${req._tag}`),
206
+ Effect.mapError((cause) =>
207
+ Schema.is(UnexpectedError)(cause)
208
+ ? cause
209
+ : ParseResult.isParseError(cause) || Schema.is(WorkerError.WorkerError)(cause)
210
+ ? new UnexpectedError({ cause })
211
+ : cause,
212
+ ),
213
+ Effect.catchAllDefect((cause) => new UnexpectedError({ cause })),
214
+ ) as any
215
+
216
+ const runInWorkerStream = <TReq extends typeof WorkerSchema.LeaderWorkerInner.Request.Type>(
217
+ req: TReq,
218
+ ): TReq extends Schema.WithResult<infer A, infer _I, infer _E, infer _EI, infer R>
219
+ ? Stream.Stream<A, UnexpectedError, R>
220
+ : never =>
221
+ Effect.gen(function* () {
222
+ const sharedWorker = yield* Fiber.join(leaderThreadFiber)
223
+ return sharedWorker.execute(req as any).pipe(
224
+ Stream.mapError((cause) =>
225
+ Schema.is(UnexpectedError)(cause)
226
+ ? cause
227
+ : ParseResult.isParseError(cause) || Schema.is(WorkerError.WorkerError)(cause)
228
+ ? new UnexpectedError({ cause })
229
+ : cause,
230
+ ),
231
+ Stream.withSpan(`@livestore/adapter-node:client-session:runInWorkerStream:${req._tag}`),
232
+ )
233
+ }).pipe(Stream.unwrap) as any
234
+
235
+ const initialLeaderHead = yield* runInWorker(new WorkerSchema.LeaderWorkerInner.GetLeaderHead())
236
+
237
+ const networkStatus = yield* SubscriptionRef.make<NetworkStatus>({
238
+ isConnected: true,
239
+ timestampMs: Date.now(),
240
+ latchClosed: false,
241
+ })
242
+
243
+ const pushQueue = yield* BucketQueue.make<MutationEvent.AnyEncoded>()
244
+
245
+ yield* Effect.gen(function* () {
246
+ const batch = yield* BucketQueue.takeBetween(pushQueue, 1, 100)
247
+ yield* runInWorker(new WorkerSchema.LeaderWorkerInner.PushToLeader({ batch })).pipe(
248
+ Effect.withSpan('@livestore/adapter-node:client-session:pushToLeader', {
249
+ attributes: { batchSize: batch.length },
250
+ }),
251
+ // We can ignore the error here because the ClientSessionSyncProcessor will retry after rebasing
252
+ Effect.ignoreLogged,
253
+ )
254
+ }).pipe(Effect.forever, Effect.interruptible, Effect.tapCauseLogPretty, Effect.forkScoped)
255
+
256
+ const bootResult = yield* runInWorker(new WorkerSchema.LeaderWorkerInner.GetRecreateSnapshot()).pipe(
257
+ Effect.timeout(10_000),
258
+ UnexpectedError.mapToUnexpectedError,
259
+ Effect.withSpan('@livestore/adapter-node:client-session:export'),
260
+ )
261
+
262
+ const leaderThread = {
263
+ networkStatus,
264
+ mutations: {
265
+ pull: runInWorkerStream(new WorkerSchema.LeaderWorkerInner.PullStream({ cursor: initialLeaderHead })).pipe(
266
+ Stream.orDie,
267
+ ),
268
+ // NOTE instead of sending the worker message right away, we're batching the events in order to
269
+ // - maintain a consistent order of events
270
+ // - improve efficiency by reducing the number of messages
271
+ push: (batch) => BucketQueue.offerAll(pushQueue, batch),
272
+ },
273
+ initialState: {
274
+ leaderHead: initialLeaderHead,
275
+ migrationsReport: bootResult.migrationsReport,
276
+ },
277
+ export: runInWorker(new WorkerSchema.LeaderWorkerInner.Export()).pipe(
278
+ Effect.timeout(10_000),
279
+ UnexpectedError.mapToUnexpectedError,
280
+ Effect.withSpan('@livestore/adapter-node:client-session:export'),
281
+ ),
282
+ getMutationLogData: Effect.dieMessage('Not implemented'),
283
+ getSyncState: runInWorker(new WorkerSchema.LeaderWorkerInner.GetLeaderSyncState()).pipe(
284
+ UnexpectedError.mapToUnexpectedError,
285
+ Effect.withSpan('@livestore/adapter-node:client-session:getLeaderSyncState'),
286
+ ),
287
+ sendDevtoolsMessage: (message) =>
288
+ runInWorker(new WorkerSchema.LeaderWorkerInner.ExtraDevtoolsMessage({ message })).pipe(
289
+ UnexpectedError.mapToUnexpectedError,
290
+ Effect.withSpan('@livestore/adapter-node:client-session:devtoolsMessageForLeader'),
291
+ ),
292
+ } satisfies ClientSessionLeaderThreadProxy
293
+
294
+ return { leaderThread, initialSnapshot: bootResult.snapshot }
295
+ })
@@ -0,0 +1,88 @@
1
+ import http from 'node:http'
2
+ import path from 'node:path'
3
+
4
+ import { UnexpectedError } from '@livestore/common'
5
+ import type { Scope } from '@livestore/utils/effect'
6
+ import { Effect } from '@livestore/utils/effect'
7
+ import { makeWebSocketServer } from '@livestore/webmesh/websocket-server'
8
+
9
+ import { makeViteServer } from './vite-dev-server.js'
10
+
11
+ /**
12
+ * Starts a devtools HTTP/WS server which serves ...
13
+ * - the Devtools UI via Vite
14
+ * - the Devtools Protocol via WebSocket Webmesh
15
+ */
16
+ export const startDevtoolsServer = ({
17
+ schemaPath,
18
+ storeId,
19
+ clientId,
20
+ sessionId,
21
+ port,
22
+ }: {
23
+ schemaPath: string
24
+ storeId: string
25
+ clientId: string
26
+ sessionId: string
27
+ port: number
28
+ }): Effect.Effect<void, UnexpectedError, Scope.Scope> =>
29
+ Effect.gen(function* () {
30
+ const httpServer = http.createServer()
31
+ const webSocketServer = yield* makeWebSocketServer({ relayNodeName: 'ws' })
32
+
33
+ yield* Effect.addFinalizer(() => Effect.sync(() => httpServer.close()))
34
+
35
+ // Handle upgrade manually
36
+ httpServer.on('upgrade', (request, socket, head) => {
37
+ webSocketServer.handleUpgrade(request, socket, head, (ws) => {
38
+ webSocketServer.emit('connection', ws, request)
39
+ })
40
+ })
41
+
42
+ const startServer = (port: number) =>
43
+ Effect.async<void, UnexpectedError>((cb) => {
44
+ httpServer.on('error', (err: any) => {
45
+ cb(UnexpectedError.make({ cause: err }))
46
+ })
47
+
48
+ httpServer.listen(port, () => {
49
+ cb(Effect.succeed(undefined))
50
+ })
51
+ })
52
+
53
+ yield* startServer(port)
54
+
55
+ yield* Effect.logDebug(
56
+ `[@livestore/adapter-node:devtools] LiveStore devtools are available at http://localhost:${port}/livestore-devtools`,
57
+ )
58
+
59
+ const viteServer = yield* makeViteServer({
60
+ mode: { _tag: 'node', storeId, clientId, sessionId, url: `ws://localhost:${port}` },
61
+ schemaPath: path.resolve(process.cwd(), schemaPath),
62
+ viteConfig: (viteConfig) => {
63
+ viteConfig.server ??= {}
64
+ viteConfig.server.fs ??= {}
65
+
66
+ // TODO move this into the example code
67
+ // Point to Overtone monorepo root
68
+ viteConfig.server.fs.allow ??= []
69
+ viteConfig.server.fs.allow.push(process.env.WORKSPACE_ROOT + '/../..')
70
+
71
+ viteConfig.optimizeDeps ??= {}
72
+ viteConfig.optimizeDeps.force = true
73
+
74
+ return viteConfig
75
+ },
76
+ })
77
+
78
+ yield* Effect.addFinalizer(() => Effect.promise(() => viteServer.close()))
79
+
80
+ httpServer.on('request', (req, res) => {
81
+ if (req.url === '/' || req.url === '') {
82
+ res.writeHead(302, { Location: '/livestore-devtools' })
83
+ res.end()
84
+ } else if (req.url?.startsWith('/livestore-devtools')) {
85
+ return viteServer.middlewares(req, res as any)
86
+ }
87
+ })
88
+ }).pipe(Effect.withSpan('@livestore/adapter-node:devtools:startDevtoolsServer'))
@@ -0,0 +1,2 @@
1
+ export { makeViteServer } from './vite-dev-server.js'
2
+ export type { Options } from './types.js'
@@ -0,0 +1,33 @@
1
+ import type * as http from 'node:http'
2
+
3
+ import type * as Vite from 'vite'
4
+
5
+ export type Middleware = (req: http.IncomingMessage, res: http.ServerResponse, next: () => void) => void
6
+
7
+ export type Options = {
8
+ viteConfig?: (config: Vite.UserConfig) => Vite.UserConfig
9
+ /**
10
+ * Path to the file exporting the LiveStore schema as `export const schema = ...`
11
+ * File path must be relative to the project root and will be imported via Vite.
12
+ *
13
+ * Example: `./src/schema.ts`
14
+ */
15
+ schemaPath: string
16
+ // TODO consolidate with `Mode` in `@livestore/devtools-react/devtools-api.ts`
17
+ /**
18
+ * The mode of the devtools server.
19
+ *
20
+ * @default 'node'
21
+ */
22
+ mode:
23
+ | {
24
+ _tag: 'node'
25
+ storeId: string
26
+ clientId: string
27
+ sessionId: string
28
+ url: string
29
+ }
30
+ | {
31
+ _tag: 'expo'
32
+ }
33
+ }
@@ -0,0 +1,122 @@
1
+ import * as http from 'node:http'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { UnexpectedError } from '@livestore/common'
6
+ import { Effect } from '@livestore/utils/effect'
7
+ import * as Vite from 'vite'
8
+
9
+ import type { Options } from './types.js'
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+
13
+ export const makeViteServer = (options: Options): Effect.Effect<Vite.ViteDevServer, UnexpectedError> =>
14
+ Effect.gen(function* () {
15
+ const hmrPort = yield* getFreePort
16
+
17
+ const cwd = process.cwd()
18
+
19
+ const defaultViteConfig = Vite.defineConfig({
20
+ server: {
21
+ middlewareMode: true,
22
+ hmr: {
23
+ port: hmrPort,
24
+ },
25
+ fs: {
26
+ // Adds `node_modules` so we can import `@livestore/wa-sqlite` for WASM to work
27
+ allow: [path.resolve(__dirname, '..', '..')],
28
+ },
29
+ },
30
+ resolve: {
31
+ alias: {
32
+ '@schema': path.resolve(cwd, options.schemaPath),
33
+ },
34
+ },
35
+ appType: 'spa',
36
+ optimizeDeps: {
37
+ // TODO remove once fixed https://github.com/vitejs/vite/issues/8427
38
+ exclude: ['@livestore/wa-sqlite'],
39
+ },
40
+ root: __dirname,
41
+ base: '/livestore-devtools/',
42
+ plugins: [virtualHtmlPlugin(options.mode)],
43
+ clearScreen: false,
44
+ logLevel: 'silent',
45
+ })
46
+
47
+ const viteConfig = options.viteConfig?.(defaultViteConfig) ?? defaultViteConfig
48
+
49
+ const viteServer = yield* Effect.promise(() => Vite.createServer(viteConfig)).pipe(
50
+ UnexpectedError.mapToUnexpectedError,
51
+ )
52
+
53
+ return viteServer
54
+ }).pipe(Effect.withSpan('@livestore/adapter-node:devtools:makeViteServer'))
55
+
56
+ // TODO unify this with `@livestore/devtools-vite/plugin.ts`
57
+ const virtualHtmlPlugin = (mode: Options['mode']): Vite.Plugin => ({
58
+ name: 'virtual-html',
59
+ configureServer: (server) => {
60
+ return () => {
61
+ server.middlewares.use(async (req, res, next) => {
62
+ if (req.url === '/' || req.url === '' || req.url === '/index.html') {
63
+ const html = `
64
+ <!doctype html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="UTF-8" />
68
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
69
+ <meta name="livestore-devtools" content="true" />
70
+ <title>LiveStore Devtools</title>
71
+ </head>
72
+ <body>
73
+ <div id="root"></div>
74
+ <script type="module">
75
+ import '@livestore/devtools-react/index.css'
76
+ import { mountDevtools } from '@livestore/devtools-react'
77
+ import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
78
+ import { schema } from '@schema'
79
+
80
+ mountDevtools({
81
+ schema,
82
+ rootEl: document.getElementById('root'),
83
+ sharedWorker,
84
+ mode: ${JSON.stringify(mode)},
85
+ license: ${JSON.stringify(process.env.LSD_LICENSE)},
86
+ })
87
+ </script>
88
+ </body>
89
+ </html>
90
+ `
91
+ const transformedHtml = await server.transformIndexHtml(req.url, html)
92
+ res.statusCode = 200
93
+ res.setHeader('Content-Type', 'text/html')
94
+ res.end(transformedHtml)
95
+ } else {
96
+ next()
97
+ }
98
+ })
99
+ }
100
+ },
101
+ })
102
+
103
+ export const getFreePort = Effect.async<number, UnexpectedError>((cb) => {
104
+ const server = http.createServer()
105
+
106
+ // Listen on port 0 to get an available port
107
+ server.listen(0, () => {
108
+ const address = server.address()
109
+
110
+ if (address && typeof address === 'object') {
111
+ const port = address.port
112
+ server.close(() => cb(Effect.succeed(port)))
113
+ } else {
114
+ server.close(() => cb(UnexpectedError.make({ cause: 'Failed to get a free port' })))
115
+ }
116
+ })
117
+
118
+ // Error handling in case the server encounters an error
119
+ server.on('error', (err) => {
120
+ server.close(() => cb(UnexpectedError.make({ cause: err })))
121
+ })
122
+ })