@livestore/utils-dev 0.4.0-dev.1 → 0.4.0-dev.10

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