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,
@@ -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
+ { '<': '&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
+
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
 
@@ -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.33",
3
+ "version": "0.3.34",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {