@livestore/utils-dev 0.4.0-dev.18 → 0.4.0-dev.19

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/src/node/cmd.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
 
3
- import { isNotUndefined, shouldNeverHappen } from '@livestore/utils'
3
+ import { isNotUndefined } from '@livestore/utils'
4
4
  import {
5
5
  Cause,
6
6
  Command,
@@ -19,6 +19,7 @@ import {
19
19
  } from '@livestore/utils/effect'
20
20
  import { applyLoggingToCommand } from './cmd-log.ts'
21
21
  import * as FileLogger from './FileLogger.ts'
22
+ import { CurrentWorkingDirectory } from './workspace.ts'
22
23
 
23
24
  // Branded zero value so we can compare exit codes without touching internals.
24
25
  const SUCCESS_EXIT_CODE: CommandExecutor.ExitCode = 0 as CommandExecutor.ExitCode
@@ -27,7 +28,6 @@ export const cmd: (
27
28
  commandInput: string | (string | undefined)[],
28
29
  options?:
29
30
  | {
30
- cwd?: string
31
31
  stderr?: 'inherit' | 'pipe'
32
32
  stdout?: 'inherit' | 'pipe'
33
33
  shell?: boolean
@@ -43,84 +43,86 @@ export const cmd: (
43
43
  logRetention?: number
44
44
  }
45
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
- })
46
+ ) => Effect.Effect<
47
+ CommandExecutor.ExitCode,
48
+ PlatformError.PlatformError | CmdError,
49
+ CommandExecutor.CommandExecutor | CurrentWorkingDirectory
50
+ > = Effect.fn('cmd')(function* (commandInput, options) {
51
+ const cwd = yield* CurrentWorkingDirectory
52
+
53
+ const asArray = Array.isArray(commandInput)
54
+ const parts = asArray ? (commandInput as (string | undefined)[]).filter(isNotUndefined) : undefined
55
+ const [command, ...args] = asArray ? (parts as string[]) : (commandInput as string).split(' ')
56
+
57
+ const debugEnvStr = Object.entries(options?.env ?? {})
58
+ .map(([key, value]) => `${key}='${value}' `)
59
+ .join('')
60
+
61
+ const loggingOpts = {
62
+ ...(options?.logDir ? { logDir: options.logDir } : {}),
63
+ ...(options?.logFileName ? { logFileName: options.logFileName } : {}),
64
+ ...(options?.logRetention ? { logRetention: options.logRetention } : {}),
65
+ } as const
66
+ const { input: finalInput, subshell: needsShell, logPath } = yield* applyLoggingToCommand(commandInput, loggingOpts)
67
+
68
+ const stdoutMode = options?.stdout ?? 'inherit'
69
+ const stderrMode = options?.stderr ?? 'inherit'
70
+ const useShell = (options?.shell ? true : false) || needsShell
71
+
72
+ const commandDebugStr =
73
+ debugEnvStr + (Array.isArray(finalInput) ? (finalInput as string[]).join(' ') : (finalInput as string))
74
+ const subshellStr = useShell ? ' (in subshell)' : ''
75
+
76
+ yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
77
+ yield* Effect.annotateCurrentSpan({
78
+ 'span.label': commandDebugStr,
79
+ cwd,
80
+ command,
81
+ args,
82
+ logDir: options?.logDir,
83
+ })
81
84
 
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
- }
85
+ const baseArgs = {
86
+ commandInput: finalInput,
87
+ cwd,
88
+ env: options?.env ?? {},
89
+ stdoutMode,
90
+ stderrMode,
91
+ useShell,
92
+ } as const
93
+
94
+ const exitCode = yield* isNotUndefined(logPath)
95
+ ? Effect.gen(function* () {
96
+ yield* Effect.sync(() => console.log(`Logging output to ${logPath}`))
97
+ return yield* runWithLogging({ ...baseArgs, logPath, threadName: commandDebugStr })
98
+ })
99
+ : runWithoutLogging(baseArgs)
100
+
101
+ if (exitCode !== SUCCESS_EXIT_CODE) {
102
+ return yield* Effect.fail(
103
+ CmdError.make({
104
+ command: command!,
105
+ args,
106
+ cwd,
107
+ env: options?.env ?? {},
108
+ stderr: stderrMode,
109
+ }),
110
+ )
111
+ }
109
112
 
110
- return exitCode
111
- })
113
+ return exitCode
114
+ })
112
115
 
