mango-cms 0.3.31 → 0.3.33

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.
@@ -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
  }
@@ -424,12 +485,15 @@ export function createStagingGatewayServer({
424
485
  sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
425
486
  return
426
487
  }
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)) {
488
+ // HAP-1175 / HAP-1188: default route is the shared fallback front. Only
489
+ // requests that carry a DELIBERATE branch signal attempt per-branch
490
+ // resolution the branch in the Host header (HAP-1188, `<branch>--<host>`),
491
+ // a vibe_branch cookie, an x-vibe-branch header, or a ?branch= URL param.
492
+ // A returning browser with stale session cookies but no branch signal —
493
+ // the case that re-broke staging in HAP-1175 — passes straight through to
494
+ // the fallback. The shell host has no host-branch, so it lands here too.
495
+ const hostBranch = parsed.branch || ''
496
+ if (!hostBranch && !hasBranchSignal(req)) {
433
497
  if (fallbackTarget) {
434
498
  proxy.web(req, res, { target: fallbackTarget })
435
499
  return
@@ -437,7 +501,7 @@ export function createStagingGatewayServer({
437
501
  sendError(res, 401, 'Vibe staging: no branch signal and no fallback configured')
438
502
  return
439
503
  }
440
- const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
504
+ const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve, branch: hostBranch })
441
505
  if (outcome.error) {
442
506
  // HAP-1175: even with a branch signal, a failed resolve falls back
443
507
  // to the shared front rather than 401-ing the page load. Hard errors
@@ -458,11 +522,14 @@ export function createStagingGatewayServer({
458
522
  try { socket.destroy() } catch { /* noop */ }
459
523
  return
460
524
  }
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)) {
525
+ // HAP-1175 / HAP-1188: same default-to-fallback rule as handleRequest.
526
+ // The shell's own HMR upgrade rides without a branch signal → fallback.
527
+ // The iframe's HMR WebSocket carries the branch in the Host header
528
+ // (`<branch>--<host>`), so it resolves to the SAME per-branch dev server
529
+ // as the iframe's document — this is what closes the dual-Vue / white-
530
+ // screen gap (HAP-1177 class) that `?branch=`-on-document-only left open.
531
+ const hostBranch = parsed.branch || ''
532
+ if (!hostBranch && !hasBranchSignal(req)) {
466
533
  if (fallbackTarget) {
467
534
  proxy.ws(req, socket, head, { target: fallbackTarget })
468
535
  return
@@ -470,7 +537,7 @@ export function createStagingGatewayServer({
470
537
  try { socket.destroy() } catch { /* noop */ }
471
538
  return
472
539
  }
473
- const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
540
+ const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve, branch: hostBranch })
474
541
  if (outcome.error) {
475
542
  // Failed resolve on an upgrade: prefer the fallback over destroying
476
543
  // the socket so HMR keeps working through the shared front.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mango-cms",
3
- "version": "0.3.31",
3
+ "version": "0.3.33",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {