@docker-harpoon/core 0.1.3 → 0.1.5

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 (98) hide show
  1. package/dist/__tests__/bindings.test.js +9 -5
  2. package/dist/__tests__/container.test.js +30 -9
  3. package/dist/__tests__/database.test.js +0 -14
  4. package/dist/__tests__/docker-infra.template.d.ts +2 -0
  5. package/dist/__tests__/docker-infra.template.d.ts.map +1 -0
  6. package/dist/__tests__/docker-infra.template.js +174 -0
  7. package/dist/__tests__/test-setup.d.ts +9 -0
  8. package/dist/__tests__/test-setup.d.ts.map +1 -0
  9. package/dist/__tests__/test-setup.js +27 -0
  10. package/dist/api/index.d.ts +1 -1
  11. package/dist/api/index.d.ts.map +1 -1
  12. package/dist/api/promise.d.ts +13 -3
  13. package/dist/api/promise.d.ts.map +1 -1
  14. package/dist/api/promise.js +33 -18
  15. package/dist/bindings/index.d.ts +2 -2
  16. package/dist/bindings/index.d.ts.map +1 -1
  17. package/dist/bindings/index.js +1 -1
  18. package/dist/bindings/types.d.ts.map +1 -1
  19. package/dist/bindings/types.js +1 -3
  20. package/dist/build-strategies/types.d.ts.map +1 -1
  21. package/dist/config-patchers/index.d.ts.map +1 -1
  22. package/dist/config-patchers/types.d.ts.map +1 -1
  23. package/dist/dockerfile-transformers/core.d.ts.map +1 -1
  24. package/dist/dockerfile-transformers/core.js +2 -5
  25. package/dist/dockerfile-transformers/index.d.ts.map +1 -1
  26. package/dist/dockerfile-transformers/types.d.ts.map +1 -1
  27. package/dist/errors.d.ts +6 -1
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +3 -3
  30. package/dist/helpers/database.d.ts.map +1 -1
  31. package/dist/helpers/database.js +1 -3
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/resources/container.d.ts +25 -2
  36. package/dist/resources/container.d.ts.map +1 -1
  37. package/dist/resources/container.js +301 -77
  38. package/dist/resources/image.d.ts.map +1 -1
  39. package/dist/resources/image.js +14 -35
  40. package/dist/resources/index.d.ts +1 -1
  41. package/dist/resources/index.d.ts.map +1 -1
  42. package/dist/resources/network.d.ts.map +1 -1
  43. package/dist/resources/network.js +23 -9
  44. package/dist/resources/schemas.d.ts +178 -18
  45. package/dist/resources/schemas.d.ts.map +1 -1
  46. package/dist/resources/schemas.js +2 -1
  47. package/dist/services/CircuitBreaker.d.ts +83 -0
  48. package/dist/services/CircuitBreaker.d.ts.map +1 -0
  49. package/dist/services/CircuitBreaker.js +164 -0
  50. package/dist/services/ContainerPool.d.ts +82 -0
  51. package/dist/services/ContainerPool.d.ts.map +1 -0
  52. package/dist/services/ContainerPool.js +186 -0
  53. package/dist/services/DockerBatcher.d.ts +74 -0
  54. package/dist/services/DockerBatcher.d.ts.map +1 -0
  55. package/dist/services/DockerBatcher.js +107 -0
  56. package/dist/services/DockerClient.d.ts +125 -0
  57. package/dist/services/DockerClient.d.ts.map +1 -0
  58. package/dist/services/DockerClient.js +220 -0
  59. package/dist/services/DockerErrors.d.ts +145 -0
  60. package/dist/services/DockerErrors.d.ts.map +1 -0
  61. package/dist/services/DockerErrors.js +224 -0
  62. package/dist/services/DockerRateLimiter.d.ts +80 -0
  63. package/dist/services/DockerRateLimiter.d.ts.map +1 -0
  64. package/dist/services/DockerRateLimiter.js +93 -0
  65. package/dist/services/EventBus.d.ts +126 -0
  66. package/dist/services/EventBus.d.ts.map +1 -0
  67. package/dist/services/EventBus.js +111 -0
  68. package/dist/services/Harpoon.d.ts +151 -0
  69. package/dist/services/Harpoon.d.ts.map +1 -0
  70. package/dist/services/Harpoon.js +148 -0
  71. package/dist/services/HarpoonConfig.d.ts +60 -0
  72. package/dist/services/HarpoonConfig.d.ts.map +1 -0
  73. package/dist/services/HarpoonConfig.js +67 -0
  74. package/dist/services/HarpoonLogger.d.ts +36 -0
  75. package/dist/services/HarpoonLogger.d.ts.map +1 -0
  76. package/dist/services/HarpoonLogger.js +94 -0
  77. package/dist/services/ReadinessCoordinator.d.ts +128 -0
  78. package/dist/services/ReadinessCoordinator.d.ts.map +1 -0
  79. package/dist/services/ReadinessCoordinator.js +170 -0
  80. package/dist/services/ResourceTracker.d.ts +74 -0
  81. package/dist/services/ResourceTracker.d.ts.map +1 -0
  82. package/dist/services/ResourceTracker.js +145 -0
  83. package/dist/services/index.d.ts +29 -0
  84. package/dist/services/index.d.ts.map +1 -0
  85. package/dist/services/index.js +47 -0
  86. package/dist/testing/helpers.d.ts +114 -0
  87. package/dist/testing/helpers.d.ts.map +1 -0
  88. package/dist/testing/helpers.js +140 -0
  89. package/dist/testing/index.d.ts +29 -0
  90. package/dist/testing/index.d.ts.map +1 -0
  91. package/dist/testing/index.js +47 -0
  92. package/dist/testing/mocks.d.ts +66 -0
  93. package/dist/testing/mocks.d.ts.map +1 -0
  94. package/dist/testing/mocks.js +224 -0
  95. package/dist/utils/process.d.ts +24 -0
  96. package/dist/utils/process.d.ts.map +1 -0
  97. package/dist/utils/process.js +49 -0
  98. package/package.json +12 -8
@@ -6,17 +6,28 @@
6
6
  *
7
7
  * Supports bindings for environment variable injection and lifecycle hooks.
8
8
  */
9
- import { Effect, Schedule, Exit } from 'effect';
9
+ import { Effect, Schedule, Exit, Stream } from 'effect';
10
10
  import { toHarpoonError, ContainerError } from '../errors';
11
11
  import { mergeBindingsEnv } from '../bindings/types';
12
12
  import { containerStatsSchema, execOptionsSchema, execResultSchema, logOptionsSchema, } from './schemas';
13
13
  const CLEANUP_TIMEOUT_MS = 30_000; // 30 seconds for database graceful shutdown
14
+ const makeContainerLogger = (containerName) => ({
15
+ debug: (message, meta) => {
16
+ if (process.env.HARPOON_LOG_LEVEL === 'debug') {
17
+ console.log(`[Container:${containerName}] ${message}`, meta ?? '');
18
+ }
19
+ },
20
+ info: (message, _meta) => console.log(`[Container:${containerName}] ${message}`),
21
+ warn: (message, _meta) => console.warn(`[Container:${containerName}] ${message}`),
22
+ error: (message, meta) => console.error(`[Container:${containerName}] ${message}`, meta ?? ''),
23
+ });
14
24
  const safeRemoveContainer = async (docker, containerId, containerName) => {
15
25
  const container = docker.getContainer(containerId);
26
+ const log = makeContainerLogger(containerName);
16
27
  try {
17
28
  const data = await container.inspect();
18
29
  if (data.State.Running) {
19
- console.log(`[Cleanup] Stopping container: ${containerName}`);
30
+ log.info('Stopping container');
20
31
  try {
21
32
  await container.kill({ signal: 'SIGTERM' });
22
33
  await new Promise((resolve) => {
@@ -46,7 +57,7 @@ const safeRemoveContainer = async (docker, containerId, containerName) => {
46
57
  setTimeout(checkStopped, 100);
47
58
  };
48
59
  const timeout = setTimeout(() => {
49
- console.warn(`[Cleanup] Container ${containerName} did not stop gracefully, forcing`);
60
+ log.warn('Container did not stop gracefully, forcing');
50
61
  container.kill({ signal: 'SIGKILL' }).catch(() => { });
51
62
  safeResolve();
52
63
  }, 5000);
@@ -55,33 +66,34 @@ const safeRemoveContainer = async (docker, containerId, containerName) => {
55
66
  }
56
67
  catch (e) {
57
68
  if (!e.message?.includes('is not running')) {
58
- console.warn(`[Cleanup] Error stopping ${containerName}: ${e.message}`);
69
+ log.warn(`Error stopping: ${e.message}`);
59
70
  }
60
71
  }
61
72
  }
62
73
  await container.remove({ force: true });
63
- console.log(`[Cleanup] Container removed: ${containerName}`);
74
+ log.info('Container removed');
64
75
  }
65
76
  catch (e) {
66
77
  if (e.statusCode === 404) {
67
- console.log(`[Cleanup] Container ${containerName} already removed`);
78
+ log.debug('Container already removed');
68
79
  }
69
80
  else {
70
- console.error(`[Cleanup] Error removing container ${containerName}:`, e.message);
81
+ log.error(`Error removing container: ${e.message}`);
71
82
  }
72
83
  }
73
84
  };
74
85
  export const Container = (resourceId, config, docker) => Effect.acquireRelease(Effect.tryPromise({
75
86
  try: async () => {
87
+ const log = makeContainerLogger(resourceId);
76
88
  try {
77
89
  const existingContainer = docker.getContainer(resourceId);
78
90
  await existingContainer.inspect();
79
- console.log(`[Container] Found existing container ${resourceId}, removing...`);
91
+ log.info('Found existing container, removing...');
80
92
  await safeRemoveContainer(docker, resourceId, resourceId);
81
93
  }
82
94
  catch (e) {
83
95
  if (e.statusCode !== 404) {
84
- console.warn(`[Container] Warning checking for existing container: ${e.message}`);
96
+ log.warn(`Warning checking for existing container: ${e.message}`);
85
97
  }
86
98
  }
87
99
  if (config.ports?.length) {
@@ -95,12 +107,12 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
95
107
  if (!usesConflictingPort)
96
108
  continue;
97
109
  const name = c.Names?.[0]?.replace(/^\//, '') ?? c.Id.substring(0, 12);
98
- console.log(`[Container] Found container "${name}" using port(s) ${externalPorts.join(', ')}, removing...`);
110
+ log.info(`Found container "${name}" using port(s) ${externalPorts.join(', ')}, removing...`);
99
111
  await safeRemoveContainer(docker, c.Id, name);
100
112
  }
101
113
  }
102
114
  catch (e) {
103
- console.warn(`[Container] Warning checking for port conflicts: ${e.message}`);
115
+ log.warn(`Warning checking for port conflicts: ${e.message}`);
104
116
  }
105
117
  }
106
118
  const PortBindings = {};
@@ -113,7 +125,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
113
125
  try {
114
126
  const image = docker.getImage(config.image);
115
127
  await image.inspect();
116
- console.log(`[Container] Verified image exists: ${config.image}`);
128
+ log.debug(`Verified image exists: ${config.image}`);
117
129
  }
118
130
  catch (e) {
119
131
  if (e.statusCode === 404) {
@@ -127,9 +139,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
127
139
  throw new Error(`Failed to verify image "${config.image}": ${e.message}`);
128
140
  }
129
141
  // Merge env vars from bindings
130
- const bindingsEnv = config.bindings
131
- ? mergeBindingsEnv(config.bindings)
132
- : {};
142
+ const bindingsEnv = config.bindings ? mergeBindingsEnv(config.bindings) : {};
133
143
  const finalEnv = {
134
144
  ...bindingsEnv,
135
145
  ...config.env, // User env takes precedence
@@ -142,9 +152,14 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
142
152
  NetworkMode: config.networks?.[0]?.name,
143
153
  AutoRemove: false,
144
154
  PortBindings,
155
+ Binds: config.volumes?.map((v) => `${v.hostPath}:${v.containerPath}${v.readonly ? ':ro' : ''}`),
156
+ CapAdd: config.capAdd,
157
+ CapDrop: config.capDrop,
158
+ Privileged: config.privileged,
145
159
  },
146
160
  ExposedPorts,
147
161
  Cmd: config.cmd,
162
+ User: config.user,
148
163
  StopSignal: 'SIGTERM',
149
164
  StopTimeout: 10,
150
165
  });
@@ -229,8 +244,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
229
244
  getIp: (networkName) => Effect.tryPromise({
230
245
  try: async () => {
231
246
  const data = await container.inspect();
232
- return (data.NetworkSettings.Networks[networkName]?.IPAddress ??
233
- 'localhost');
247
+ return data.NetworkSettings.Networks[networkName]?.IPAddress ?? 'localhost';
234
248
  },
235
249
  catch: (e) => new Error(`getIp failed: ${e}`),
236
250
  }),
@@ -244,12 +258,9 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
244
258
  // Calculate CPU percentage
245
259
  const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
246
260
  statsData.precpu_stats.cpu_usage.total_usage;
247
- const systemDelta = statsData.cpu_stats.system_cpu_usage -
248
- statsData.precpu_stats.system_cpu_usage;
261
+ const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
249
262
  const cpuCores = statsData.cpu_stats.online_cpus || 1;
250
- const cpuPercent = systemDelta > 0
251
- ? (cpuDelta / systemDelta) * cpuCores * 100
252
- : 0;
263
+ const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
253
264
  // Calculate memory stats
254
265
  const memUsage = statsData.memory_stats?.usage ?? 0;
255
266
  const memLimit = statsData.memory_stats?.limit ?? 0;
@@ -294,6 +305,67 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
294
305
  },
295
306
  catch: (e) => new Error(`stats failed: ${e}`),
296
307
  }),
308
+ /**
309
+ * Stream container stats as an Effect Stream.
310
+ * Polls every 1 second using Stream.repeatEffectWithSchedule.
311
+ */
312
+ statsStream: () => {
313
+ const getStats = Effect.tryPromise({
314
+ try: async () => {
315
+ const statsData = await container.stats({ stream: false });
316
+ // Calculate CPU percentage
317
+ const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
318
+ statsData.precpu_stats.cpu_usage.total_usage;
319
+ const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
320
+ const cpuCores = statsData.cpu_stats.online_cpus || 1;
321
+ const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
322
+ // Calculate memory stats
323
+ const memUsage = statsData.memory_stats?.usage ?? 0;
324
+ const memLimit = statsData.memory_stats?.limit ?? 0;
325
+ const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100 : 0;
326
+ // Calculate network stats (sum all interfaces)
327
+ const networks = statsData.networks ?? {};
328
+ let rxBytes = 0;
329
+ let txBytes = 0;
330
+ let rxPackets = 0;
331
+ let txPackets = 0;
332
+ for (const iface of Object.values(networks)) {
333
+ rxBytes += iface.rx_bytes ?? 0;
334
+ txBytes += iface.tx_bytes ?? 0;
335
+ rxPackets += iface.rx_packets ?? 0;
336
+ txPackets += iface.tx_packets ?? 0;
337
+ }
338
+ const result = {
339
+ cpu: {
340
+ usage: statsData.cpu_stats.cpu_usage.total_usage,
341
+ system: statsData.cpu_stats.system_cpu_usage,
342
+ percent: Math.round(cpuPercent * 100) / 100,
343
+ cores: cpuCores,
344
+ },
345
+ memory: {
346
+ usage: memUsage,
347
+ limit: memLimit,
348
+ percent: Math.round(memPercent * 100) / 100,
349
+ usageMB: Math.round((memUsage / 1024 / 1024) * 100) / 100,
350
+ limitMB: Math.round((memLimit / 1024 / 1024) * 100) / 100,
351
+ },
352
+ network: {
353
+ rxBytes,
354
+ txBytes,
355
+ rxPackets,
356
+ txPackets,
357
+ },
358
+ timestamp: new Date().toISOString(),
359
+ containerId: container.id,
360
+ };
361
+ return containerStatsSchema.parse(result);
362
+ },
363
+ catch: (e) => toHarpoonError(new Error(`statsStream failed: ${e}`), resourceId, ContainerError),
364
+ });
365
+ // Poll every 1 second
366
+ const schedule = Schedule.spaced('1 seconds');
367
+ return Stream.repeatEffectWithSchedule(getStats, schedule);
368
+ },
297
369
  /**
298
370
  * Execute a command inside the container.
299
371
  * Returns stdout, stderr, and exit code.
@@ -376,69 +448,218 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
376
448
  const stdout = opts.stdout ?? true;
377
449
  const stderr = opts.stderr ?? true;
378
450
  const timestamps = opts.timestamps ?? false;
379
- const stream = await container.logs({
380
- follow: true,
381
- stdout,
382
- stderr,
383
- since: opts.since,
384
- until: opts.until,
385
- tail: opts.tail,
386
- timestamps,
387
- });
388
- // Create a wrapper to make the stream async iterable
389
- const readable = stream;
390
- // Convert NodeJS.ReadableStream to AsyncIterable<LogLine>
391
- async function* logIterator() {
392
- const chunks = [];
393
- // Use Promise-based iteration for ReadableStream
394
- for await (const chunk of readable) {
395
- // Parse Docker log format (8-byte header + payload)
396
- let offset = 0;
397
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
398
- while (offset < buffer.length) {
399
- if (offset + 8 > buffer.length) {
400
- // Incomplete header, yield rest as stdout
451
+ const follow = opts.follow ?? false;
452
+ // Helper to parse Docker log format from a buffer
453
+ function* parseLogBuffer(buffer) {
454
+ let offset = 0;
455
+ while (offset < buffer.length) {
456
+ if (offset + 8 > buffer.length) {
457
+ // Incomplete header, yield rest as stdout
458
+ const remaining = buffer.slice(offset).toString('utf-8').trim();
459
+ if (remaining) {
401
460
  yield {
402
461
  stream: 'stdout',
403
- message: buffer.slice(offset).toString('utf-8').trim(),
462
+ message: remaining,
404
463
  };
405
- break;
406
464
  }
407
- const streamType = buffer.readUInt8(offset);
408
- const size = buffer.readUInt32BE(offset + 4);
409
- if (offset + 8 + size > buffer.length) {
410
- // Incomplete payload
465
+ break;
466
+ }
467
+ const streamType = buffer.readUInt8(offset);
468
+ const size = buffer.readUInt32BE(offset + 4);
469
+ if (offset + 8 + size > buffer.length) {
470
+ // Incomplete payload
471
+ const remaining = buffer
472
+ .slice(offset + 8)
473
+ .toString('utf-8')
474
+ .trim();
475
+ if (remaining) {
411
476
  yield {
412
477
  stream: streamType === 2 ? 'stderr' : 'stdout',
413
- message: buffer.slice(offset + 8).toString('utf-8').trim(),
478
+ message: remaining,
414
479
  };
415
- break;
416
480
  }
417
- const payload = buffer.slice(offset + 8, offset + 8 + size);
418
- const message = payload.toString('utf-8');
419
- // Parse timestamp if present (format: "2024-01-14T21:00:00.000000000Z message")
420
- let timestamp;
421
- let content = message;
422
- if (timestamps) {
423
- const match = message.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/s);
424
- if (match) {
425
- timestamp = match[1];
426
- content = match[2] ?? '';
427
- }
481
+ break;
482
+ }
483
+ const payload = buffer.slice(offset + 8, offset + 8 + size);
484
+ const message = payload.toString('utf-8');
485
+ // Parse timestamp if present (format: "2024-01-14T21:00:00.000000000Z message")
486
+ let timestamp;
487
+ let content = message;
488
+ if (timestamps) {
489
+ const match = message.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/s);
490
+ if (match) {
491
+ timestamp = match[1];
492
+ content = match[2] ?? '';
428
493
  }
429
- yield {
430
- stream: streamType === 2 ? 'stderr' : 'stdout',
431
- timestamp,
432
- message: content.trim(),
433
- };
434
- offset += 8 + size;
435
494
  }
495
+ yield {
496
+ stream: streamType === 2 ? 'stderr' : 'stdout',
497
+ timestamp,
498
+ message: content.trim(),
499
+ };
500
+ offset += 8 + size;
436
501
  }
437
502
  }
438
- return logIterator();
503
+ // Dockerode has different return types based on follow option:
504
+ // - follow: false (or undefined) returns Buffer
505
+ // - follow: true returns NodeJS.ReadableStream
506
+ // We branch explicitly so TypeScript can infer correct types
507
+ if (follow) {
508
+ const readable = await container.logs({
509
+ follow: true,
510
+ stdout,
511
+ stderr,
512
+ since: opts.since,
513
+ until: opts.until,
514
+ tail: opts.tail,
515
+ timestamps,
516
+ });
517
+ async function* streamIterator() {
518
+ for await (const chunk of readable) {
519
+ if (!Buffer.isBuffer(chunk) && typeof chunk !== 'string') {
520
+ continue;
521
+ }
522
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
523
+ yield* parseLogBuffer(buffer);
524
+ }
525
+ }
526
+ return streamIterator();
527
+ }
528
+ // Non-follow mode: Docker returns Buffer directly
529
+ const buffer = await container.logs({
530
+ follow: false,
531
+ stdout,
532
+ stderr,
533
+ since: opts.since,
534
+ until: opts.until,
535
+ tail: opts.tail,
536
+ timestamps,
537
+ });
538
+ async function* bufferIterator() {
539
+ yield* parseLogBuffer(buffer);
540
+ }
541
+ return bufferIterator();
439
542
  },
440
543
  catch: (e) => new Error(`streamLogs failed: ${e}`),
441
544
  }),
545
+ /**
546
+ * Stream container logs as an Effect Stream.
547
+ * Internal API for Effect-based consumers.
548
+ * Uses Stream.asyncScoped for follow mode to ensure proper cleanup.
549
+ */
550
+ logsStream: (options) => {
551
+ const opts = logOptionsSchema.parse(options ?? {});
552
+ const stdout = opts.stdout ?? true;
553
+ const stderr = opts.stderr ?? true;
554
+ const timestamps = opts.timestamps ?? false;
555
+ const follow = opts.follow ?? false;
556
+ // Helper to parse Docker log format from a buffer
557
+ const parseLogBuffer = (buffer) => {
558
+ const lines = [];
559
+ let bufferOffset = 0;
560
+ while (bufferOffset < buffer.length) {
561
+ if (bufferOffset + 8 > buffer.length) {
562
+ const remaining = buffer.slice(bufferOffset).toString('utf-8').trim();
563
+ if (remaining) {
564
+ lines.push({ stream: 'stdout', message: remaining });
565
+ }
566
+ break;
567
+ }
568
+ const streamType = buffer.readUInt8(bufferOffset);
569
+ const size = buffer.readUInt32BE(bufferOffset + 4);
570
+ if (bufferOffset + 8 + size > buffer.length) {
571
+ const remaining = buffer
572
+ .slice(bufferOffset + 8)
573
+ .toString('utf-8')
574
+ .trim();
575
+ if (remaining) {
576
+ lines.push({
577
+ stream: streamType === 2 ? 'stderr' : 'stdout',
578
+ message: remaining,
579
+ });
580
+ }
581
+ break;
582
+ }
583
+ const payload = buffer.slice(bufferOffset + 8, bufferOffset + 8 + size);
584
+ const message = payload.toString('utf-8');
585
+ let timestamp;
586
+ let content = message;
587
+ if (timestamps) {
588
+ const match = message.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/s);
589
+ if (match) {
590
+ timestamp = match[1];
591
+ content = match[2] ?? '';
592
+ }
593
+ }
594
+ lines.push({
595
+ stream: streamType === 2 ? 'stderr' : 'stdout',
596
+ timestamp,
597
+ message: content.trim(),
598
+ });
599
+ bufferOffset += 8 + size;
600
+ }
601
+ return lines;
602
+ };
603
+ if (follow) {
604
+ // Follow mode: use Stream.asyncScoped for proper resource cleanup
605
+ return Stream.asyncScoped((emit) => Effect.gen(function* () {
606
+ const readable = yield* Effect.tryPromise({
607
+ try: () => container.logs({
608
+ follow: true,
609
+ stdout,
610
+ stderr,
611
+ since: opts.since,
612
+ until: opts.until,
613
+ tail: opts.tail,
614
+ timestamps,
615
+ }),
616
+ catch: (e) => toHarpoonError(new Error(`logsStream failed: ${e}`), resourceId, ContainerError),
617
+ });
618
+ // Set up event handlers
619
+ const onData = (chunk) => {
620
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
621
+ const lines = parseLogBuffer(buffer);
622
+ for (const line of lines) {
623
+ emit.single(line);
624
+ }
625
+ };
626
+ const onError = (err) => {
627
+ emit.fail(toHarpoonError(err, resourceId, ContainerError));
628
+ };
629
+ const onEnd = () => {
630
+ emit.end();
631
+ };
632
+ readable.on('data', onData);
633
+ readable.on('error', onError);
634
+ readable.on('end', onEnd);
635
+ // Register cleanup on scope close
636
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
637
+ readable.removeListener('data', onData);
638
+ readable.removeListener('error', onError);
639
+ readable.removeListener('end', onEnd);
640
+ if (readable.destroy) {
641
+ readable.destroy();
642
+ }
643
+ }));
644
+ }));
645
+ }
646
+ // Non-follow mode: emit all lines from buffer
647
+ return Stream.fromEffect(Effect.tryPromise({
648
+ try: async () => {
649
+ const buffer = await container.logs({
650
+ follow: false,
651
+ stdout,
652
+ stderr,
653
+ since: opts.since,
654
+ until: opts.until,
655
+ tail: opts.tail,
656
+ timestamps,
657
+ });
658
+ return parseLogBuffer(buffer);
659
+ },
660
+ catch: (e) => toHarpoonError(new Error(`logsStream failed: ${e}`), resourceId, ContainerError),
661
+ })).pipe(Stream.flatMap((lines) => Stream.fromIterable(lines)));
662
+ },
442
663
  };
443
664
  // Call onStart hooks for bindings
444
665
  // This is done in Effect context for proper error handling
@@ -446,19 +667,20 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
446
667
  },
447
668
  catch: (e) => new Error(`Container "${resourceId}" creation failed: ${e}`),
448
669
  }).pipe(Effect.mapError((e) => toHarpoonError(e, resourceId, ContainerError)),
449
- // Run onStart hooks after container creation
670
+ // Run onStart hooks after container creation (in parallel for performance)
450
671
  Effect.tap((resource) => Effect.gen(function* () {
451
672
  if (config.bindings) {
452
- for (const binding of Object.values(config.bindings)) {
453
- if (binding.onStart) {
454
- yield* binding.onStart(resource);
455
- }
673
+ const onStartHooks = Object.values(config.bindings)
674
+ .filter((binding) => binding.onStart !== undefined)
675
+ .map((binding) => binding.onStart(resource));
676
+ if (onStartHooks.length > 0) {
677
+ yield* Effect.all(onStartHooks, { concurrency: 4 });
456
678
  }
457
679
  }
458
680
  }))), (resource, exit) => Effect.gen(function* () {
459
681
  const isInterrupted = Exit.isInterrupted(exit);
460
682
  if (isInterrupted) {
461
- console.log(`[Cleanup] Interrupted - force cleaning container: ${resource.name}`);
683
+ yield* Effect.logDebug('Interrupted - force cleaning container').pipe(Effect.annotateLogs({ service: 'Container', containerId: resource.id }));
462
684
  }
463
685
  yield* Effect.tryPromise({
464
686
  try: () => Promise.race([
@@ -466,7 +688,9 @@ Effect.tap((resource) => Effect.gen(function* () {
466
688
  new Promise((_, reject) => setTimeout(() => reject(new Error('Cleanup timeout')), CLEANUP_TIMEOUT_MS)),
467
689
  ]),
468
690
  catch: (e) => {
469
- console.error(`[Cleanup] Failed to clean up container ${resource.name}:`, e);
691
+ // Log error but don't fail - cleanup should be best effort
692
+ const log = makeContainerLogger(resource.name);
693
+ log.error(`Failed to clean up container`, { error: e });
470
694
  return null;
471
695
  },
472
696
  });
@@ -1 +1 @@
1
- {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../../src/resources/image.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKhC,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kDAAkD;IAClD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uCAAuC;IACvC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,kFA4HmD,CAAC"}
1
+ {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../../src/resources/image.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAMhC,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kDAAkD;IAClD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uCAAuC;IACvC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,kFAgGmD,CAAC"}
@@ -8,6 +8,7 @@ import { Effect } from 'effect';
8
8
  import * as fs from 'fs/promises';
9
9
  import { toHarpoonError, ImageError } from '../errors';
10
10
  import { getBuildStrategy } from '../build-strategies';
11
+ import { execCommand } from '../utils/process';
11
12
  /**
12
13
  * Build a Docker image using the configured strategy.
13
14
  *
@@ -45,34 +46,18 @@ export const Image = (tag, config) => Effect.gen(function* () {
45
46
  }
46
47
  }
47
48
  args.push(buildContext.contextPath);
48
- const proc = Bun.spawn(args, {
49
- stdout: 'ignore',
50
- stderr: 'pipe',
51
- });
52
- const stderrOutput = [];
53
- const errorReader = async () => {
54
- if (!proc.stderr)
55
- return;
56
- const reader = proc.stderr.getReader();
57
- const decoder = new TextDecoder();
58
- try {
59
- while (true) {
60
- const { done, value } = await reader.read();
61
- if (done)
62
- break;
63
- const output = decoder.decode(value);
64
- stderrOutput.push(output);
65
- if (output.toLowerCase().includes('error')) {
66
- console.error(`[${tag}] ${output.trim()}`);
67
- }
49
+ // Execute docker build using cross-runtime utility
50
+ const buildResult = await execCommand('docker', args.slice(1), { captureStderr: true });
51
+ // Log errors from stderr if present
52
+ if (buildResult.stderr) {
53
+ for (const line of buildResult.stderr.split('\n')) {
54
+ if (line.toLowerCase().includes('error')) {
55
+ console.error(`[${tag}] ${line.trim()}`);
68
56
  }
69
57
  }
70
- catch (e) {
71
- console.error(`[${tag}] Error reading stderr:`, e);
72
- }
73
- };
74
- await errorReader();
75
- const exitCode = await proc.exited;
58
+ }
59
+ const exitCode = buildResult.exitCode;
60
+ const stderrOutput = buildResult.stderr ? [buildResult.stderr] : [];
76
61
  // Cleanup temp directory if needed
77
62
  if (buildContext.requiresCleanup && buildContext.tempDir) {
78
63
  await fs.rm(buildContext.tempDir, { recursive: true, force: true });
@@ -84,16 +69,10 @@ export const Image = (tag, config) => Effect.gen(function* () {
84
69
  // Verify image was created
85
70
  let verified = false;
86
71
  for (let attempt = 0; attempt < 5; attempt++) {
87
- const verifyProc = Bun.spawn(['docker', 'images', '-q', tag], {
88
- stdout: 'pipe',
89
- stderr: 'pipe',
72
+ const verifyResult = await execCommand('docker', ['images', '-q', tag], {
73
+ captureStdout: true,
90
74
  });
91
- let verifyOutput = '';
92
- if (verifyProc.stdout) {
93
- verifyOutput = await Bun.readableStreamToText(verifyProc.stdout);
94
- }
95
- const verifyExitCode = await verifyProc.exited;
96
- if (verifyExitCode === 0 && verifyOutput.trim()) {
75
+ if (verifyResult.exitCode === 0 && verifyResult.stdout.trim()) {
97
76
  verified = true;
98
77
  break;
99
78
  }
@@ -6,7 +6,7 @@
6
6
  export { Network } from './network';
7
7
  export type { NetworkConfig, NetworkResource } from './network';
8
8
  export { Container } from './container';
9
- export type { ContainerConfig, ContainerResource, PortMapping, ShutdownMetadata, } from './container';
9
+ export type { ContainerConfig, ContainerResource, PortMapping, ShutdownMetadata, VolumeMapping, } from './container';
10
10
  export { containerStatsSchema, execOptionsSchema, execResultSchema, logOptionsSchema, logLineSchema, cpuStatsSchema, memoryStatsSchema, networkStatsSchema, statsOptionsSchema, } from './schemas';
11
11
  export type { ContainerStats, CpuStats, MemoryStats, NetworkStats, ExecOptions, ExecResult, LogOptions, LogLine, StatsOptions, } from './schemas';
12
12
  export { Image } from './image';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/resources/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEhE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,cAAc,EACd,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,OAAO,EACP,YAAY,GACb,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/resources/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEhE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,aAAa,GACd,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,cAAc,EACd,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,OAAO,EACP,YAAY,GACb,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/resources/network.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAQ,MAAM,QAAQ,CAAC;AAC7C,OAAO,MAAM,MAAM,WAAW,CAAC;AAG/B,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAiCD;;GAEG;AACH,eAAO,MAAM,OAAO,mHAmFjB,CAAC"}
1
+ {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/resources/network.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAQ,MAAM,QAAQ,CAAC;AAC7C,OAAO,MAAM,MAAM,WAAW,CAAC;AAG/B,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAmDD;;GAEG;AACH,eAAO,MAAM,OAAO,mHA2EjB,CAAC"}