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.
- package/default/mango/config/settings.json +0 -5
- package/lib/devProxyGateway.js +276 -36
- package/lib/staging-gateway.js +23 -1
- package/package.json +1 -1
|
@@ -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
|
@@ -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
|
-
//
|
|
124
|
-
//
|
|
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
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
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)
|
|
188
|
-
//
|
|
189
|
-
|
|
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
|
+
{ '<': '<', '>': '>', '&': '&', '"': '"' }[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
|
|
428
|
-
// that carry a
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
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.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
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.
|
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) {
|