@livestore/utils-dev 0.0.0-snapshot-92c31f56117b4fd377b66f55163ed565795c5e4c → 0.0.0-snapshot-6d1da0d93ffe8144f36fc2ec57c22db22a1d18d8

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.
@@ -1,6 +1,18 @@
1
+ import { error } from 'node:console'
1
2
  import * as path from 'node:path'
2
-
3
- import { Command, Effect, Exit, type PlatformError, Schema, Stream } from '@livestore/utils/effect'
3
+ import { IS_CI, shouldNeverHappen } from '@livestore/utils'
4
+ import {
5
+ Command,
6
+ Duration,
7
+ Effect,
8
+ Exit,
9
+ HttpClient,
10
+ Option,
11
+ type PlatformError,
12
+ Schedule,
13
+ Schema,
14
+ Stream,
15
+ } from '@livestore/utils/effect'
4
16
  import { getFreePort } from '@livestore/utils/node'
5
17
  import { cleanupOrphanedProcesses, killProcessTree } from './process-tree-manager.ts'
6
18
 
@@ -28,9 +40,12 @@ export interface WranglerDevServer {
28
40
  export interface StartWranglerDevServerArgs {
29
41
  wranglerConfigPath?: string
30
42
  cwd: string
31
- port?: number
43
+ /** The port to try first. The dev server may bind a different port if unavailable. */
44
+ preferredPort?: number
32
45
  /** @default false */
33
46
  showLogs?: boolean
47
+ inspectorPort?: number
48
+ connectTimeout?: Duration.DurationInput
34
49
  }
35
50
 
36
51
  /**
@@ -57,27 +72,31 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
57
72
  Effect.ignore, // Don't fail startup if cleanup fails
58
73
  )
59
74
 
60
- // Allocate port
61
- const port =
62
- args.port ??
75
+ // Allocate preferred port (Wrangler may bind a different one if unavailable)
76
+ const preferredPort =
77
+ args.preferredPort ??
63
78
  (yield* getFreePort.pipe(
64
79
  Effect.mapError(
65
80
  (cause) => new WranglerDevServerError({ cause, message: 'Failed to get free port', port: -1 }),
66
81
  ),
67
82
  ))
68
83
 
69
- yield* Effect.annotateCurrentSpan({ port })
84
+ yield* Effect.annotateCurrentSpan({ preferredPort })
70
85
 
71
86
  // Resolve config path
72
87
  const configPath = path.resolve(args.wranglerConfigPath ?? path.join(args.cwd, 'wrangler.toml'))
73
88
 
89
+ const inspectorPort = args.inspectorPort ?? (yield* getFreePort)
90
+
74
91
  // Start wrangler process using Effect Command
75
92
  const process = yield* Command.make(
76
93
  'bunx',
77
94
  'wrangler',
78
95
  'dev',
79
96
  '--port',
80
- port.toString(),
97
+ preferredPort.toString(),
98
+ '--inspector-port',
99
+ inspectorPort.toString(),
81
100
  '--config',
82
101
  configPath,
83
102
  ).pipe(
@@ -90,7 +109,7 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
90
109
  new WranglerDevServerError({
91
110
  cause: error,
92
111
  message: `Failed to start wrangler process in directory: ${args.cwd}`,
93
- port,
112
+ port: preferredPort,
94
113
  }),
95
114
  ),
96
115
  Effect.withSpan('WranglerDevServerService:startProcess'),
@@ -154,26 +173,38 @@ export class WranglerDevServerService extends Effect.Service<WranglerDevServerSe
154
173
  yield* Effect.logDebug(`Process ${processId} already terminated`)
155
174
  }
156
175
  }).pipe(
176
+ Effect.withSpan('WranglerDevServerService:cleanupProcess'),
157
177
  Effect.timeout('5 seconds'), // Don't let cleanup hang forever
158
178
  Effect.ignoreLogged,
159
179
  ),
160
180
  )
161
181
 
162
- // Wait for server to be ready
163
- yield* waitForReady({ stdout, showLogs })
182
+ // Wait for server to be ready and parse the actual bound host:port from stdout
183
+ const readyInfo = yield* waitForReady({ stdout, showLogs })
184
+
185
+ const actualPort = readyInfo.port
186
+ const actualHost = readyInfo.host
187
+ const url = `http://${actualHost}:${actualPort}`
188
+
189
+ // Use longer timeout in CI environments to account for slower startup times
190
+ const defaultTimeout = Duration.seconds(IS_CI ? 30 : 5)
191
+
192
+ yield* verifyHttpConnectivity({ url, showLogs, connectTimeout: args.connectTimeout ?? defaultTimeout })
164
193
 
165
194
  if (showLogs) {
166
- yield* Effect.logDebug(`Wrangler dev server ready on port ${port}`)
195
+ yield* Effect.logDebug(
196
+ `Wrangler dev server ready and accepting connections on port ${actualPort} (preferred: ${preferredPort})`,
197
+ )
167
198
  }
168
199
 
169
200
  return {
170
- port,
171
- url: `http://localhost:${port}`,
201
+ port: actualPort,
202
+ url,
172
203
  processId,
173
204
  } satisfies WranglerDevServer
174
205
  }).pipe(
175
206
  Effect.withSpan('WranglerDevServerService', {
176
- attributes: { port: args.port ?? 'auto', cwd: args.cwd },
207
+ attributes: { preferredPort: args.preferredPort ?? 'auto', cwd: args.cwd },
177
208
  }),
178
209
  ),
179
210
  }) {}
@@ -187,20 +218,85 @@ const waitForReady = ({
187
218
  }: {
188
219
  stdout: Stream.Stream<Uint8Array, PlatformError.PlatformError, never>
189
220
  showLogs: boolean
190
- }): Effect.Effect<void, WranglerDevServerError, never> =>
221
+ }): Effect.Effect<{ host: string; port: number }, WranglerDevServerError, never> =>
191
222
  stdout.pipe(
192
223
  Stream.decodeText('utf8'),
193
224
  Stream.splitLines,
194
225
  Stream.tap((line) => (showLogs ? Effect.logDebug(`[wrangler] ${line}`) : Effect.void)),
195
- Stream.takeUntil((line) => line.includes('Ready on')),
196
- Stream.runDrain,
197
- Effect.timeout('30 seconds'),
198
- Effect.mapError(
199
- (error) =>
226
+ // Find the first readiness line and try to parse the port from it
227
+ Stream.filterMap<string, { host: string; port: number }>((line) => {
228
+ if (line.includes('Ready on')) {
229
+ // Expect: "Ready on http://<host>:<port>"
230
+ const m = line.match(/https?:\/\/([^:\s]+):(\d+)/i)
231
+ if (!m) return shouldNeverHappen('Could not parse host:port from Wrangler "Ready on" line', line)
232
+ const host = m[1]! as string
233
+ const port = Number.parseInt(m[2]!, 10)
234
+ if (!Number.isFinite(port)) return shouldNeverHappen('Parsed non-finite port from Wrangler output', line)
235
+ return Option.some({ host, port } as const)
236
+ } else {
237
+ return Option.none()
238
+ }
239
+ }),
240
+ Stream.take(1),
241
+ Stream.runHead,
242
+ Effect.flatten,
243
+ Effect.orElse(
244
+ () =>
245
+ new WranglerDevServerError({
246
+ cause: 'WranglerReadyLineMissing',
247
+ message: 'Wrangler server did not emit a "Ready on" line',
248
+ port: 0,
249
+ }),
250
+ ),
251
+ Effect.timeoutFail({
252
+ duration: '30 seconds',
253
+ onTimeout: () =>
200
254
  new WranglerDevServerError({
201
255
  cause: error,
202
256
  message: 'Wrangler server failed to start within timeout',
203
257
  port: 0,
204
258
  }),
205
- ),
259
+ }),
206
260
  )
261
+
262
+ /**
263
+ * Verifies the server is actually accepting HTTP connections by making a test request
264
+ */
265
+ const verifyHttpConnectivity = ({
266
+ url,
267
+ showLogs,
268
+ connectTimeout,
269
+ }: {
270
+ url: string
271
+ showLogs: boolean
272
+ connectTimeout: Duration.DurationInput
273
+ }): Effect.Effect<void, WranglerDevServerError, HttpClient.HttpClient> =>
274
+ Effect.gen(function* () {
275
+ const client = yield* HttpClient.HttpClient
276
+
277
+ if (showLogs) {
278
+ yield* Effect.logDebug(`Verifying HTTP connectivity to ${url}`)
279
+ }
280
+
281
+ // Try to connect with retries using exponential backoff
282
+ yield* client.get(url).pipe(
283
+ Effect.retryOrElse(
284
+ Schedule.exponential('50 millis', 2).pipe(
285
+ Schedule.jittered,
286
+ Schedule.intersect(Schedule.elapsed.pipe(Schedule.whileOutput(Duration.lessThanOrEqualTo(connectTimeout)))),
287
+ Schedule.compose(Schedule.count),
288
+ ),
289
+ (error, attemptCount) =>
290
+ Effect.fail(
291
+ new WranglerDevServerError({
292
+ cause: error,
293
+ message: `Failed to establish HTTP connection to Wrangler server at ${url} after ${attemptCount} attempts (timeout: ${Duration.toMillis(connectTimeout)}ms)`,
294
+ port: 0,
295
+ }),
296
+ ),
297
+ ),
298
+ Effect.tap(() => (showLogs ? Effect.logDebug(`HTTP connectivity verified for ${url}`) : Effect.void)),
299
+ Effect.asVoid,
300
+ Effect.withSpan('verifyHttpConnectivity'),
301
+ )
302
+ })
@@ -20,12 +20,17 @@ export * from '@effect/vitest'
20
20
 
21
21
  export const DEBUGGER_ACTIVE = Boolean(process.env.DEBUGGER_ACTIVE ?? inspector.url() !== undefined)
22
22
 
23
- export const makeWithTestCtx =
24
- <R1 = never, E1 = never>(ctxParams: WithTestCtxParams<R1, E1>) =>
25
- (testContext: Vitest.TestContext) =>
23
+ export const makeWithTestCtx: <R1, E1>(
24
+ ctxParams: WithTestCtxParams<R1, E1>,
25
+ ) => (
26
+ testContext: Vitest.TestContext,
27
+ ) => <A, E>(
28
+ self: Effect.Effect<A, E, Scope.Scope | NoInfer<R1> | OtelTracer.OtelTracer>,
29
+ ) => Effect.Effect<A, E1 | Cause.TimeoutException | E, Scope.Scope> =
30
+ (ctxParams) => (testContext: Vitest.TestContext) =>
26
31
  withTestCtx(testContext, ctxParams)
27
32
 
28
- export type WithTestCtxParams<R1 = never, E1 = never> = {
33
+ export type WithTestCtxParams<R1, E1> = {
29
34
  suffix?: string
30
35
  makeLayer?: (testContext: Vitest.TestContext) => Layer.Layer<R1, E1, Scope.Scope>
31
36
  timeout?: Duration.DurationInput