@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/client-session/index.d.ts +23 -0
- package/dist/client-session/index.d.ts.map +1 -0
- package/dist/client-session/index.js +140 -0
- package/dist/client-session/index.js.map +1 -0
- package/dist/devtools/devtools-server.d.ts +16 -0
- package/dist/devtools/devtools-server.d.ts.map +1 -0
- package/dist/devtools/devtools-server.js +58 -0
- package/dist/devtools/devtools-server.js.map +1 -0
- package/dist/devtools/mod.d.ts +3 -0
- package/dist/devtools/mod.d.ts.map +1 -0
- package/dist/devtools/mod.js +2 -0
- package/dist/devtools/mod.js.map +1 -0
- package/dist/devtools/vite-dev-server.d.ts +7 -0
- package/dist/devtools/vite-dev-server.d.ts.map +1 -0
- package/dist/devtools/vite-dev-server.js +107 -0
- package/dist/devtools/vite-dev-server.js.map +1 -0
- package/dist/in-memory/index.d.ts +11 -0
- package/dist/in-memory/index.d.ts.map +1 -0
- package/dist/in-memory/index.js +71 -0
- package/dist/in-memory/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/leader-thread-lazy.d.ts +2 -0
- package/dist/leader-thread-lazy.d.ts.map +1 -0
- package/dist/leader-thread-lazy.js +10 -0
- package/dist/leader-thread-lazy.js.map +1 -0
- package/dist/make-leader-worker.d.ts +20 -0
- package/dist/make-leader-worker.d.ts.map +1 -0
- package/dist/make-leader-worker.js +151 -0
- package/dist/make-leader-worker.js.map +1 -0
- package/dist/shutdown-channel.d.ts +6 -0
- package/dist/shutdown-channel.d.ts.map +1 -0
- package/dist/shutdown-channel.js +7 -0
- package/dist/shutdown-channel.js.map +1 -0
- package/dist/thread-polyfill.d.ts +2 -0
- package/dist/thread-polyfill.d.ts.map +1 -0
- package/dist/thread-polyfill.js +3 -0
- package/dist/thread-polyfill.js.map +1 -0
- package/dist/webchannel.d.ts +6 -0
- package/dist/webchannel.d.ts.map +1 -0
- package/dist/webchannel.js +33 -0
- package/dist/webchannel.js.map +1 -0
- package/dist/worker-schema.d.ts +196 -0
- package/dist/worker-schema.d.ts.map +1 -0
- package/dist/worker-schema.js +161 -0
- package/dist/worker-schema.js.map +1 -0
- package/package.json +54 -0
- package/rollup.config.mjs +24 -0
- package/src/client-session/index.ts +295 -0
- package/src/devtools/devtools-server.ts +88 -0
- package/src/devtools/mod.ts +2 -0
- package/src/devtools/types.d.ts +33 -0
- package/src/devtools/vite-dev-server.ts +122 -0
- package/src/in-memory/index.ts +133 -0
- package/src/index.ts +2 -0
- package/src/leader-thread-lazy.ts +9 -0
- package/src/make-leader-worker.ts +285 -0
- package/src/shutdown-channel.ts +9 -0
- package/src/thread-polyfill.ts +1 -0
- package/src/webchannel.ts +54 -0
- package/src/worker-schema.ts +175 -0
- 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,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
|
+
})
|