mango-cms 0.3.31 → 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,
@@ -27,6 +27,14 @@
27
27
 
28
28
  export const DEFAULT_STAGING_PREFIX = 'staging.'
29
29
 
30
+ // HAP-1188 (Option B): the container shell points its iframe at
31
+ // `<branch>--<baseStagingHost>` so the branch travels in the Host header on the
32
+ // iframe's document AND every one of its sub-resource + HMR-WS requests — sticky
33
+ // per-branch with zero cookie pollution. This is the separator between the
34
+ // branch prefix and the base staging host. The front builds it from
35
+ // VITE_VIBE_SHELL_HOST_TEMPLATE="{branch}--staging-vibe.<site>".
36
+ export const DEFAULT_BRANCH_HOST_SEPARATOR = '--'
37
+
30
38
  // Header / cookie the editing surface (and the orchestrator) use to carry the
31
39
  // SELECTED BRANCH with a staging request (HAP-1164). The branch — not the session
32
40
  // identity — is what the resolver demuxes on, so it must travel with every
@@ -120,37 +128,84 @@ export function hasBranchSignal(req) {
120
128
  return false
121
129
  }
122
130
 
123
- // Pure: does this request's Host header look like a staging request, and what
124
- // site slug does it resolve to?
131
+ // Internal: match an already-normalized (port-stripped, trimmed, lowercased)
132
+ // hostname as a staging host, returning { hostname, slug } | null. This is the
133
+ // pre-HAP-1188 matcher, factored out so the branch-in-hostname peeling below can
134
+ // reuse it on the host's remainder.
125
135
  //
126
136
  // Matches in this order:
