@livestore/utils-dev 0.4.0-dev.9 → 0.4.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.
Files changed (59) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/node/DockerComposeService/DockerComposeService.d.ts +10 -5
  3. package/dist/node/DockerComposeService/DockerComposeService.d.ts.map +1 -1
  4. package/dist/node/DockerComposeService/DockerComposeService.js +47 -42
  5. package/dist/node/DockerComposeService/DockerComposeService.js.map +1 -1
  6. package/dist/node/DockerComposeService/DockerComposeService.test.js +2 -2
  7. package/dist/node/DockerComposeService/DockerComposeService.test.js.map +1 -1
  8. package/dist/node/FileLogger.d.ts.map +1 -1
  9. package/dist/node/FileLogger.js +3 -3
  10. package/dist/node/FileLogger.js.map +1 -1
  11. package/dist/node/cmd-log.d.ts +21 -0
  12. package/dist/node/cmd-log.d.ts.map +1 -0
  13. package/dist/node/cmd-log.js +47 -0
  14. package/dist/node/cmd-log.js.map +1 -0
  15. package/dist/node/cmd.d.ts +12 -4
  16. package/dist/node/cmd.d.ts.map +1 -1
  17. package/dist/node/cmd.js +207 -29
  18. package/dist/node/cmd.js.map +1 -1
  19. package/dist/node/cmd.test.d.ts +2 -0
  20. package/dist/node/cmd.test.d.ts.map +1 -0
  21. package/dist/node/cmd.test.js +103 -0
  22. package/dist/node/cmd.test.js.map +1 -0
  23. package/dist/node/mod.d.ts +3 -2
  24. package/dist/node/mod.d.ts.map +1 -1
  25. package/dist/node/mod.js +63 -18
  26. package/dist/node/mod.js.map +1 -1
  27. package/dist/node/workspace.d.ts +22 -0
  28. package/dist/node/workspace.d.ts.map +1 -0
  29. package/dist/node/workspace.js +26 -0
  30. package/dist/node/workspace.js.map +1 -0
  31. package/dist/node-vitest/Vitest.d.ts.map +1 -1
  32. package/dist/node-vitest/Vitest.js +11 -11
  33. package/dist/node-vitest/Vitest.js.map +1 -1
  34. package/dist/node-vitest/Vitest.test.d.ts +8 -0
  35. package/dist/node-vitest/Vitest.test.d.ts.map +1 -1
  36. package/dist/node-vitest/Vitest.test.js +11 -7
  37. package/dist/node-vitest/Vitest.test.js.map +1 -1
  38. package/dist/wrangler/WranglerDevServer.d.ts +20 -3
  39. package/dist/wrangler/WranglerDevServer.d.ts.map +1 -1
  40. package/dist/wrangler/WranglerDevServer.js +23 -10
  41. package/dist/wrangler/WranglerDevServer.js.map +1 -1
  42. package/dist/wrangler/WranglerDevServer.test.js +3 -3
  43. package/dist/wrangler/WranglerDevServer.test.js.map +1 -1
  44. package/package.json +74 -21
  45. package/src/node/DockerComposeService/DockerComposeService.test.ts +5 -2
  46. package/src/node/DockerComposeService/DockerComposeService.ts +105 -90
  47. package/src/node/DockerComposeService/test-fixtures/docker-compose.yml +1 -1
  48. package/src/node/FileLogger.ts +4 -3
  49. package/src/node/cmd-log.ts +84 -0
  50. package/src/node/cmd.test.ts +134 -0
  51. package/src/node/cmd.ts +380 -62
  52. package/src/node/mod.ts +69 -21
  53. package/src/node/workspace.ts +46 -0
  54. package/src/node-vitest/Vitest.test.ts +12 -7
  55. package/src/node-vitest/Vitest.ts +28 -24
  56. package/src/wrangler/WranglerDevServer.test.ts +5 -3
  57. package/src/wrangler/WranglerDevServer.ts +54 -13
  58. package/src/wrangler/fixtures/wrangler.toml +1 -1
  59. package/dist/.tsbuildinfo.json +0 -1
