mango-cms 0.3.27 → 0.3.29
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/lib/devProxyGateway.js +124 -5
- package/lib/staging-gateway.js +27 -0
- package/package.json +1 -1
package/lib/devProxyGateway.js
CHANGED
|
@@ -34,13 +34,31 @@ export const DEFAULT_STAGING_PREFIX = 'staging.'
|
|
|
34
34
|
export const BRANCH_HEADER = 'x-vibe-branch'
|
|
35
35
|
export const BRANCH_COOKIE = 'vibe_branch'
|
|
36
36
|
|
|
37
|
-
// Pure: pull the selected branch out of a request
|
|
38
|
-
//
|
|
39
|
-
//
|
|
37
|
+
// Pure: pull the selected branch out of a request. Precedence (high → low):
|
|
38
|
+
// 1. x-vibe-branch header (set by fetch/XHR after the picker)
|
|
39
|
+
// 2. ?branch=<name> URL search param (deliberate URL navigation, HAP-1175)
|
|
40
|
+
// 3. vibe_branch cookie (set by the picker after selection)
|
|
41
|
+
// Returns '' when none is present so the resolver can fall back to the site's
|
|
42
|
+
// configured default branch.
|
|
40
43
|
export function parseBranchFromReq(req) {
|
|
41
44
|
const headerVal = req?.headers?.[BRANCH_HEADER]
|
|
42
45
|
if (headerVal) return String(Array.isArray(headerVal) ? headerVal[0] : headerVal).trim()
|
|
43
46
|
|
|
47
|
+
const url = req?.url || ''
|
|
48
|
+
const q = url.indexOf('?')
|
|
49
|
+
if (q !== -1) {
|
|
50
|
+
for (const pair of url.slice(q + 1).split('&')) {
|
|
51
|
+
const eq = pair.indexOf('=')
|
|
52
|
+
const key = eq === -1 ? pair : pair.slice(0, eq)
|
|
53
|
+
if (key === 'branch') {
|
|
54
|
+
const val = eq === -1 ? '' : pair.slice(eq + 1)
|
|
55
|
+
if (val) {
|
|
56
|
+
try { return decodeURIComponent(val.trim()) } catch { return val.trim() }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
const cookieHeader = req?.headers?.cookie || ''
|
|
45
63
|
for (const pair of String(cookieHeader).split(';')) {
|
|
46
64
|
const idx = pair.indexOf('=')
|
|
@@ -56,6 +74,52 @@ export function parseBranchFromReq(req) {
|
|
|
56
74
|
return ''
|
|
57
75
|
}
|
|
58
76
|
|
|
77
|
+
// Pure: does this request carry a DELIBERATE branch signal (HAP-1175)?
|
|
78
|
+
//
|
|
79
|
+
// The gateway uses this — not "is there any Cookie/Authorization header" — to
|
|
80
|
+
// decide whether to attempt per-branch resolution. Random session cookies left
|
|
81
|
+
// over from prior visits do NOT count; only signals the app sets on purpose:
|
|
82
|
+
//
|
|
83
|
+
// 1. the `x-vibe-branch` request header (set by fetch/XHR after the picker)
|
|
84
|
+
// 2. a non-empty `?branch=` URL search param (deliberate URL navigation)
|
|
85
|
+
// 3. a non-empty `vibe_branch` cookie (set by the picker after selection)
|
|
86
|
+
//
|
|
87
|
+
// Returns true iff at least one of those carries a non-empty value. A request
|
|
88
|
+
// with only unrelated cookies (e.g. `foo=bar`, `connect.sid=...`) returns false
|
|
89
|
+
// so the gateway defaults it to the shared fallback front, not the resolver.
|
|
90
|
+
export function hasBranchSignal(req) {
|
|
91
|
+
const headerVal = req?.headers?.[BRANCH_HEADER]
|
|
92
|
+
if (headerVal && String(Array.isArray(headerVal) ? headerVal[0] : headerVal).trim()) {
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const url = req?.url || ''
|
|
97
|
+
const q = url.indexOf('?')
|
|
98
|
+
if (q !== -1) {
|
|
99
|
+
for (const pair of url.slice(q + 1).split('&')) {
|
|
100
|
+
const eq = pair.indexOf('=')
|
|
101
|
+
const key = eq === -1 ? pair : pair.slice(0, eq)
|
|
102
|
+
if (key === 'branch') {
|
|
103
|
+
const val = eq === -1 ? '' : pair.slice(eq + 1)
|
|
104
|
+
if (val) return true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cookieHeader = req?.headers?.cookie || ''
|
|
110
|
+
if (cookieHeader) {
|
|
111
|
+
for (const pair of String(cookieHeader).split(';')) {
|
|
112
|
+
const idx = pair.indexOf('=')
|
|
113
|
+
if (idx === -1) continue
|
|
114
|
+
if (pair.slice(0, idx).trim() === BRANCH_COOKIE) {
|
|
115
|
+
if (pair.slice(idx + 1).trim()) return true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
59
123
|
// Pure: does this request's Host header look like a staging request, and what
|
|
60
124
|
// site slug does it resolve to?
|
|
61
125
|
//
|
|
@@ -282,9 +346,22 @@ export function createHttpResolver({
|
|
|
282
346
|
* - Matching staging host → resolve via Mango → http-proxy to the workspace port.
|
|
283
347
|
* - WebSocket upgrade (Vite HMR) → same resolution → proxy.ws to the same port.
|
|
284
348
|
* - Non-staging host → 404 with a clear message (no misroute).
|
|
285
|
-
* - Resolver errors (
|
|
349
|
+
* - Resolver errors (403/404/502) → surfaced as the resolver's HTTP status.
|
|
286
350
|
* - Upgrade-time resolver failure → socket destroyed (Vite retries the WS).
|
|
287
351
|
*
|
|
352
|
+
* Page-load auth (HAP-1175, supersedes HAP-1173): a top-level browser navigation
|
|
353
|
+
* MUST never 401, regardless of what cookies it carries. Real browsers attach
|
|
354
|
+
* stale session cookies from prior visits on every request, so a "has Cookie →
|
|
355
|
+
* try the resolver" gate locks out every returning user (the original HAP-1173
|
|
356
|
+
* fix only solved the cookie-less case). The new contract: the gateway defaults
|
|
357
|
+
* ALL requests to `fallbackPort` (the shared front) and only resolves a per-
|
|
358
|
+
* branch workspace when the request carries a DELIBERATE branch signal — the
|
|
359
|
+
* `vibe_branch` cookie the picker sets after selection, the `x-vibe-branch`
|
|
360
|
+
* header on fetch/XHR, or a `?branch=` URL search param. If a signaled resolve
|
|
361
|
+
* fails (no auth, no workspace, resolver error), the request STILL falls back
|
|
362
|
+
* to the shared front rather than 401-ing. Hard errors only surface in
|
|
363
|
+
* "lockdown" mode where no `fallbackPort` is configured.
|
|
364
|
+
*
|
|
288
365
|
* Returns { server, proxy, close() } — caller owns server.listen(port, host).
|
|
289
366
|
*/
|
|
290
367
|
export function createStagingGatewayServer({
|
|
@@ -295,6 +372,7 @@ export function createStagingGatewayServer({
|
|
|
295
372
|
backendPort,
|
|
296
373
|
resolverPath,
|
|
297
374
|
timeoutMs,
|
|
375
|
+
fallbackPort,
|
|
298
376
|
onError,
|
|
299
377
|
} = {}) {
|
|
300
378
|
if (!http || typeof http.createServer !== 'function') {
|
|
@@ -306,9 +384,13 @@ export function createStagingGatewayServer({
|
|
|
306
384
|
if (!Number.isInteger(backendPort)) {
|
|
307
385
|
throw new Error('createStagingGatewayServer: backendPort (integer) is required')
|
|
308
386
|
}
|
|
387
|
+
if (fallbackPort != null && (!Number.isInteger(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535)) {
|
|
388
|
+
throw new Error('createStagingGatewayServer: fallbackPort, if set, must be an integer in 1..65535')
|
|
389
|
+
}
|
|
309
390
|
|
|
310
391
|
const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
|
|
311
392
|
const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
|
|
393
|
+
const fallbackTarget = Number.isInteger(fallbackPort) ? `http://127.0.0.1:${fallbackPort}` : null
|
|
312
394
|
|
|
313
395
|
const log = (where, err, req) => {
|
|
314
396
|
if (typeof onError === 'function') onError(where, err, req)
|
|
@@ -342,8 +424,28 @@ export function createStagingGatewayServer({
|
|
|
342
424
|
sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
|
|
343
425
|
return
|
|
344
426
|
}
|
|
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)) {
|
|
433
|
+
if (fallbackTarget) {
|
|
434
|
+
proxy.web(req, res, { target: fallbackTarget })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
sendError(res, 401, 'Vibe staging: no branch signal and no fallback configured')
|
|
438
|
+
return
|
|
439
|
+
}
|
|
345
440
|
const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
|
|
346
441
|
if (outcome.error) {
|
|
442
|
+
// HAP-1175: even with a branch signal, a failed resolve falls back
|
|
443
|
+
// to the shared front rather than 401-ing the page load. Hard errors
|
|
444
|
+
// surface only in lockdown mode (no fallback configured).
|
|
445
|
+
if (fallbackTarget) {
|
|
446
|
+
proxy.web(req, res, { target: fallbackTarget })
|
|
447
|
+
return
|
|
448
|
+
}
|
|
347
449
|
sendError(res, outcome.status || 502, outcome.message || outcome.error)
|
|
348
450
|
return
|
|
349
451
|
}
|
|
@@ -356,9 +458,26 @@ export function createStagingGatewayServer({
|
|
|
356
458
|
try { socket.destroy() } catch { /* noop */ }
|
|
357
459
|
return
|
|
358
460
|
}
|
|
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)) {
|
|
466
|
+
if (fallbackTarget) {
|
|
467
|
+
proxy.ws(req, socket, head, { target: fallbackTarget })
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
try { socket.destroy() } catch { /* noop */ }
|
|
471
|
+
return
|
|
472
|
+
}
|
|
359
473
|
const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
|
|
360
474
|
if (outcome.error) {
|
|
361
|
-
//
|
|
475
|
+
// Failed resolve on an upgrade: prefer the fallback over destroying
|
|
476
|
+
// the socket so HMR keeps working through the shared front.
|
|
477
|
+
if (fallbackTarget) {
|
|
478
|
+
proxy.ws(req, socket, head, { target: fallbackTarget })
|
|
479
|
+
return
|
|
480
|
+
}
|
|
362
481
|
try { socket.destroy() } catch { /* noop */ }
|
|
363
482
|
return
|
|
364
483
|
}
|
package/lib/staging-gateway.js
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* STAGING_HOST_PREFIX (default "staging.") Prefix fallback for slug demux.
|
|
19
19
|
* RESOLVER_PATH (default /system/vibe/staging-resolve)
|
|
20
20
|
* RESOLVER_TIMEOUT_MS (default 2000, int)
|
|
21
|
+
* FALLBACK_PORT (default 7121, int) Where to send any request without a
|
|
22
|
+
* deliberate branch signal (vibe_branch
|
|
23
|
+
* cookie, x-vibe-branch header, or
|
|
24
|
+
* ?branch= URL param), or any signaled
|
|
25
|
+
* request whose resolution fails. The
|
|
26
|
+
* shared front (HAP-1175). Set to 0 to
|
|
27
|
+
* disable and 401 those requests instead.
|
|
21
28
|
*
|
|
22
29
|
* Usage (typical droplet pm2):
|
|
23
30
|
* BACKEND_PORT=7122 LISTEN_PORT=7123 \
|
|
@@ -40,6 +47,19 @@ function intEnv(name, fallback) {
|
|
|
40
47
|
return n
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
function fallbackPortEnv() {
|
|
51
|
+
// Allow `FALLBACK_PORT=0` to opt out (page-loads → 401, the pre-1173 behavior).
|
|
52
|
+
const raw = process.env.FALLBACK_PORT
|
|
53
|
+
if (raw === undefined) return 7121
|
|
54
|
+
if (raw === '' || raw === '0') return null
|
|
55
|
+
const n = parseInt(raw, 10)
|
|
56
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
57
|
+
console.error(`[staging-gateway] FALLBACK_PORT must be a port in 1..65535 or 0/empty (got "${raw}")`)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
return n
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
const BACKEND_PORT = intEnv('BACKEND_PORT', null)
|
|
44
64
|
if (BACKEND_PORT === null) {
|
|
45
65
|
console.error('[staging-gateway] BACKEND_PORT (int) is required')
|
|
@@ -52,6 +72,7 @@ const STAGING_DOMAIN = process.env.STAGING_DOMAIN || ''
|
|
|
52
72
|
const STAGING_HOST_PREFIX = process.env.STAGING_HOST_PREFIX || undefined
|
|
53
73
|
const RESOLVER_PATH = process.env.RESOLVER_PATH || undefined
|
|
54
74
|
const RESOLVER_TIMEOUT_MS = intEnv('RESOLVER_TIMEOUT_MS', 2000)
|
|
75
|
+
const FALLBACK_PORT = fallbackPortEnv()
|
|
55
76
|
|
|
56
77
|
const { server, close } = createStagingGatewayServer({
|
|
57
78
|
http,
|
|
@@ -61,6 +82,7 @@ const { server, close } = createStagingGatewayServer({
|
|
|
61
82
|
backendPort: BACKEND_PORT,
|
|
62
83
|
resolverPath: RESOLVER_PATH,
|
|
63
84
|
timeoutMs: RESOLVER_TIMEOUT_MS,
|
|
85
|
+
fallbackPort: FALLBACK_PORT,
|
|
64
86
|
onError: (where, err) => {
|
|
65
87
|
console.error(`[staging-gateway] ${where} error: ${err?.message || err}`)
|
|
66
88
|
},
|
|
@@ -70,6 +92,11 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
|
|
70
92
|
const target = STAGING_DOMAIN || `${STAGING_HOST_PREFIX || 'staging.'}*`
|
|
71
93
|
console.log(`[staging-gateway] listening on ${LISTEN_HOST}:${LISTEN_PORT}`)
|
|
72
94
|
console.log(`[staging-gateway] ${target} -> per-branch Vibe workspace (resolved via 127.0.0.1:${BACKEND_PORT})`)
|
|
95
|
+
if (FALLBACK_PORT) {
|
|
96
|
+
console.log(`[staging-gateway] no branch signal / failed resolve -> 127.0.0.1:${FALLBACK_PORT}`)
|
|
97
|
+
} else {
|
|
98
|
+
console.log('[staging-gateway] fallback disabled (no branch signal → 401, failed resolve → resolver status)')
|
|
99
|
+
}
|
|
73
100
|
})
|
|
74
101
|
|
|
75
102
|
function shutdown(signal) {
|