127
- // 1. exact match against opts.stagingDomain (when configured) — slug is the
128
- // part of the hostname after the staging prefix, or the full hostname if
129
- // it doesn't have one.
130
- // 2. any hostname starting with opts.stagingHostPrefix (defaults to
131
- // "staging.") slug is the part after that prefix.
132
- //
133
- // Returns null when no match, or when the slug would be empty. Port suffixes
134
- // (":80") and case are normalized away so the matcher is stable across
135
- // browsers, curl, and the Vite HMR upgrade.
136
- export function parseStagingHost(hostHeader, opts = {}) {
137
- if (!hostHeader) return null
138
- const prefix = String(opts.stagingHostPrefix || DEFAULT_STAGING_PREFIX).toLowerCase()
139
- const hostname = String(hostHeader).split(':')[0].trim().toLowerCase()
137
+ // 1. exact match against `configured` (opts.stagingDomain, when set) — slug is
138
+ // the part after the staging prefix, or the full hostname if it has none.
139
+ // 2. any hostname starting with `prefix` (defaults to "staging.") — slug is the
140
+ // part after that prefix.
141
+ function matchStagingBase(hostname, prefix, configured) {
140
142
  if (!hostname) return null
141
-
142
- const configured = opts.stagingDomain ? String(opts.stagingDomain).toLowerCase() : null
143
143
  if (configured && hostname === configured) {
144
144
  const slug = hostname.startsWith(prefix) ? hostname.slice(prefix.length) : hostname
145
145
  if (!slug) return null
146
146
  return { hostname, slug }
147
147
  }
148
-
149
148
  if (hostname.startsWith(prefix)) {
150
149
  const slug = hostname.slice(prefix.length)
151
150
  if (!slug) return null
152
151
  return { hostname, slug }
153
152
  }
153
+ return null
154
+ }
155
+
156
+ // Pure: does this request's Host header look like a staging request, what site
157
+ // slug does it resolve to, and — when the container shell encodes the branch in
158
+ // the hostname (HAP-1188, Option B) — which branch?
159
+ //
160
+ // Returns null when no match, or when the slug would be empty. Port suffixes
161
+ // (":80") and case are normalized away so the matcher is stable across
162
+ // browsers, curl, and the Vite HMR upgrade.
163
+ //
164
+ // Branch-in-hostname (HAP-1188): a host of the form `<branch>--<baseStagingHost>`
165
+ // resolves to the same { hostname, slug } as `<baseStagingHost>` PLUS a `branch`
166
+ // field. The gateway treats that host-branch as a deliberate branch signal, so
167
+ // the iframe's document, modules, AND HMR WebSocket all demux to the one branch's
168
+ // dev server — no `?branch=` on sub-resources, no origin-wide cookie. The shell
169
+ // host (`<baseStagingHost>`, no `--` prefix) carries no branch and is unaffected.
170
+ export function parseStagingHost(hostHeader, opts = {}) {
171
+ if (!hostHeader) return null
172
+ const prefix = String(opts.stagingHostPrefix || DEFAULT_STAGING_PREFIX).toLowerCase()
173
+ const sep = String(opts.branchHostSeparator ?? DEFAULT_BRANCH_HOST_SEPARATOR)
174
+ const raw = String(hostHeader).split(':')[0].trim()
175
+ if (!raw) return null
176
+ const hostname = raw.toLowerCase()
177
+ const configured = opts.stagingDomain ? String(opts.stagingDomain).toLowerCase() : null
178
+
179
+ // Fast path: the shell host and every pre-HAP-1188 staging host (no branch
180
+ // prefix) match directly. This also means a plain host is never misread as a
181
+ // branch-prefixed one.
182
+ const direct = matchStagingBase(hostname, prefix, configured)
183
+ if (direct) return direct
184
+
185
+ // Branch-in-hostname: peel a leading `<branch>--` and match the REMAINDER as
186
+ // an ordinary staging host. Scan separator positions left→right and accept
187
+ // the first split whose remainder is a valid base host — so a branch whose
188
+ // sanitized form itself contains `--` (e.g. two non-git chars in a row) still
189
+ // resolves to the real base host rather than splitting in the middle.
190
+ if (sep) {
191
+ let from = 0
192
+ let idx
193
+ while ((idx = hostname.indexOf(sep, from)) > 0) {
194
+ const base = matchStagingBase(hostname.slice(idx + sep.length), prefix, configured)
195
+ if (base) {
196
+ // Slice the ORIGINAL-case host for the branch (toLowerCase keeps
197
+ // length, so `idx` is valid in both). Browsers usually lowercase
198
+ // the whole Host, but don't assume it. Percent-decode because the
199
+ // front builds the prefix with encodeURIComponent(sanitizeBranch()).
200
+ const rawBranch = raw.slice(0, idx)
201
+ let branch = rawBranch
202
+ try { branch = decodeURIComponent(rawBranch) } catch { /* keep raw */ }
203
+ branch = branch.trim()
204
+ return branch ? { ...base, branch } : base
205
+ }
206
+ from = idx + sep.length
207
+ }
208
+ }
154
209
 
155
210
  return null
156
211
  }
@@ -170,7 +225,7 @@ export function parseStagingHost(hostHeader, opts = {}) {
170
225
  // no_workspace (404) — resolver returned nothing for (user, slug)
171
226
  // resolver_error (502) — resolver threw / returned a bad shape
172
227
  // forbidden (403) — resolver explicitly refused (e.g. wrong site)
173
- export async function resolveStagingTarget({ req, slug, resolve }) {
228
+ export async function resolveStagingTarget({ req, slug, resolve, branch }) {
174
229
  if (typeof resolve !== 'function') {
175
230
  return { error: 'resolver_error', status: 502, message: 'No staging resolver configured' }
176
231
  }
@@ -184,13 +239,19 @@ export async function resolveStagingTarget({ req, slug, resolve }) {
184
239
  }
185
240
  }
186
241
 
187
- // The SELECTED BRANCH (HAP-1164) travels with the request and is what the
188
- // resolver demuxes on. Absent resolver falls back to the configured default.
189
- const branch = parseBranchFromReq(req)
242
+ // The SELECTED BRANCH (HAP-1164) is what the resolver demuxes on. It travels
243
+ // either in the Host header (HAP-1188 branch-in-hostname, passed in here as
244
+ // `branch` by the gateway) or on the request itself (x-vibe-branch / ?branch=
245
+ // / vibe_branch cookie). An explicit host-branch wins — it's the most
246
+ // deliberate signal and is what keeps the iframe's sub-resources sticky.
247
+ // Absent → resolver falls back to the configured default.
248
+ const selectedBranch = (branch != null && String(branch).trim())
249
+ ? String(branch).trim()
250
+ : parseBranchFromReq(req)
190
251
 
191
252
  let result
192
253
  try {
193
- result = await resolve({ slug, branch, cookieHeader, authHeader, req })
254
+ result = await resolve({ slug, branch: selectedBranch, cookieHeader, authHeader, req })
194
255
  } catch (err) {
195
256
  return { error: 'resolver_error', status: 502, message: `Vibe staging resolver error: ${err?.message || err}` }
196
257
  }
@@ -330,6 +391,107 @@ export function createHttpResolver({
330
391
  }
331
392
  }
332
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
+ { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[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
+
333
495
  /**
334
496
  * Standalone staging-gateway server factory (HAP-1165).
335
497
  *
@@ -348,6 +510,9 @@ export function createHttpResolver({
348
510
  * - Non-staging host → 404 with a clear message (no misroute).
349
511
  * - Resolver errors (403/404/502) → surfaced as the resolver's HTTP status.
350
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).
351
516
  *
352
517
  * Page-load auth (HAP-1175, supersedes HAP-1173): a top-level browser navigation
353
518
  * MUST never 401, regardless of what cookies it carries. Real browsers attach
@@ -374,6 +539,13 @@ export function createStagingGatewayServer({
374
539
  timeoutMs,
375
540
  fallbackPort,
376
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
377
549
  } = {}) {
378
550
  if (!http || typeof http.createServer !== 'function') {
379
551
  throw new Error('createStagingGatewayServer: http module with createServer() is required')
@@ -387,6 +559,9 @@ export function createStagingGatewayServer({
387
559
  if (fallbackPort != null && (!Number.isInteger(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535)) {
388
560
  throw new Error('createStagingGatewayServer: fallbackPort, if set, must be an integer in 1..65535')
389
561
  }
562
+ if (readinessProbe != null && typeof readinessProbe !== 'function') {
563
+ throw new Error('createStagingGatewayServer: readinessProbe, if set, must be a function')
564
+ }
390
565
 
391
566
  const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
392
567
  const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
@@ -396,6 +571,54 @@ export function createStagingGatewayServer({
396
571
  if (typeof onError === 'function') onError(where, err, req)
397
572
  }
398
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
+
399
622
  proxy.on('error', (err, req, res) => {
400
623
  log('proxy', err, req)
401
624
  if (res && typeof res.writeHead === 'function' && !res.headersSent) {
@@ -424,12 +647,15 @@ export function createStagingGatewayServer({
424
647
  sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
425
648
  return
426
649
  }
427
- // HAP-1175: default route is the shared fallback front. Only requests
428
- // that carry a deliberate branch signal (vibe_branch cookie, x-vibe-branch
429
- // header, or ?branch= URL param) attempt per-branch resolution. A returning
430
- // browser with stale session cookies but no branch signal — the case that
431
- // re-broke staging in HAP-1175 passes straight through here.
432
- if (!hasBranchSignal(req)) {
650
+ // HAP-1175 / HAP-1188: default route is the shared fallback front. Only
651
+ // requests that carry a DELIBERATE branch signal attempt per-branch
652
+ // resolution the branch in the Host header (HAP-1188, `<branch>--<host>`),
653
+ // a vibe_branch cookie, an x-vibe-branch header, or a ?branch= URL param.
654
+ // A returning browser with stale session cookies but no branch signal —
655
+ // the case that re-broke staging in HAP-1175 — passes straight through to
656
+ // the fallback. The shell host has no host-branch, so it lands here too.
657
+ const hostBranch = parsed.branch || ''
658
+ if (!hostBranch && !hasBranchSignal(req)) {
433
659
  if (fallbackTarget) {
434
660
  proxy.web(req, res, { target: fallbackTarget })
435
661
  return
@@ -437,7 +663,7 @@ export function createStagingGatewayServer({
437
663
  sendError(res, 401, 'Vibe staging: no branch signal and no fallback configured')
438
664
  return
439
665
  }
440
- const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
666
+ const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve, branch: hostBranch })
441
667
  if (outcome.error) {
442
668
  // HAP-1175: even with a branch signal, a failed resolve falls back
443
669
  // to the shared front rather than 401-ing the page load. Hard errors
@@ -449,6 +675,17 @@ export function createStagingGatewayServer({
449
675
  sendError(res, outcome.status || 502, outcome.message || outcome.error)
450
676
  return
451
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
+ }
452
689
  proxy.web(req, res, { target: outcome.target })
453
690
  }
454
691
 
@@ -458,11 +695,14 @@ export function createStagingGatewayServer({
458
695
  try { socket.destroy() } catch { /* noop */ }
459
696
  return
460
697
  }
461
- // HAP-1175: same default-to-fallback rule as handleRequest. The Vite
462
- // HMR upgrade for the shared shell rides without a branch signal; only
463
- // after the picker fires does the next upgrade carry x-vibe-branch and
464
- // resolve per-branch.
465
- if (!hasBranchSignal(req)) {
698
+ // HAP-1175 / HAP-1188: same default-to-fallback rule as handleRequest.
699
+ // The shell's own HMR upgrade rides without a branch signal → fallback.
700
+ // The iframe's HMR WebSocket carries the branch in the Host header
701
+ // (`<branch>--<host>`), so it resolves to the SAME per-branch dev server
702
+ // as the iframe's document — this is what closes the dual-Vue / white-
703
+ // screen gap (HAP-1177 class) that `?branch=`-on-document-only left open.
704
+ const hostBranch = parsed.branch || ''
705
+ if (!hostBranch && !hasBranchSignal(req)) {
466
706
  if (fallbackTarget) {
467
707
  proxy.ws(req, socket, head, { target: fallbackTarget })
468
708
  return
@@ -470,7 +710,7 @@ export function createStagingGatewayServer({
470
710
  try { socket.destroy() } catch { /* noop */ }
471
711
  return
472
712
  }
473
- const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
713
+ const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve, branch: hostBranch })
474
714
  if (outcome.error) {
475
715
  // Failed resolve on an upgrade: prefer the fallback over destroying
476
716
  // the socket so HMR keeps working through the shared front.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mango-cms",
3
- "version": "0.3.31",
3
+ "version": "0.3.34",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {