cuttlely 1.3.0 → 1.3.1
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/README.md +1 -1
- package/lib/cli.js +92 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npx cuttlely start
|
|
|
15
15
|
That pulls `ghcr.io/cuttlely/cuttlely`, tagged with the same version as the `cuttlely` package you ran, keeps data in the Docker volume `cuttlely-data`, publishes port **43117**, waits until `http://127.0.0.1:43117/api/v1/ping` answers `pong`, prints `http://localhost:43117`, and opens it.
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npx cuttlely start --port 8080 --tag 1.3.
|
|
18
|
+
npx cuttlely start --port 8080 --tag 1.3.1 --env-file .env --no-open
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
| Flag | Meaning |
|
package/lib/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// This package only talks to the local Docker daemon.
|
|
3
3
|
import { readFileSync, statSync } from 'node:fs'
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
|
+
import { get as httpGet } from 'node:http'
|
|
5
6
|
import { dirname, join } from 'node:path'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
7
8
|
import { RESOURCE_ID_RE, runCheck } from './check.js'
|
|
@@ -17,6 +18,21 @@ export const VOLUME_NAME = 'cuttlely-data'
|
|
|
17
18
|
export const DATA_DIR = '/var/cuttlely'
|
|
18
19
|
export const INSTALL_DOCS = 'https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/docker.md'
|
|
19
20
|
export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
|
|
21
|
+
// Each health try gets its own limit, so one stuck request cannot use up the whole wait.
|
|
22
|
+
export const HEALTH_TRY_TIMEOUT_MS = 5000
|
|
23
|
+
// Error codes for a connection that the port accepts and then drops while the container boots.
|
|
24
|
+
export const RETRYABLE_HEALTH_ERRORS = new Set([
|
|
25
|
+
'ECONNRESET',
|
|
26
|
+
'ECONNREFUSED',
|
|
27
|
+
'ECONNABORTED',
|
|
28
|
+
'EPIPE',
|
|
29
|
+
'ETIMEDOUT',
|
|
30
|
+
'EHOSTUNREACH',
|
|
31
|
+
'UND_ERR_SOCKET',
|
|
32
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
33
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
34
|
+
'HEALTH_TRY_TIMEOUT'
|
|
35
|
+
])
|
|
20
36
|
|
|
21
37
|
const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
|
|
22
38
|
const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
|
|
@@ -457,26 +473,86 @@ async function readImageId(docker, image) {
|
|
|
457
473
|
return result.stdout.trim()
|
|
458
474
|
}
|
|
459
475
|
|
|
460
|
-
|
|
476
|
+
function healthErrorCode(error) {
|
|
477
|
+
return error?.code || error?.cause?.code || error?.name || 'UNKNOWN'
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// One health try with its own timeout. The timer is a normal (ref'd) timer, so a request that never settles still
|
|
481
|
+
// keeps the event loop alive until it fires; AbortSignal.timeout alone is unref'd and does not.
|
|
482
|
+
function healthTry(fetchImpl, url, tryTimeoutMs) {
|
|
483
|
+
const controller = new AbortController()
|
|
484
|
+
let timer
|
|
485
|
+
const timeout = new Promise((_, reject) => {
|
|
486
|
+
timer = setTimeout(() => {
|
|
487
|
+
const error = new Error(`No answer from ${url} within ${tryTimeoutMs} ms`)
|
|
488
|
+
error.code = 'HEALTH_TRY_TIMEOUT'
|
|
489
|
+
controller.abort(error)
|
|
490
|
+
reject(error)
|
|
491
|
+
}, tryTimeoutMs)
|
|
492
|
+
})
|
|
493
|
+
const attempt = (async () => {
|
|
494
|
+
const res = await fetchImpl(url, { redirect: 'manual', signal: controller.signal })
|
|
495
|
+
const body = typeof res.text === 'function' ? await res.text() : String(res.body ?? '')
|
|
496
|
+
return res.ok && body.includes('pong')
|
|
497
|
+
})()
|
|
498
|
+
attempt.catch(() => {})
|
|
499
|
+
return Promise.race([attempt, timeout]).finally(() => clearTimeout(timer))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Waits until the ping answers pong or the deadline passes. Returns { ready, lastError }.
|
|
503
|
+
// A keep-alive interval runs for the whole wait: on Node 20 a fetch whose socket is reset during boot can stay
|
|
504
|
+
// pending with nothing else holding the event loop, and Node then exits with code 13 before the ready message.
|
|
505
|
+
export async function waitForHealth({ url, fetchImpl, sleep, timeoutMs, now, tryTimeoutMs = HEALTH_TRY_TIMEOUT_MS }) {
|
|
461
506
|
const deadline = now() + timeoutMs
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
507
|
+
const keepAlive = setInterval(() => {}, 1000)
|
|
508
|
+
let lastError = null
|
|
509
|
+
try {
|
|
510
|
+
for (;;) {
|
|
511
|
+
try {
|
|
512
|
+
if (await healthTry(fetchImpl, url, tryTimeoutMs)) return { ready: true, lastError: null }
|
|
513
|
+
lastError = null
|
|
514
|
+
} catch (error) {
|
|
515
|
+
// Resets, refusals and per-try timeouts mean the server is still booting: try again.
|
|
516
|
+
// Anything else is also retried until the deadline, but is reported if it is the last error seen.
|
|
517
|
+
lastError = { code: healthErrorCode(error), retryable: RETRYABLE_HEALTH_ERRORS.has(healthErrorCode(error)) }
|
|
518
|
+
}
|
|
519
|
+
if (now() >= deadline) return { ready: false, lastError }
|
|
520
|
+
await sleep(1000)
|
|
469
521
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
522
|
+
} finally {
|
|
523
|
+
clearInterval(keepAlive)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Default health fetch: node:http with a socket timeout, so the open request itself holds the event loop.
|
|
528
|
+
export function httpHealthFetch(url, { signal, timeoutMs = HEALTH_TRY_TIMEOUT_MS } = {}) {
|
|
529
|
+
return new Promise((resolve, reject) => {
|
|
530
|
+
const req = httpGet(url, { signal, timeout: timeoutMs }, (res) => {
|
|
531
|
+
let body = ''
|
|
532
|
+
res.setEncoding('utf8')
|
|
533
|
+
res.on('data', (chunk) => {
|
|
534
|
+
body += chunk
|
|
535
|
+
})
|
|
536
|
+
res.on('end', () => {
|
|
537
|
+
const status = res.statusCode || 0
|
|
538
|
+
resolve({ ok: status >= 200 && status < 300, status, text: async () => body })
|
|
539
|
+
})
|
|
540
|
+
res.on('error', reject)
|
|
541
|
+
})
|
|
542
|
+
req.on('timeout', () => {
|
|
543
|
+
const error = new Error(`No answer from ${url} within ${timeoutMs} ms`)
|
|
544
|
+
error.code = 'HEALTH_TRY_TIMEOUT'
|
|
545
|
+
req.destroy(error)
|
|
546
|
+
})
|
|
547
|
+
req.on('error', reject)
|
|
548
|
+
})
|
|
473
549
|
}
|
|
474
550
|
|
|
475
551
|
async function reportReady(ctx) {
|
|
476
552
|
const url = publicUrl(ctx.opts.port)
|
|
477
553
|
const ping = healthUrl(ctx.opts.port)
|
|
478
554
|
ctx.out(`Waiting for Cuttlely at ${ping}`)
|
|
479
|
-
const ready = await waitForHealth({
|
|
555
|
+
const { ready, lastError } = await waitForHealth({
|
|
480
556
|
url: ping,
|
|
481
557
|
fetchImpl: ctx.fetchImpl,
|
|
482
558
|
sleep: ctx.sleep,
|
|
@@ -484,6 +560,9 @@ async function reportReady(ctx) {
|
|
|
484
560
|
now: ctx.now
|
|
485
561
|
})
|
|
486
562
|
if (!ready) {
|
|
563
|
+
const minutes = Math.max(1, Math.round(ctx.timeoutMs / 60000))
|
|
564
|
+
const reason = lastError ? ` (last error: ${lastError.code})` : ''
|
|
565
|
+
ctx.err(`Cuttlely did not answer ${ping} within ${minutes} min${reason}.`)
|
|
487
566
|
ctx.err(`Cuttlely is still starting. Open ${url} when it is ready.`)
|
|
488
567
|
ctx.err('Logs: cuttlely logs')
|
|
489
568
|
ctx.err(INSTALL_DOCS)
|
|
@@ -649,7 +728,7 @@ export async function main(argv, io = {}) {
|
|
|
649
728
|
env: io.env || process.env,
|
|
650
729
|
fetch: io.fetch || globalThis.fetch.bind(globalThis),
|
|
651
730
|
writeFile: io.writeFile,
|
|
652
|
-
fetchImpl: io.fetch ||
|
|
731
|
+
fetchImpl: io.fetch || httpHealthFetch,
|
|
653
732
|
sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
654
733
|
open: io.open || openBrowser,
|
|
655
734
|
now: io.now || Date.now,
|