@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/node/DockerComposeService/DockerComposeService.d.ts +10 -5
- package/dist/node/DockerComposeService/DockerComposeService.d.ts.map +1 -1
- package/dist/node/DockerComposeService/DockerComposeService.js +47 -42
- package/dist/node/DockerComposeService/DockerComposeService.js.map +1 -1
- package/dist/node/DockerComposeService/DockerComposeService.test.js +2 -2
- package/dist/node/DockerComposeService/DockerComposeService.test.js.map +1 -1
- package/dist/node/FileLogger.d.ts.map +1 -1
- package/dist/node/FileLogger.js +3 -3
- package/dist/node/FileLogger.js.map +1 -1
- package/dist/node/cmd-log.d.ts +21 -0
- package/dist/node/cmd-log.d.ts.map +1 -0
- package/dist/node/cmd-log.js +47 -0
- package/dist/node/cmd-log.js.map +1 -0
- package/dist/node/cmd.d.ts +12 -4
- package/dist/node/cmd.d.ts.map +1 -1
- package/dist/node/cmd.js +207 -29
- package/dist/node/cmd.js.map +1 -1
- package/dist/node/cmd.test.d.ts +2 -0
- package/dist/node/cmd.test.d.ts.map +1 -0
- package/dist/node/cmd.test.js +103 -0
- package/dist/node/cmd.test.js.map +1 -0
- package/dist/node/mod.d.ts +3 -2
- package/dist/node/mod.d.ts.map +1 -1
- package/dist/node/mod.js +63 -18
- package/dist/node/mod.js.map +1 -1
- package/dist/node/workspace.d.ts +22 -0
- package/dist/node/workspace.d.ts.map +1 -0
- package/dist/node/workspace.js +26 -0
- package/dist/node/workspace.js.map +1 -0
- package/dist/node-vitest/Vitest.d.ts.map +1 -1
- package/dist/node-vitest/Vitest.js +11 -11
- package/dist/node-vitest/Vitest.js.map +1 -1
- package/dist/node-vitest/Vitest.test.d.ts +8 -0
- package/dist/node-vitest/Vitest.test.d.ts.map +1 -1
- package/dist/node-vitest/Vitest.test.js +11 -7
- package/dist/node-vitest/Vitest.test.js.map +1 -1
- package/dist/wrangler/WranglerDevServer.d.ts +20 -3
- package/dist/wrangler/WranglerDevServer.d.ts.map +1 -1
- package/dist/wrangler/WranglerDevServer.js +23 -10
- package/dist/wrangler/WranglerDevServer.js.map +1 -1
- package/dist/wrangler/WranglerDevServer.test.js +3 -3
- package/dist/wrangler/WranglerDevServer.test.js.map +1 -1
- package/package.json +74 -21
- package/src/node/DockerComposeService/DockerComposeService.test.ts +5 -2
- package/src/node/DockerComposeService/DockerComposeService.ts +105 -90
- package/src/node/DockerComposeService/test-fixtures/docker-compose.yml +1 -1
- package/src/node/FileLogger.ts +4 -3
- package/src/node/cmd-log.ts +84 -0
- package/src/node/cmd.test.ts +134 -0
- package/src/node/cmd.ts +380 -62
- package/src/node/mod.ts +69 -21
- package/src/node/workspace.ts +46 -0
- package/src/node-vitest/Vitest.test.ts +12 -7
- package/src/node-vitest/Vitest.ts +28 -24
- package/src/wrangler/WranglerDevServer.test.ts +5 -3
- package/src/wrangler/WranglerDevServer.ts +54 -13
- package/src/wrangler/fixtures/wrangler.toml +1 -1
- package/dist/.tsbuildinfo.json +0 -1
package/src/node/cmd.ts
CHANGED
|
@@ -1,80 +1,132 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
56
|
+
const debugEnvStr = Object.entries(options?.env ?? {})
|
|
57
|
+
.map(([key, value]) => `${key}='${String(value)}' `)
|
|
58
|
+
.join('')
|
|
30
59
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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> =
|
|
67
|
-
function* (commandInput, options) {
|
|
68
|
-
const cwd =
|
|
69
|
-
const [command, ...args] =
|
|
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
|
|
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 =
|
|
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':
|
|
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(
|
|
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
|
|
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
|
+
}
|