package/src/node/cmd.ts CHANGED
@@ -1,80 +1,132 @@
1
- import { isNotUndefined, shouldNeverHappen } from '@livestore/utils'
2
- import { Command, type CommandExecutor, Effect, identity, type PlatformError, Schema } from '@livestore/utils/effect'
1
+ import fs from 'node:fs'
2
+
3
+ import { isNotUndefined } from '@livestore/utils'
4
+ import {
5
+ Cause,
6
+ Command,
7
+ type CommandExecutor,
8
+ Effect,
9
+ Fiber,
10
+ FiberId,
11
+ FiberRefs,
12
+ HashMap,
13
+ identity,
14
+ List,
15
+ LogLevel,
16
+ type PlatformError,
17
+ Schema,
18
+ Stream,
19
+ } from '@livestore/utils/effect'
20
+
21
+ import { applyLoggingToCommand } from './cmd-log.ts'
22
+ import * as FileLogger from './FileLogger.ts'
23
+ import { CurrentWorkingDirectory } from './workspace.ts'
24
+
25
+ // Branded zero value so we can compare exit codes without touching internals.
26
+ const SUCCESS_EXIT_CODE: CommandExecutor.ExitCode = 0 as CommandExecutor.ExitCode
3
27
 
4
28
  export const cmd: (
5
29
  commandInput: string | (string | undefined)[],
6
- options?:
7
- | {
8
- cwd?: string
9
- stderr?: 'inherit' | 'pipe'
10
- stdout?: 'inherit' | 'pipe'
11
- shell?: boolean
12
- env?: Record<string, string | undefined>
13
- }
14
- | undefined,
15
- ) => Effect.Effect<CommandExecutor.ExitCode, PlatformError.PlatformError | CmdError, CommandExecutor.CommandExecutor> =
16
- Effect.fn('cmd')(function* (commandInput, options) {
17
- const cwd = options?.cwd ?? process.env.WORKSPACE_ROOT ?? shouldNeverHappen('WORKSPACE_ROOT is not set')
18
- const [command, ...args] = Array.isArray(commandInput)
19
- ? commandInput.filter(isNotUndefined)
20
- : commandInput.split(' ')
30
+ options?: {
31
+ stderr?: 'inherit' | 'pipe'
32
+ stdout?: 'inherit' | 'pipe'
33
+ shell?: boolean
34
+ env?: Record<string, string | undefined>
35
+ /**
36
+ * When provided, streams command output to terminal AND to a canonical log file (`${logDir}/dev.log`) in this directory.
37
+ * Also archives the previous run to `${logDir}/archive/dev-<ISO>.log` and keeps only the latest 50 archives.
38
+ */
39
+ logDir?: string
40
+ /** Optional basename for the canonical log file; defaults to 'dev.log' */
41
+ logFileName?: string
42
+ /** Optional number of archived logs to retain; defaults to 50 */
43
+ logRetention?: number
44
+ },
45
+ ) => Effect.Effect<
46
+ CommandExecutor.ExitCode,
47
+ PlatformError.PlatformError | CmdError,
48
+ CommandExecutor.CommandExecutor | CurrentWorkingDirectory
49
+ > = Effect.fn('cmd')(function* (commandInput, options) {
50
+ const cwd = yield* CurrentWorkingDirectory
21
51
 
22
- const debugEnvStr = Object.entries(options?.env ?? {})
23
- .map(([key, value]) => `${key}='${value}' `)
24
- .join('')
25
- const subshellStr = options?.shell ? ' (in subshell)' : ''
26
- const commandDebugStr = debugEnvStr + [command, ...args].join(' ')
52
+ const asArray = Array.isArray(commandInput)
53
+ const parts = asArray === true ? commandInput.filter(isNotUndefined) : undefined
54
+ const [command, ...args] = asArray === true ? (parts as string[]) : commandInput.split(' ')
27
55
 
28
- yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
29
- yield* Effect.annotateCurrentSpan({ 'span.label': commandDebugStr, cwd, command, args })
56
+ const debugEnvStr = Object.entries(options?.env ?? {})
57
+ .map(([key, value]) => `${key}='${String(value)}' `)
58
+ .join('')
30
59
 
31
- return yield* Command.make(command!, ...args).pipe(
32
- // TODO don't forward abort signal to the command
33
- Command.stdin('inherit'), // Forward stdin to the command
34
- // inherit = Stream stdout to process.stdout, pipe = Stream stdout to process.stderr
35
- Command.stdout(options?.stdout ?? 'inherit'),
36
- // inherit = Stream stderr to process.stderr, pipe = Stream stderr to process.stdout
37
- Command.stderr(options?.stderr ?? 'inherit'),
38
- Command.workingDirectory(cwd),
39
- options?.shell ? Command.runInShell(true) : identity,
40
- Command.env(options?.env ?? {}),
41
- Command.exitCode,
42
- Effect.tap((exitCode) =>
43
- exitCode === 0
44
- ? Effect.void
45
- : Effect.fail(
46
- CmdError.make({
47
- command: command!,
48
- args,
49
- cwd,
50
- env: options?.env ?? {},
51
- stderr: options?.stderr ?? 'inherit',
52
- }),
53
- ),
54
- ),
55
- )
60
+ const loggingOpts = {
61
+ ...(options?.logDir !== undefined ? { logDir: options.logDir } : {}),
62
+ ...(options?.logFileName !== undefined ? { logFileName: options.logFileName } : {}),
63
+ ...(options?.logRetention !== undefined ? { logRetention: options.logRetention } : {}),
64
+ } as const
65
+ const { input: finalInput, subshell: needsShell, logPath } = yield* applyLoggingToCommand(commandInput, loggingOpts)
66
+
67
+ const stdoutMode = options?.stdout ?? 'inherit'
68
+ const stderrMode = options?.stderr ?? 'inherit'
69
+ const useShell = (options?.shell === true ? true : false) || needsShell
70
+
71
+ const commandDebugStr = debugEnvStr + (Array.isArray(finalInput) === true ? finalInput.join(' ') : finalInput)
72
+ const subshellStr = useShell === true ? ' (in subshell)' : ''
73
+
74
+ yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
75
+ yield* Effect.annotateCurrentSpan({
76
+ 'span.label': commandDebugStr,
77
+ cwd,
78
+ command,
79
+ args,
80
+ logDir: options?.logDir,
56
81
  })
57
82
 
83
+ const baseArgs = {
84
+ commandInput: finalInput,
85
+ cwd,
86
+ env: options?.env ?? {},
87
+ stdoutMode,
88
+ stderrMode,
89
+ useShell,
90
+ } as const
91
+
92
+ const exitCode = yield* isNotUndefined(logPath) === true
93
+ ? Effect.gen(function* () {
94
+ yield* Effect.sync(() => console.log(`Logging output to ${logPath}`))
95
+ return yield* runWithLogging({ ...baseArgs, logPath, threadName: commandDebugStr })
96
+ })
97
+ : runWithoutLogging(baseArgs)
98
+
99
+ if (exitCode !== SUCCESS_EXIT_CODE) {
100
+ return yield* CmdError.make({
101
+ command: command!,
102
+ args,
103
+ cwd,
104
+ env: options?.env ?? {},
105
+ stderr: stderrMode,
106
+ })
107
+ }
108
+
109
+ return exitCode
110
+ })
111
+
58
112
  export const cmdText: (
59
113
  commandInput: string | (string | undefined)[],
60
114
  options?: {
61
- cwd?: string
62
115
  stderr?: 'inherit' | 'pipe'
63
116
  runInShell?: boolean
64
117
  env?: Record<string, string | undefined>
65
118
  },
66
- ) => Effect.Effect<string, PlatformError.PlatformError, CommandExecutor.CommandExecutor> = Effect.fn('cmdText')(
67
- function* (commandInput, options) {
68
- const cwd = options?.cwd ?? process.env.WORKSPACE_ROOT ?? shouldNeverHappen('WORKSPACE_ROOT is not set')
69
- const [command, ...args] = Array.isArray(commandInput)
70
- ? commandInput.filter(isNotUndefined)
71
- : commandInput.split(' ')
119
+ ) => Effect.Effect<string, PlatformError.PlatformError, CommandExecutor.CommandExecutor | CurrentWorkingDirectory> =
120
+ Effect.fn('cmdText')(function* (commandInput, options) {
121
+ const cwd = yield* CurrentWorkingDirectory
122
+ const [command, ...args] =
123
+ Array.isArray(commandInput) === true ? commandInput.filter(isNotUndefined) : commandInput.split(' ')
72
124
  const debugEnvStr = Object.entries(options?.env ?? {})
73
- .map(([key, value]) => `${key}='${value}' `)
125
+ .map(([key, value]) => `${key}='${String(value)}' `)
74
126
  .join('')
75
127
 
76
128
  const commandDebugStr = debugEnvStr + [command, ...args].join(' ')
77
- const subshellStr = options?.runInShell ? ' (in subshell)' : ''
129
+ const subshellStr = options?.runInShell === true ? ' (in subshell)' : ''
78
130
 
79
131
  yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
80
132
  yield* Effect.annotateCurrentSpan({ 'span.label': commandDebugStr, command, cwd })
@@ -83,17 +135,283 @@ export const cmdText: (
83
135
  // inherit = Stream stderr to process.stderr, pipe = Stream stderr to process.stdout
84
136
  Command.stderr(options?.stderr ?? 'inherit'),
85
137
  Command.workingDirectory(cwd),
86
- options?.runInShell ? Command.runInShell(true) : identity,
138
+ options?.runInShell === true ? Command.runInShell(true) : identity,
87
139
  Command.env(options?.env ?? {}),
88
140
  Command.string,
89
141
  )
90
- },
91
- )
142
+ })
92
143
 
