mango-cms 0.3.28 → 0.3.30

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.
@@ -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's headers (header first, then
38
- // the `vibe_branch` cookie). Returns '' when none is present so the resolver can
39
- // fall back to the site's configured default branch.
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
  //
@@ -285,14 +349,18 @@ export function createHttpResolver({
285
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
  *
288
- * Page-load auth (HAP-1173): a top-level browser navigation (`GET /`) carries
289
- * neither Cookie nor Authorization the Vibe front's session is a localStorage
290
- * token attached only to explicit fetch/XHR. If the gateway gated those on auth
291
- * the editing surface couldn't load at all. So when `fallbackPort` is configured
292
- * AND the request has no Cookie + no Authorization, the gateway proxies the
293
- * request to that port (the shared front) instead of 401-ing. Branch selection
294
- * happens client-side after the SPA loads; subsequent authenticated calls carry
295
- * `x-vibe-branch` and resolve to the per-branch workspace as before.
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.
296
364
  *
297
365
  * Returns { server, proxy, close() } — caller owns server.listen(port, host).
298
366
  */
@@ -323,9 +391,6 @@ export function createStagingGatewayServer({
323
391
  const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
324
392
  const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
325
393
  const fallbackTarget = Number.isInteger(fallbackPort) ? `http://127.0.0.1:${fallbackPort}` : null
326
- function isUnauthenticated(req) {
327
- return !(req?.headers?.cookie) && !(req?.headers?.authorization)
328
- }
329
394
 
330
395
  const log = (where, err, req) => {
331
396
  if (typeof onError === 'function') onError(where, err, req)
@@ -359,17 +424,28 @@ export function createStagingGatewayServer({
359
424
  sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
360
425
  return
361
426
  }
362
- // HAP-1173: a raw browser navigation carries no Cookie + no Authorization
363
- // (the front's session is a localStorage token attached only to fetch/XHR).
364
- // Without a fallback the SPA shell can't load editing surface is dead.
365
- // With one configured, serve page + assets from the shared front; the
366
- // branch picker fires client-side and later authenticated calls demux.
367
- if (fallbackTarget && isUnauthenticated(req)) {
368
- proxy.web(req, res, { target: fallbackTarget })
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')
369
438
  return
370
439
  }
371
440
  const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
372
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
+ }
373
449
  sendError(res, outcome.status || 502, outcome.message || outcome.error)
374
450
  return
375
451
  }
@@ -382,17 +458,26 @@ export function createStagingGatewayServer({
382
458
  try { socket.destroy() } catch { /* noop */ }
383
459
  return
384
460
  }
385
- // Page-load HMR upgrade rides without auth on the very first connect
386
- // (same reason as handleRequest above). Send it to the fallback front so
387
- // Vite HMR works for the un-routed shell; once the user picks a branch,
388
- // the next upgrade carries `x-vibe-branch` and routes per-branch.
389
- if (fallbackTarget && isUnauthenticated(req)) {
390
- proxy.ws(req, socket, head, { target: fallbackTarget })
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 */ }
391
471
  return
392
472
  }
393
473
  const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
394
474
  if (outcome.error) {
395
- // Don't write an HTTP response onto an upgrade socket Vite retries.
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
+ }
396
481
  try { socket.destroy() } catch { /* noop */ }
397
482
  return
398
483
  }
@@ -18,10 +18,12 @@
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 requests with no Cookie
22
- * and no Authorization header — i.e. a
23
- * browser page-load (HAP-1173). Usually
24
- * the site's shared front. Set to 0 to
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
25
27
  * disable and 401 those requests instead.
26
28
  *
27
29
  * Usage (typical droplet pm2):
@@ -91,9 +93,9 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
91
93
  console.log(`[staging-gateway] listening on ${LISTEN_HOST}:${LISTEN_PORT}`)
92
94
  console.log(`[staging-gateway] ${target} -> per-branch Vibe workspace (resolved via 127.0.0.1:${BACKEND_PORT})`)
93
95
  if (FALLBACK_PORT) {
94
- console.log(`[staging-gateway] page-load (no Cookie + no Authorization) -> 127.0.0.1:${FALLBACK_PORT}`)
96
+ console.log(`[staging-gateway] no branch signal / failed resolve -> 127.0.0.1:${FALLBACK_PORT}`)
95
97
  } else {
96
- console.log('[staging-gateway] page-load fallback disabled (no Cookie + no Authorization401)')
98
+ console.log('[staging-gateway] fallback disabled (no branch signal 401, failed resolve resolver status)')
97
99
  }
98
100
  })
99
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mango-cms",
3
- "version": "0.3.28",
3
+ "version": "0.3.30",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {
package/vite.config.js CHANGED
@@ -34,6 +34,17 @@ export default defineConfig(({ mode }) => {
34
34
 
35
35
  const buildPath = legacyMode ? mangoRoot : userProjectRoot
36
36
 
37
+ // HAP-1180: honour an explicit per-worktree Vite cache dir. Every per-branch
38
+ // vibe dev watcher (`vite build --watch`) is spawned with cwd = the SHARED
39
+ // node_modules/mango-cms, so without an override they all share one default
40
+ // `.vite` optimize cache and contend — the cross-branch half of the
41
+ // leaked-watcher → white-screen failure (HAP-1179). The dev-server wrapper
42
+ // (generations-vibe mango/helpers/vibeDevServer.mjs) exports
43
+ // MANGO_VITE_CACHE_DIR=<worktree>/.vite-cache (absolute); read it here so each
44
+ // worktree's build keeps an isolated cache. No-op when the env var is unset.
45
+ const cacheDir = process.env.MANGO_VITE_CACHE_DIR || process.env.VITE_CACHE_DIR || undefined
46
+ if (cacheDir) console.log('Vite cacheDir (per-worktree):', cacheDir)
47
+
37
48
  // Auto-detect dependencies to externalize (avoids bundling native modules, etc.)
38
49
  const getDeps = (pkgPath) => {
39
50
  if (!fs.existsSync(pkgPath)) return []
@@ -59,6 +70,7 @@ export default defineConfig(({ mode }) => {
59
70
  })
60
71
 
61
72
  return {
73
+ ...(cacheDir ? { cacheDir } : {}),
62
74
  build: {
63
75
  ssr: path.resolve(mangoRoot, 'src/main.js'),
64
76
  outDir: path.resolve(buildPath, 'build'),