113
116
  export const cmdText: (
114
117
  commandInput: string | (string | undefined)[],
115
118
  options?: {
116
- cwd?: string
117
119
  stderr?: 'inherit' | 'pipe'
118
120
  runInShell?: boolean
119
121
  env?: Record<string, string | undefined>
120
122
  },
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')
123
+ ) => Effect.Effect<string, PlatformError.PlatformError, CommandExecutor.CommandExecutor | CurrentWorkingDirectory> =
124
+ Effect.fn('cmdText')(function* (commandInput, options) {
125
+ const cwd = yield* CurrentWorkingDirectory
124
126
  const [command, ...args] = Array.isArray(commandInput)
125
127
  ? commandInput.filter(isNotUndefined)
126
128
  : commandInput.split(' ')
@@ -142,8 +144,7 @@ export const cmdText: (
142
144
  Command.env(options?.env ?? {}),
143
145
  Command.string,
144
146
  )
145
- },
146
- )
147
+ })
147
148
 
148
149
  export class CmdError extends Schema.TaggedError<CmdError>()('CmdError', {
149
150
  command: Schema.String,
package/src/node/mod.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  startDockerComposeServicesScoped,
25
25
  } from './DockerComposeService/DockerComposeService.ts'
26
26
  export * as FileLogger from './FileLogger.ts'
27
+ export * from './workspace.ts'
27
28
 
28
29
  export const OtelLiveHttp = ({
29
30
  serviceName,
@@ -0,0 +1,45 @@
1
+ import path from 'node:path'
2
+ import { shouldNeverHappen } from '@livestore/utils'
3
+ import { Context, Effect, Layer } from '@livestore/utils/effect'
4
+
5
+ export type WorkspaceInfo = string
6
+
7
+ /** Current working directory. */
8
+ export class CurrentWorkingDirectory extends Context.Tag('CurrentWorkingDirectory')<
9
+ CurrentWorkingDirectory,
10
+ WorkspaceInfo
11
+ >() {
12
+ /** Layer that captures the process cwd once. */
13
+ static live = Layer.effect(
14
+ CurrentWorkingDirectory,
15
+ Effect.sync(() => process.cwd()),
16
+ )
17
+
18
+ /** Override CWD for tests or nested invocations. */
19
+ static fromPath = (cwd: string) => Layer.succeed(CurrentWorkingDirectory, cwd)
20
+ }
21
+
22
+ /** Livestore workspace root (env required). */
23
+ export class LivestoreWorkspace extends Context.Tag('LivestoreWorkspace')<LivestoreWorkspace, WorkspaceInfo>() {
24
+ /** Resolve from WORKSPACE_ROOT env. */
25
+ static live = Layer.effect(
26
+ LivestoreWorkspace,
27
+ Effect.sync(() => {
28
+ const root = process.env.WORKSPACE_ROOT ?? shouldNeverHappen('WORKSPACE_ROOT is not set')
29
+ return root
30
+ }),
31
+ )
32
+
33
+ /** Provide a fixed Livestore root. */
34
+ static fromPath = (root: string) => Layer.succeed(LivestoreWorkspace, root)
35
+
36
+ /** Derive a CurrentWorkingDirectory layer from the Livestore workspace root (with optional subpath) */
37
+ static toCwd = (/** Relative path to the Livestore workspace root */ subPath?: string) =>
38
+ Layer.effect(
39
+ CurrentWorkingDirectory,
40
+ Effect.gen(function* () {
41
+ const root = yield* LivestoreWorkspace
42
+ return path.join(root, subPath ?? '')
43
+ }),
44
+ )
45
+ }
@@ -59,7 +59,7 @@ Vitest.describe('WranglerDevServer', { timeout: testTimeout }, () => {
59
59
  WranglerDevServerTest({
60
60
  cwd: '/tmp',
61
61
  wranglerConfigPath: '/dev/null',
62
- connectTimeout: '500 millis',
62
+ readiness: { connectTimeout: '500 millis' },
63
63
  }).pipe(Layer.provide(PlatformNode.NodeContext.layer)),
64
64
  ),
65
65
  Effect.flip,
@@ -24,17 +24,35 @@ export interface WranglerDevServer {
24
24
  }
25
25
 
26
26
  /**
27
- * Configuration for starting WranglerDevServer
27
+ * Readiness and retry configuration for wrangler boot and HTTP health.
28
+ *
29
+ * Example: startupTimeout=20s, connectTimeout=5s, retrySchedule=recurs(1)
30
+ * - Give wrangler up to 20s to boot; if it succeeds, give the HTTP check up to 5s.
31
+ * - If wrangler fails/times out, retry boot once; each boot attempt gets its own 20s budget.
32
+ * connectTimeout should be shorter than startupTimeout because HTTP readiness should be fast after boot.
28
33
  */
34
+ export interface WranglerReadinessOptions {
35
+ /** Max time to wait for wrangler to report ready before retrying. */
36
+ startupTimeout?: Duration.DurationInput
37
+ /** Max time for the HTTP connectivity check after wrangler reports ready. */
38
+ connectTimeout?: Duration.DurationInput
39
+ /** Retry policy for startup attempts (applies when startupTimeout elapses or wrangler throws). */
40
+ retrySchedule?: Schedule.Schedule<unknown, unknown, never>
41
+ }
42
+
29
43
  export interface StartWranglerDevServerArgs {
44
+ /** Path to wrangler.toml (defaults to cwd/wrangler.toml). */
30
45
  wranglerConfigPath?: string
46
+ /** Working directory wrangler should use. */
31
47
  cwd: string
32
48
  /** The port to try first. The dev server may bind a different port if unavailable. */
33
49
  preferredPort?: number
34
50
  /** @default false */
35
51
  showLogs?: boolean
52
+ /** Optional inspector port for wrangler dev. */
36
53
  inspectorPort?: number
37
- connectTimeout?: Duration.DurationInput
54
+ /** Readiness and retry configuration for bringing up wrangler and confirming connectivity. */
55
+ readiness?: WranglerReadinessOptions
38
56
  }
39
57
 
40
58
  /**
@@ -74,6 +92,8 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
74
92
  )
75
93
  const resolvedMainPath = yield* Effect.try(() => path.resolve(args.cwd, parsedConfig.main))
76
94
 
95
+ const readiness = args.readiness ?? {}
96
+ const startupTimeout = readiness.startupTimeout ?? Duration.seconds(IS_CI ? 30 : 10)
77
97
  const devServer = yield* Effect.promise(() =>
78
98
  wrangler.unstable_dev(resolvedMainPath, {
79
99
  config: configPath,
@@ -85,6 +105,24 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
85
105
  disableExperimentalWarning: true,
86
106
  },
87
107
  }),
108
+ ).pipe(
109
+ Effect.timeout(startupTimeout),
110
+ Effect.mapError(
111
+ (cause) =>
112
+ new WranglerDevServerError({
113
+ cause,
114
+ message: `Failed to start wrangler dev server within ${Duration.format(startupTimeout)}`,
115
+ port: preferredPort,
116
+ }),
117
+ ),
118
+ Effect.tapError((error) =>
119
+ Effect.logError('Wrangler dev server failed to start', {
120
+ message: error.message,
121
+ preferredPort,
122
+ cwd: args.cwd,
123
+ }),
124
+ ),
125
+ Effect.retry(readiness.retrySchedule ?? Schedule.recurs(1)),
88
126
  )
89
127
 
90
128
  yield* Effect.addFinalizer(
@@ -111,10 +149,11 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
111
149
  const actualHost = devServer.address
112
150
  const url = `http://${actualHost}:${actualPort}`
113
151
 
114
- // Use longer timeout in CI environments to account for slower startup times
115
- const defaultTimeout = Duration.seconds(IS_CI ? 30 : 5)
152
+ // Use longer timeout in CI environments to account for slower HTTP readiness
153
+ const defaultConnectivityTimeout = Duration.seconds(IS_CI ? 30 : 5)
154
+ const connectivityTimeout = readiness.connectTimeout ?? defaultConnectivityTimeout
116
155
 
117
- yield* verifyHttpConnectivity({ url, showLogs, connectTimeout: args.connectTimeout ?? defaultTimeout })
156
+ yield* verifyHttpConnectivity({ url, showLogs, connectTimeout: connectivityTimeout })
118
157
 
119
158
  if (showLogs) {
120
159
  yield* Effect.logDebug(
@@ -127,9 +166,10 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
127
166
  url,
128
167
  } satisfies WranglerDevServer
129
168
  }).pipe(
130
- Effect.mapError(
131
- (error) =>
132
- new WranglerDevServerError({ cause: error, message: 'Failed to start wrangler dev server', port: -1 }),
169
+ Effect.mapError((error) =>
170
+ error instanceof WranglerDevServerError
171
+ ? error
172
+ : new WranglerDevServerError({ cause: error, message: 'Failed to start wrangler dev server', port: -1 }),
133
173
  ),
134
174
  Effect.withSpan('WranglerDevServerService', {
135
175
  attributes: { preferredPort: args.preferredPort ?? 'auto', cwd: args.cwd },