93
- export class CmdError extends Schema.TaggedError<CmdError>()('CmdError', {
144
+ export class CmdError extends Schema.TaggedError<CmdError>('~@livestore/utils-dev/CmdError')('CmdError', {
94
145
  command: Schema.String,
95
146
  args: Schema.Array(Schema.String),
96
147
  cwd: Schema.String,
97
148
  env: Schema.Record({ key: Schema.String, value: Schema.String.pipe(Schema.UndefinedOr) }),
98
149
  stderr: Schema.Literal('inherit', 'pipe'),
99
150
  }) {}
151
+
152
+ type TRunBaseArgs = {
153
+ readonly commandInput: string | string[]
154
+ readonly cwd: string
155
+ readonly env: Record<string, string | undefined>
156
+ readonly stdoutMode: 'inherit' | 'pipe'
157
+ readonly stderrMode: 'inherit' | 'pipe'
158
+ readonly useShell: boolean
159
+ }
160
+
161
+ const runWithoutLogging = ({ commandInput, cwd, env, stdoutMode, stderrMode, useShell }: TRunBaseArgs) =>
162
+ buildCommand(commandInput, useShell).pipe(
163
+ Command.stdin('inherit'),
164
+ Command.stdout(stdoutMode),
165
+ Command.stderr(stderrMode),
166
+ Command.workingDirectory(cwd),
167
+ useShell === true ? Command.runInShell(true) : identity,
168
+ Command.env(env),
169
+ Command.exitCode,
170
+ )
171
+
172
+ type TRunWithLoggingArgs = TRunBaseArgs & {
173
+ readonly logPath: string
174
+ readonly threadName: string
175
+ }
176
+
177
+ const runWithLogging = ({
178
+ commandInput,
179
+ cwd,
180
+ env,
181
+ stdoutMode,
182
+ stderrMode,
183
+ useShell,
184
+ logPath,
185
+ threadName,
186
+ }: TRunWithLoggingArgs) =>
187
+ // When logging is enabled we have to replace the `2>&1 | tee` pipeline the
188
+ // shell used to give us. We now pipe both streams through Effect so we can
189
+ // mirror to the terminal (only when requested) and append formatted entries
190
+ // into the canonical log ourselves.
191
+ Effect.scoped(
192
+ Effect.gen(function* () {
193
+ const envWithColor = env.FORCE_COLOR === undefined ? { ...env, FORCE_COLOR: '1' } : env
194
+
195
+ const logFile = yield* Effect.acquireRelease(
196
+ Effect.sync(() => fs.openSync(logPath, 'a', 0o666)),
197
+ (fd) => Effect.sync(() => fs.closeSync(fd)),
198
+ )
199
+
200
+ const prettyLogger = FileLogger.prettyLoggerTty({
201
+ colors: true,
202
+ stderr: false,
203
+ formatDate: (date) => `${FileLogger.defaultDateFormat(date)} ${threadName}`,
204
+ })
205
+
206
+ const appendLog = ({ channel, content }: { channel: 'stdout' | 'stderr'; content: string }) =>
207
+ Effect.sync(() => {
208
+ const formatted = prettyLogger.log({
209
+ fiberId: FiberId.none,
210
+ logLevel: channel === 'stdout' ? LogLevel.Info : LogLevel.Warning,
211
+ message: [`[${channel}]${content.length > 0 ? ` ${content}` : ''}`],
212
+ cause: Cause.empty,
213
+ context: FiberRefs.empty(),
214
+ spans: List.empty(),
215
+ annotations: HashMap.empty(),
216
+ date: new Date(),
217
+ })
218
+ fs.writeSync(logFile, formatted)
219
+ })
220
+
221
+ const command = buildCommand(commandInput, useShell).pipe(
222
+ Command.stdin('inherit'),
223
+ Command.stdout('pipe'),
224
+ Command.stderr('pipe'),
225
+ Command.workingDirectory(cwd),
226
+ useShell === true ? Command.runInShell(true) : identity,
227
+ Command.env(envWithColor),
228
+ )
229
+
230
+ // Acquire/start the command and make sure we kill it on interruption.
231
+ const runningProcess = yield* Effect.acquireRelease(command.pipe(Command.start), (proc) =>
232
+ proc.isRunning.pipe(
233
+ Effect.flatMap((running) =>
234
+ running === true ? proc.kill().pipe(Effect.catchAll(() => Effect.void)) : Effect.void,
235
+ ),
236
+ Effect.ignore,
237
+ ),
238
+ )
239
+
240
+ const stdoutHandler = makeStreamHandler({
241
+ channel: 'stdout',
242
+ ...(stdoutMode === 'inherit' ? { mirrorTarget: process.stdout } : {}),
243
+ appendLog,
244
+ })
245
+ const stderrHandler = makeStreamHandler({
246
+ channel: 'stderr',
247
+ ...(stderrMode === 'inherit' ? { mirrorTarget: process.stderr } : {}),
248
+ appendLog,
249
+ })
250
+
251
+ const stdoutFiber = yield* runningProcess.stdout.pipe(
252
+ Stream.decodeText('utf8'),
253
+ Stream.runForEach((chunk) => stdoutHandler.onChunk(chunk)),
254
+ Effect.forkScoped,
255
+ )
256
+
257
+ const stderrFiber = yield* runningProcess.stderr.pipe(
258
+ Stream.decodeText('utf8'),
259
+ Stream.runForEach((chunk) => stderrHandler.onChunk(chunk)),
260
+ Effect.forkScoped,
261
+ )
262
+
263
+ // Dump any buffered data and finish both stream fibers before we return.
264
+ const flushOutputs = Effect.gen(function* () {
265
+ const stillRunning = yield* runningProcess.isRunning.pipe(Effect.catchAll(() => Effect.succeed(false)))
266
+ if (stillRunning === true) {
267
+ yield* Effect.ignore(runningProcess.kill())
268
+ }
269
+ yield* Effect.ignore(Fiber.join(stdoutFiber))
270
+ yield* Effect.ignore(Fiber.join(stderrFiber))
271
+ yield* stdoutHandler.flush()
272
+ yield* stderrHandler.flush()
273
+ })
274
+
275
+ const exitCode = yield* runningProcess.exitCode.pipe(Effect.ensuring(flushOutputs))
276
+
277
+ return exitCode
278
+ }),
279
+ )
280
+
281
+ const buildCommand = (input: string | string[], useShell: boolean) => {
282
+ if (Array.isArray(input) === true) {
283
+ const [c, ...a] = input
284
+ return Command.make(c!, ...a)
285
+ }
286
+
287
+ if (useShell === true) {
288
+ return Command.make(input)
289
+ }
290
+
291
+ const [c, ...a] = input.split(' ')
292
+ return Command.make(c!, ...a)
293
+ }
294
+
295
+ type TLineTerminator = 'newline' | 'carriage-return' | 'none'
296
+
297
+ type TStreamHandler = {
298
+ readonly onChunk: (chunk: string) => Effect.Effect<void>
299
+ readonly flush: () => Effect.Effect<void>
300
+ }
301
+
302
+ const makeStreamHandler = ({
303
+ channel,
304
+ mirrorTarget,
305
+ appendLog,
306
+ }: {
307
+ readonly channel: 'stdout' | 'stderr'
308
+ readonly mirrorTarget?: NodeJS.WriteStream
309
+ readonly appendLog: (args: { channel: 'stdout' | 'stderr'; content: string }) => Effect.Effect<void>
310
+ }): TStreamHandler => {
311
+ let buffer = ''
312
+
313
+ // Effect's FileLogger expects line-oriented messages, but the subprocess
314
+ // gives us arbitrary UTF-8 chunks. We keep a tiny line splitter here so the
315
+ // log and console stay readable (and consistent with the previous `tee`
316
+ // behaviour).
317
+ const emit = (content: string, terminator: TLineTerminator) =>
318
+ emitSegment({
319
+ channel,
320
+ content,
321
+ terminator,
322
+ ...(mirrorTarget !== undefined ? { mirrorTarget } : {}),
323
+ appendLog,
324
+ })
325
+
326
+ const consumeBuffer = (): Effect.Effect<void> => {
327
+ if (buffer.length === 0) return Effect.void
328
+
329
+ const lastChar = buffer[buffer.length - 1]
330
+ if (lastChar === '\r') {
331
+ const line = buffer.slice(0, -1)
332
+ buffer = ''
333
+ return emit(line, 'carriage-return')
334
+ }
335
+
336
+ const line = buffer
337
+ buffer = ''
338
+ return line.length === 0 ? Effect.void : emit(line, 'none')
339
+ }
340
+
341
+ return {
342
+ onChunk: (chunk) =>
343
+ Effect.gen(function* () {
344
+ buffer += chunk
345
+ while (buffer.length > 0) {
346
+ const newlineIndex = buffer.indexOf('\n')
347
+ const carriageIndex = buffer.indexOf('\r')
348
+
349
+ if (newlineIndex === -1 && carriageIndex === -1) {
350
+ break
351
+ }
352
+
353
+ let index: number
354
+ let terminator: TLineTerminator
355
+ let skip = 1
356
+
357
+ if (carriageIndex !== -1 && (newlineIndex === -1 || carriageIndex < newlineIndex)) {
358
+ index = carriageIndex
359
+ if (carriageIndex + 1 < buffer.length && buffer[carriageIndex + 1] === '\n') {
360
+ skip = 2
361
+ terminator = 'newline'
362
+ } else {
363
+ terminator = 'carriage-return'
364
+ }
365
+ } else {
366
+ index = newlineIndex!
367
+ terminator = 'newline'
368
+ }
369
+
370
+ const line = buffer.slice(0, index)
371
+ buffer = buffer.slice(index + skip)
372
+ yield* emit(line, terminator)
373
+ }
374
+ }),
375
+ flush: () => consumeBuffer(),
376
+ }
377
+ }
378
+
379
+ const emitSegment = ({
380
+ channel,
381
+ content,
382
+ terminator,
383
+ mirrorTarget,
384
+ appendLog,
385
+ }: {
386
+ readonly channel: 'stdout' | 'stderr'
387
+ readonly content: string
388
+ readonly terminator: TLineTerminator
389
+ readonly mirrorTarget?: NodeJS.WriteStream
390
+ readonly appendLog: (args: { channel: 'stdout' | 'stderr'; content: string }) => Effect.Effect<void>
391
+ }) =>
392
+ Effect.gen(function* () {
393
+ if (mirrorTarget !== undefined) {
394
+ yield* Effect.sync(() => mirrorSegment(mirrorTarget, content, terminator))
395
+ }
396
+
397
+ const contentForLog = terminator === 'carriage-return' ? `${content}\r` : content
398
+
399
+ yield* appendLog({ channel, content: contentForLog })
400
+ })
401
+
402
+ const mirrorSegment = (target: NodeJS.WriteStream, content: string, terminator: TLineTerminator) => {
403
+ switch (terminator) {
404
+ case 'newline': {
405
+ target.write(`${content}\n`)
406
+ break
407
+ }
408
+ case 'carriage-return': {
409
+ target.write(`${content}\r`)
410
+ break
411
+ }
412
+ case 'none': {
413
+ target.write(content)
414
+ break
415
+ }
416
+ }
417
+ }
package/src/node/mod.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import { performance } from 'node:perf_hooks'
2
2
 
3
3
  import * as OtelNodeSdk from '@effect/opentelemetry/NodeSdk'
4
- import { IS_BUN, isNonEmptyString } from '@livestore/utils'
5
- import type { Tracer } from '@livestore/utils/effect'
6
- import { Config, Effect, FiberRef, Layer, LogLevel, OtelTracer } from '@livestore/utils/effect'
7
- import { OtelLiveDummy } from '@livestore/utils/node'
8
4
  import * as otel from '@opentelemetry/api'
9
5
  import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
10
6
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
11
7
  import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
12
8
  import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
13
9
 
10
+ import { IS_BUN, isNonEmptyString } from '@livestore/utils'
11
+ import type { Tracer } from '@livestore/utils/effect'
12
+ import { Config, Effect, FiberRef, Layer, LogLevel, OtelTracer, Schema } from '@livestore/utils/effect'
13
+ import { OtelLiveDummy } from '@livestore/utils/node'
14
+
14
15
  export { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
15
16
  export { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
16
17
  export * from './cmd.ts'
@@ -24,6 +25,7 @@ export {
24
25
  startDockerComposeServicesScoped,
25
26
  } from './DockerComposeService/DockerComposeService.ts'
26
27
  export * as FileLogger from './FileLogger.ts'
28
+ export * from './workspace.ts'
27
29
 
28
30
  export const OtelLiveHttp = ({
29
31
  serviceName,
@@ -39,16 +41,16 @@ export const OtelLiveHttp = ({
39
41
  rootSpanAttributes?: Record<string, unknown>
40
42
  skipLogUrl?: boolean
41
43
  traceNodeBootstrap?: boolean
42
- } = {}): Layer.Layer<OtelTracer.OtelTracer | Tracer.ParentSpan, never, never> =>
44
+ } = {}): Layer.Layer<OtelTracer.OtelTracer | Tracer.ParentSpan> =>
43
45
  Effect.gen(function* () {
44
46
  const configRes = yield* Config.all({
45
47
  exporterUrl: Config.string('OTEL_EXPORTER_OTLP_ENDPOINT').pipe(
46
48
  Config.validate({ message: 'OTEL_EXPORTER_OTLP_ENDPOINT must be set', validation: isNonEmptyString }),
47
49
  ),
48
- serviceName: serviceName
50
+ serviceName: serviceName !== undefined
49
51
  ? Config.succeed(serviceName)
50
52
  : Config.string('OTEL_SERVICE_NAME').pipe(Config.withDefault('livestore-utils-dev')),
51
- rootSpanName: rootSpanName
53
+ rootSpanName: rootSpanName !== undefined
52
54
  ? Config.succeed(rootSpanName)
53
55
  : Config.string('OTEL_ROOT_SPAN_NAME').pipe(Config.withDefault('RootSpan')),
54
56
  }).pipe(Effect.option)
@@ -90,24 +92,22 @@ export const OtelLiveHttp = ({
90
92
 
91
93
  const RootSpanLive = Layer.span(config.rootSpanName, {
92
94
  attributes: { config, ...rootSpanAttributes },
93
- onEnd: skipLogUrl ? undefined : (span: any) => logTraceUiUrlForSpan()(span.span),
95
+ onEnd: skipLogUrl === true ? undefined : (span: any) => logTraceUiUrlForSpan()(span.span),
94
96
  parent: parentSpan,
95
97
  })
96
98
 
97
99
  const layer = yield* Layer.memoize(RootSpanLive.pipe(Layer.provideMerge(OtelLive)))
98
100
 
99
- if (traceNodeBootstrap) {
101
+ if (traceNodeBootstrap === true && IS_BUN === false) {
100
102
  /**
101
103
  * Create a span representing the Node.js bootstrap duration.
104
+ * Note: Skipped in Bun since performance.nodeTiming is not properly supported.
102
105
  */
103
106
  yield* Effect.gen(function* () {
104
107
  const tracer = yield* OtelTracer.OtelTracer
105
108
  const currentSpan = yield* OtelTracer.currentOtelSpan
106
109
 
107
- const nodeTiming = performance.nodeTiming
108
-
109
- // TODO get rid of this workaround for Bun once Bun properly supports performance.nodeTiming
110
- const startTime = IS_BUN ? nodeTiming.startTime : performance.timeOrigin + nodeTiming.nodeStart
110
+ const { nodeTiming, endAbs, durationAttr } = computeBootstrapTiming()
111
111
 
112
112
  const bootSpan = tracer.startSpan(
113
113
  'node-bootstrap',
@@ -118,13 +118,13 @@ export const OtelLiveHttp = ({
118
118
  'node.timing.environment': nodeTiming.environment,
119
119
  'node.timing.bootstrapComplete': nodeTiming.bootstrapComplete,
120
120
  'node.timing.loopStart': nodeTiming.loopStart,
121
- 'node.timing.duration': nodeTiming.duration,
121
+ 'node.timing.duration': durationAttr,
122
122
  },
123
123
  },
124
124
  otel.trace.setSpanContext(otel.context.active(), currentSpan.spanContext()),
125
125
  )
126
126
 
127
- bootSpan.end(startTime + nodeTiming.duration)
127
+ bootSpan.end(endAbs)
128
128
  }).pipe(Effect.provide(layer), Effect.orDie)
129
129
  }
130
130
 
@@ -137,7 +137,7 @@ export const logTraceUiUrlForSpan = (printMsg?: (url: string) => string) => (spa
137
137
  if (url === undefined) {
138
138
  return Effect.logWarning('No tracing backend url found')
139
139
  } else {
140
- if (printMsg) {
140
+ if (printMsg !== undefined) {
141
141
  return Effect.log(printMsg(url))
142
142
  } else {
143
143
  return Effect.log(`Trace URL: ${url}`)
@@ -156,15 +156,63 @@ export const getTracingBackendUrl = (span: otel.Span) =>
156
156
  // Grafana + Tempo
157
157
 
158
158
  const grafanaEndpoint = endpoint.value
159
+ const left = yield* Schema.encode(Schema.parseJson())({
160
+ datasource: 'tempo',
161
+ queries: [{ query: traceId, queryType: 'traceql', refId: 'A' }],
162
+ range: { from: 'now-1h', to: 'now' },
163
+ }).pipe(Effect.orDie)
159
164
  const searchParams = new URLSearchParams({
160
165
  orgId: '1',
161
- left: JSON.stringify({
162
- datasource: 'tempo',
163
- queries: [{ query: traceId, queryType: 'traceql', refId: 'A' }],
164
- range: { from: 'now-1h', to: 'now' },
165
- }),
166
+ left,
166
167
  })
167
168
 
168
169
  // TODO make dynamic via env var
169
170
  return `${grafanaEndpoint}/explore?${searchParams.toString()}`
170
171
  })
172
+
173
+ /**
174
+ * Compute absolute start/end timestamps for the Node.js bootstrap span in a
175
+ * way that works in both Node and Bun.
176
+ *
177
+ * Context: Bun's perf_hooks PerformanceNodeTiming currently throws when
178
+ * accessing standard PerformanceEntry getters like `startTime` and
179
+ * `duration`, and some fields differ in semantics (e.g. `nodeStart` appears
180
+ * as an epoch timestamp rather than an offset). See:
181
+ * https://github.com/oven-sh/bun/issues/23041
182
+ *
183
+ * We therefore avoid the problematic getters and derive absolute timestamps
184
+ * using fields that exist in both runtimes.
185
+ *
186
+ * TODO: Simplify to a single, non-branching computation once the Bun issue
187
+ * above is fixed and Bun matches Node's semantics for PerformanceNodeTiming.
188
+ */
189
+ const computeBootstrapTiming = () => {
190
+ const nodeTiming = performance.nodeTiming
191
+
192
+ // Absolute start time in ms since epoch.
193
+ const startAbs = IS_BUN === true
194
+ ? typeof nodeTiming.nodeStart === 'number'
195
+ ? nodeTiming.nodeStart
196
+ : performance.timeOrigin
197
+ : performance.timeOrigin + nodeTiming.nodeStart
198
+
199
+ // Absolute end time.
200
+ const endAbs = IS_BUN === true
201
+ ? (() => {
202
+ const { loopStart, bootstrapComplete } = nodeTiming
203
+ if (typeof loopStart === 'number' && loopStart > 0) return startAbs + loopStart
204
+ if (typeof bootstrapComplete === 'number' && bootstrapComplete >= startAbs) return bootstrapComplete
205
+ return startAbs + 1
206
+ })()
207
+ : startAbs + nodeTiming.duration
208
+
209
+ // Duration attribute value for the span.
210
+ const durationAttr = IS_BUN === true
211
+ ? (() => {
212
+ const { loopStart } = nodeTiming
213
+ return typeof loopStart === 'number' && loopStart > 0 ? loopStart : 0
214
+ })()
215
+ : nodeTiming.duration
216
+
217
+ return { nodeTiming, startAbs, endAbs, durationAttr } as const
218
+ }