mango-cms 0.3.33 → 0.3.34
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.
|
@@ -19,11 +19,6 @@
|
|
|
19
19
|
"mongoURI": "mongodb://127.0.0.1:27017",
|
|
20
20
|
"database": "exampleMongoDB",
|
|
21
21
|
|
|
22
|
-
"s3AccessKeyId": null,
|
|
23
|
-
"s3AccessKeySecret": null,
|
|
24
|
-
"s3Region": null,
|
|
25
|
-
"s3Bucket": "exampleBucket",
|
|
26
|
-
|
|
27
22
|
"emailProvider": "resend",
|
|
28
23
|
"emailFrom": "Example <info@example.com>",
|
|
29
24
|
"resendKey": null,
|
package/lib/devProxyGateway.js
CHANGED
|
@@ -391,6 +391,107 @@ export function createHttpResolver({
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Build a single-shot TCP readiness probe from node's `net` module (HAP-1215).
|
|
396
|
+
*
|
|
397
|
+
* Returns `probe(port) -> Promise<boolean>` that resolves `true` iff a TCP
|
|
398
|
+
* connection to 127.0.0.1:<port> succeeds within `connectTimeoutMs`, else
|
|
399
|
+
* `false` (never rejects). A per-branch `mango dev` binds its port only once
|
|
400
|
+
* Vite has finished booting, so "can I open a socket" is the cheapest accurate
|
|
401
|
+
* signal that the workspace is ready to be proxied to. `net` is injected so the
|
|
402
|
+
* module stays import-free and unit-testable with a plain fake.
|
|
403
|
+
*/
|
|
404
|
+
export function createTcpReadinessProbe(net, { connectTimeoutMs = 1000 } = {}) {
|
|
405
|
+
if (!net || typeof net.connect !== 'function') {
|
|
406
|
+
throw new Error('createTcpReadinessProbe: net module with connect() is required')
|
|
407
|
+
}
|
|
408
|
+
return function probe(port) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
let done = false
|
|
411
|
+
const finish = (ready, sock) => {
|
|
412
|
+
if (done) return
|
|
413
|
+
done = true
|
|
414
|
+
try { sock && sock.destroy() } catch { /* noop */ }
|
|
415
|
+
resolve(ready)
|
|
416
|
+
}
|
|
417
|
+
let sock
|
|
418
|
+
try {
|
|
419
|
+
sock = net.connect({ host: '127.0.0.1', port })
|
|
420
|
+
} catch {
|
|
421
|
+
return resolve(false)
|
|
422
|
+
}
|
|
423
|
+
sock.setTimeout(connectTimeoutMs, () => finish(false, sock))
|
|
424
|
+
sock.once('connect', () => finish(true, sock))
|
|
425
|
+
sock.once('error', () => finish(false, sock))
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Branded "your branch preview is starting" interstitial (HAP-1215).
|
|
432
|
+
*
|
|
433
|
+
* Served (HTTP 503 + Retry-After) when a request resolved to a real per-branch
|
|
434
|
+
* workspace but that workspace's dev server has not bound its port within the
|
|
435
|
+
* bounded readiness window — a genuinely cold `mango dev` start on the 1vcpu
|
|
436
|
+
* droplet. This REPLACES the raw "Bad gateway: ECONNREFUSED" 502 the proxy would
|
|
437
|
+
* otherwise emit, so the owner sees a clear, self-refreshing status instead of an
|
|
438
|
+
* unbounded white/coffee screen. The page auto-reloads, and each reload re-probes
|
|
439
|
+
* with the same bounded budget — so the user converges to the live preview the
|
|
440
|
+
* instant the server is up, or keeps seeing a clear, actionable error if it never
|
|
441
|
+
* comes up. Never an indefinite hang.
|
|
442
|
+
*/
|
|
443
|
+
export function renderBootingPage({ branch = '', retryAfterSeconds = 5 } = {}) {
|
|
444
|
+
const safeBranch = String(branch).replace(/[<>&"]/g, (c) => (
|
|
445
|
+
{ '<': '<', '>': '>', '&': '&', '"': '"' }[c]
|
|
446
|
+
))
|
|
447
|
+
const branchLine = safeBranch
|
|
448
|
+
? `<p class="branch">branch <code>${safeBranch}</code></p>`
|
|
449
|
+
: ''
|
|
450
|
+
return `<!doctype html>
|
|
451
|
+
<html lang="en">
|
|
452
|
+
<head>
|
|
453
|
+
<meta charset="utf-8">
|
|
454
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
455
|
+
<meta http-equiv="refresh" content="${retryAfterSeconds}">
|
|
456
|
+
<title>Starting your preview…</title>
|
|
457
|
+
<style>
|
|
458
|
+
:root { color-scheme: light dark; }
|
|
459
|
+
html, body { height: 100%; margin: 0; }
|
|
460
|
+
body {
|
|
461
|
+
display: flex; align-items: center; justify-content: center;
|
|
462
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
463
|
+
background: #0f1115; color: #e8eaed;
|
|
464
|
+
}
|
|
465
|
+
.card { max-width: 28rem; padding: 2.5rem 2rem; text-align: center; }
|
|
466
|
+
.spinner {
|
|
467
|
+
width: 38px; height: 38px; margin: 0 auto 1.5rem;
|
|
468
|
+
border: 3px solid rgba(255,255,255,0.18); border-top-color: #7aa2f7;
|
|
469
|
+
border-radius: 50%; animation: spin 0.9s linear infinite;
|
|
470
|
+
}
|
|
471
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
472
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.5rem; }
|
|
473
|
+
p { margin: 0.35rem 0; color: #9aa0ac; font-size: 0.95rem; line-height: 1.5; }
|
|
474
|
+
.branch code { color: #c0caf5; background: rgba(122,162,247,0.12); padding: 0.1rem 0.4rem; border-radius: 4px; }
|
|
475
|
+
button {
|
|
476
|
+
margin-top: 1.5rem; padding: 0.6rem 1.4rem; font-size: 0.95rem; cursor: pointer;
|
|
477
|
+
color: #0f1115; background: #7aa2f7; border: 0; border-radius: 8px; font-weight: 600;
|
|
478
|
+
}
|
|
479
|
+
button:hover { background: #93b4ff; }
|
|
480
|
+
</style>
|
|
481
|
+
</head>
|
|
482
|
+
<body>
|
|
483
|
+
<main class="card">
|
|
484
|
+
<div class="spinner" role="status" aria-label="Loading"></div>
|
|
485
|
+
<h1>Your preview is starting…</h1>
|
|
486
|
+
<p>The branch server is booting. This usually takes a few seconds on first load.</p>
|
|
487
|
+
${branchLine}
|
|
488
|
+
<p>This page refreshes automatically. If it doesn't load shortly, the branch may have failed to start — retry below.</p>
|
|
489
|
+
<button type="button" onclick="location.reload()">Retry now</button>
|
|
490
|
+
</main>
|
|
491
|
+
</body>
|
|
492
|
+
</html>`
|
|
493
|
+
}
|
|
494
|
+
|
|
394
495
|
/**
|
|
395
496
|
* Standalone staging-gateway server factory (HAP-1165).
|
|
396
497
|
*
|
|
@@ -409,6 +510,9 @@ export function createHttpResolver({
|
|
|
409
510
|
* - Non-staging host → 404 with a clear message (no misroute).
|
|
410
511
|
* - Resolver errors (403/404/502) → surfaced as the resolver's HTTP status.
|
|
411
512
|
* - Upgrade-time resolver failure → socket destroyed (Vite retries the WS).
|
|
513
|
+
* - Cold workspace not yet listening (HAP-1215) → bounded readiness poll, then a
|
|
514
|
+
* branded self-refreshing "starting" page (503) instead of a raw ECONNREFUSED
|
|
515
|
+
* 502. Opt-in via `readinessProbe` (off → proxy immediately, legacy behavior).
|
|
412
516
|
*
|
|
413
517
|
* Page-load auth (HAP-1175, supersedes HAP-1173): a top-level browser navigation
|
|
414
518
|
* MUST never 401, regardless of what cookies it carries. Real browsers attach
|
|
@@ -435,6 +539,13 @@ export function createStagingGatewayServer({
|
|
|
435
539
|
timeoutMs,
|
|
436
540
|
fallbackPort,
|
|
437
541
|
onError,
|
|
542
|
+
// HAP-1215 cold-boot bounded-readiness (all optional — gating is OFF unless a
|
|
543
|
+
// probe is supplied, so existing callers/tests are unaffected):
|
|
544
|
+
readinessProbe, // (port) => Promise<boolean readyNow>; e.g. createTcpReadinessProbe(net)
|
|
545
|
+
readinessTimeoutMs = 25000, // total bounded wait for a cold workspace to bind its port
|
|
546
|
+
readinessIntervalMs = 500, // delay between probe attempts
|
|
547
|
+
renderBootPage = renderBootingPage, // injectable for tests
|
|
548
|
+
setTimeoutFn = setTimeout, // injectable for deterministic tests
|
|
438
549
|
} = {}) {
|
|
439
550
|
if (!http || typeof http.createServer !== 'function') {
|
|
440
551
|
throw new Error('createStagingGatewayServer: http module with createServer() is required')
|
|
@@ -448,6 +559,9 @@ export function createStagingGatewayServer({
|
|
|
448
559
|
if (fallbackPort != null && (!Number.isInteger(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535)) {
|
|
449
560
|
throw new Error('createStagingGatewayServer: fallbackPort, if set, must be an integer in 1..65535')
|
|
450
561
|
}
|
|
562
|
+
if (readinessProbe != null && typeof readinessProbe !== 'function') {
|
|
563
|
+
throw new Error('createStagingGatewayServer: readinessProbe, if set, must be a function')
|
|
564
|
+
}
|
|
451
565
|
|
|
452
566
|
const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
|
|
453
567
|
const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
|
|
@@ -457,6 +571,54 @@ export function createStagingGatewayServer({
|
|
|
457
571
|
if (typeof onError === 'function') onError(where, err, req)
|
|
458
572
|
}
|
|
459
573
|
|
|
574
|
+
// HAP-1215: a genuinely cold per-branch `mango dev` can take ~10–60s to bind
|
|
575
|
+
// its port on the 1vcpu droplet. Without this, the first request after resolve
|
|
576
|
+
// hits a not-yet-listening port and the proxy emits a raw ECONNREFUSED 502 (an
|
|
577
|
+
// unbounded "coffee screen" as the browser/owner keep retrying into 502s). This
|
|
578
|
+
// polls the resolved port for a bounded window; the caller serves the live
|
|
579
|
+
// proxy the instant it's up, or a clear branded error if the budget elapses.
|
|
580
|
+
// Returns true if the port became connectable within the window, false on timeout.
|
|
581
|
+
async function waitForReady(port) {
|
|
582
|
+
if (typeof readinessProbe !== 'function') return true // gating disabled
|
|
583
|
+
const maxAttempts = Math.max(1, Math.ceil(readinessTimeoutMs / Math.max(1, readinessIntervalMs)))
|
|
584
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
585
|
+
let ready = false
|
|
586
|
+
try {
|
|
587
|
+
ready = await readinessProbe(port)
|
|
588
|
+
} catch {
|
|
589
|
+
ready = false
|
|
590
|
+
}
|
|
591
|
+
if (ready) return true
|
|
592
|
+
if (attempt < maxAttempts - 1) {
|
|
593
|
+
await new Promise((r) => setTimeoutFn(r, readinessIntervalMs))
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return false
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function sendBootingPage(req, res, branch) {
|
|
600
|
+
if (!res || res.headersSent) return
|
|
601
|
+
const wantsHtml = /\btext\/html\b/.test(String(req?.headers?.accept || ''))
|
|
602
|
+
const retryAfter = Math.max(1, Math.round(readinessIntervalMs / 1000) || 3)
|
|
603
|
+
if (wantsHtml) {
|
|
604
|
+
const html = renderBootPage({ branch, retryAfterSeconds: retryAfter })
|
|
605
|
+
res.writeHead(503, {
|
|
606
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
607
|
+
'Retry-After': String(retryAfter),
|
|
608
|
+
'Cache-Control': 'no-store',
|
|
609
|
+
})
|
|
610
|
+
res.end(html)
|
|
611
|
+
} else {
|
|
612
|
+
// Non-navigation (asset/XHR): a short, bounded 503 the client can retry.
|
|
613
|
+
res.writeHead(503, {
|
|
614
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
615
|
+
'Retry-After': String(retryAfter),
|
|
616
|
+
'Cache-Control': 'no-store',
|
|
617
|
+
})
|
|
618
|
+
res.end('Vibe staging: branch preview is starting — retry shortly')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
460
622
|
proxy.on('error', (err, req, res) => {
|
|
461
623
|
log('proxy', err, req)
|
|
462
624
|
if (res && typeof res.writeHead === 'function' && !res.headersSent) {
|
|
@@ -513,6 +675,17 @@ export function createStagingGatewayServer({
|
|
|
513
675
|
sendError(res, outcome.status || 502, outcome.message || outcome.error)
|
|
514
676
|
return
|
|
515
677
|
}
|
|
678
|
+
// HAP-1215: the resolve succeeded, but a cold per-branch dev server may not
|
|
679
|
+
// have bound its port yet. Briefly poll (bounded) before proxying; on
|
|
680
|
+
// timeout serve a branded self-refreshing "starting" page instead of the
|
|
681
|
+
// raw ECONNREFUSED 502 the proxy would otherwise emit. Gating is a no-op
|
|
682
|
+
// (returns true immediately) when no readinessProbe is configured.
|
|
683
|
+
const ready = await waitForReady(outcome.port)
|
|
684
|
+
if (!ready) {
|
|
685
|
+
log('readiness', new Error(`workspace port ${outcome.port} not ready within ${readinessTimeoutMs}ms`), req)
|
|
686
|
+
sendBootingPage(req, res, hostBranch || parseBranchFromReq(req))
|
|
687
|
+
return
|
|
688
|
+
}
|
|
516
689
|
proxy.web(req, res, { target: outcome.target })
|
|
517
690
|
}
|
|
518
691
|
|
package/lib/staging-gateway.js
CHANGED
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
* request whose resolution fails. The
|
|
26
26
|
* shared front (HAP-1175). Set to 0 to
|
|
27
27
|
* disable and 401 those requests instead.
|
|
28
|
+
* READINESS_TIMEOUT_MS (default 25000, int) HAP-1215: after a successful
|
|
29
|
+
* per-branch resolve, poll the workspace
|
|
30
|
+
* port for up to this long before proxying,
|
|
31
|
+
* so a cold `mango dev` boot shows a branded
|
|
32
|
+
* "starting" page instead of a raw 502.
|
|
33
|
+
* Set to 0 to disable readiness gating.
|
|
34
|
+
* READINESS_INTERVAL_MS (default 500, int) Delay between readiness probes.
|
|
28
35
|
*
|
|
29
36
|
* Usage (typical droplet pm2):
|
|
30
37
|
* BACKEND_PORT=7122 LISTEN_PORT=7123 \
|
|
@@ -33,8 +40,9 @@
|
|
|
33
40
|
*/
|
|
34
41
|
|
|
35
42
|
import http from 'http'
|
|
43
|
+
import net from 'net'
|
|
36
44
|
import httpProxy from 'http-proxy'
|
|
37
|
-
import { createStagingGatewayServer } from './devProxyGateway.js'
|
|
45
|
+
import { createStagingGatewayServer, createTcpReadinessProbe } from './devProxyGateway.js'
|
|
38
46
|
|
|
39
47
|
function intEnv(name, fallback) {
|
|
40
48
|
const raw = process.env[name]
|
|
@@ -73,6 +81,11 @@ const STAGING_HOST_PREFIX = process.env.STAGING_HOST_PREFIX || undefined
|
|
|
73
81
|
const RESOLVER_PATH = process.env.RESOLVER_PATH || undefined
|
|
74
82
|
const RESOLVER_TIMEOUT_MS = intEnv('RESOLVER_TIMEOUT_MS', 2000)
|
|
75
83
|
const FALLBACK_PORT = fallbackPortEnv()
|
|
84
|
+
// HAP-1215: READINESS_TIMEOUT_MS=0 (or empty) disables cold-boot readiness gating.
|
|
85
|
+
const READINESS_TIMEOUT_MS = (process.env.READINESS_TIMEOUT_MS === '0' || process.env.READINESS_TIMEOUT_MS === '')
|
|
86
|
+
? 0
|
|
87
|
+
: intEnv('READINESS_TIMEOUT_MS', 25000)
|
|
88
|
+
const READINESS_INTERVAL_MS = intEnv('READINESS_INTERVAL_MS', 500)
|
|
76
89
|
|
|
77
90
|
const { server, close } = createStagingGatewayServer({
|
|
78
91
|
http,
|
|
@@ -83,6 +96,10 @@ const { server, close } = createStagingGatewayServer({
|
|
|
83
96
|
resolverPath: RESOLVER_PATH,
|
|
84
97
|
timeoutMs: RESOLVER_TIMEOUT_MS,
|
|
85
98
|
fallbackPort: FALLBACK_PORT,
|
|
99
|
+
// HAP-1215: only enable readiness gating when the window is > 0.
|
|
100
|
+
readinessProbe: READINESS_TIMEOUT_MS > 0 ? createTcpReadinessProbe(net) : undefined,
|
|
101
|
+
readinessTimeoutMs: READINESS_TIMEOUT_MS,
|
|
102
|
+
readinessIntervalMs: READINESS_INTERVAL_MS,
|
|
86
103
|
onError: (where, err) => {
|
|
87
104
|
console.error(`[staging-gateway] ${where} error: ${err?.message || err}`)
|
|
88
105
|
},
|
|
@@ -97,6 +114,11 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
|
|
97
114
|
} else {
|
|
98
115
|
console.log('[staging-gateway] fallback disabled (no branch signal → 401, failed resolve → resolver status)')
|
|
99
116
|
}
|
|
117
|
+
if (READINESS_TIMEOUT_MS > 0) {
|
|
118
|
+
console.log(`[staging-gateway] cold-boot readiness: poll workspace port ≤${READINESS_TIMEOUT_MS}ms (every ${READINESS_INTERVAL_MS}ms) → branded "starting" page on timeout`)
|
|
119
|
+
} else {
|
|
120
|
+
console.log('[staging-gateway] cold-boot readiness gating disabled (proxy immediately; cold boots may 502)')
|
|
121
|
+
}
|
|
100
122
|
})
|
|
101
123
|
|
|
102
124
|
function shutdown(signal) {
|