mango-cms 0.3.25 → 0.3.26
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/dev-proxy.js +2 -1
- package/lib/devProxyGateway.js +170 -9
- package/lib/staging-gateway.js +81 -0
- package/package.json +1 -1
package/lib/dev-proxy.js
CHANGED
|
@@ -50,7 +50,8 @@ proxy.on('error', (err, req, res) => {
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
// Production resolver for the staging route: hits a small Mango endpoint that
|
|
53
|
-
//
|
|
53
|
+
// authorizes the member (cookie/auth) and looks up vibeWorkspaces by the selected
|
|
54
|
+
// (site, branch) — the branch travels with the request (HAP-1164).
|
|
54
55
|
const stagingResolver = createHttpResolver({ backendPort, http });
|
|
55
56
|
|
|
56
57
|
function getTarget(host) {
|
package/lib/devProxyGateway.js
CHANGED
|
@@ -17,14 +17,45 @@
|
|
|
17
17
|
* Cookie / Authorization headers to Mango on the backend port — Mango identifies
|
|
18
18
|
* the member (`req.session.memberId` for the session cookie, member by
|
|
19
19
|
* password-hash for the Authorization header — same path getMember() uses
|
|
20
|
-
* everywhere else),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* everywhere else) and authorizes them, but the workspace is keyed by the
|
|
21
|
+
* SELECTED BRANCH (HAP-1164), looked up as `vibeWorkspaces` by (site, branch).
|
|
22
|
+
* The branch travels with the request (x-vibe-branch header / vibe_branch cookie);
|
|
23
|
+
* two members on the same branch resolve to the same port. The endpoint that does
|
|
24
|
+
* the lookup is owned by the provisioning service (HAP-1154); the contract is
|
|
25
|
+
* documented on createHttpResolver below.
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
export const DEFAULT_STAGING_PREFIX = 'staging.'
|
|
27
29
|
|
|
30
|
+
// Header / cookie the editing surface (and the orchestrator) use to carry the
|
|
31
|
+
// SELECTED BRANCH with a staging request (HAP-1164). The branch — not the session
|
|
32
|
+
// identity — is what the resolver demuxes on, so it must travel with every
|
|
33
|
+
// request. Header wins over cookie when both are present.
|
|
34
|
+
export const BRANCH_HEADER = 'x-vibe-branch'
|
|
35
|
+
export const BRANCH_COOKIE = 'vibe_branch'
|
|
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.
|
|
40
|
+
export function parseBranchFromReq(req) {
|
|
41
|
+
const headerVal = req?.headers?.[BRANCH_HEADER]
|
|
42
|
+
if (headerVal) return String(Array.isArray(headerVal) ? headerVal[0] : headerVal).trim()
|
|
43
|
+
|
|
44
|
+
const cookieHeader = req?.headers?.cookie || ''
|
|
45
|
+
for (const pair of String(cookieHeader).split(';')) {
|
|
46
|
+
const idx = pair.indexOf('=')
|
|
47
|
+
if (idx === -1) continue
|
|
48
|
+
if (pair.slice(0, idx).trim() === BRANCH_COOKIE) {
|
|
49
|
+
try {
|
|
50
|
+
return decodeURIComponent(pair.slice(idx + 1).trim())
|
|
51
|
+
} catch {
|
|
52
|
+
return pair.slice(idx + 1).trim()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
// Pure: does this request's Host header look like a staging request, and what
|
|
29
60
|
// site slug does it resolve to?
|
|
30
61
|
//
|
|
@@ -89,9 +120,13 @@ export async function resolveStagingTarget({ req, slug, resolve }) {
|
|
|
89
120
|
}
|
|
90
121
|
}
|
|
91
122
|
|
|
123
|
+
// The SELECTED BRANCH (HAP-1164) travels with the request and is what the
|
|
124
|
+
// resolver demuxes on. Absent → resolver falls back to the configured default.
|
|
125
|
+
const branch = parseBranchFromReq(req)
|
|
126
|
+
|
|
92
127
|
let result
|
|
93
128
|
try {
|
|
94
|
-
result = await resolve({ slug, cookieHeader, authHeader, req })
|
|
129
|
+
result = await resolve({ slug, branch, cookieHeader, authHeader, req })
|
|
95
130
|
} catch (err) {
|
|
96
131
|
return { error: 'resolver_error', status: 502, message: `Vibe staging resolver error: ${err?.message || err}` }
|
|
97
132
|
}
|
|
@@ -117,13 +152,13 @@ export async function resolveStagingTarget({ req, slug, resolve }) {
|
|
|
117
152
|
* Production resolver: calls Mango (running on the backend port colocated with
|
|
118
153
|
* this proxy) at a small system endpoint that does identity + workspace lookup.
|
|
119
154
|
*
|
|
120
|
-
* GET <resolverPath>?slug=<slug>
|
|
155
|
+
* GET <resolverPath>?slug=<slug>[&branch=<branch>]
|
|
121
156
|
* forwards: Cookie, Authorization
|
|
122
157
|
*
|
|
123
158
|
* 200 { port: <int> } workspace found, proxy to that port
|
|
124
159
|
* 401 no member could be identified
|
|
125
160
|
* 403 member identified, but not authorized for slug
|
|
126
|
-
* 404 no vibeWorkspaces record for (
|
|
161
|
+
* 404 no vibeWorkspaces record for (site, branch)
|
|
127
162
|
* 5xx / network error surfaced as resolver_error
|
|
128
163
|
*
|
|
129
164
|
* The endpoint implementation lives with the provisioning service (HAP-1154)
|
|
@@ -146,9 +181,10 @@ export function createHttpResolver({
|
|
|
146
181
|
throw new Error('createHttpResolver: http module with request() is required')
|
|
147
182
|
}
|
|
148
183
|
|
|
149
|
-
return function resolve({ slug, cookieHeader, authHeader }) {
|
|
184
|
+
return function resolve({ slug, branch, cookieHeader, authHeader }) {
|
|
150
185
|
return new Promise((resolvePromise) => {
|
|
151
|
-
|
|
186
|
+
let path = `${resolverPath}?slug=${encodeURIComponent(slug)}`
|
|
187
|
+
if (branch) path += `&branch=${encodeURIComponent(branch)}`
|
|
152
188
|
const headers = {}
|
|
153
189
|
if (cookieHeader) headers.cookie = cookieHeader
|
|
154
190
|
if (authHeader) headers.authorization = authHeader
|
|
@@ -229,3 +265,128 @@ export function createHttpResolver({
|
|
|
229
265
|
})
|
|
230
266
|
}
|
|
231
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Standalone staging-gateway server factory (HAP-1165).
|
|
271
|
+
*
|
|
272
|
+
* Wraps parseStagingHost + resolveStagingTarget + createHttpResolver into a
|
|
273
|
+
* runnable HTTP server fronted by nginx on the droplet. Listens on a configured
|
|
274
|
+
* loopback port; nginx proxy_passes `staging-vibe.<site>` to it instead of to
|
|
275
|
+
* the shared `:7121` front, so per-branch isolation actually happens.
|
|
276
|
+
*
|
|
277
|
+
* Pure DI: `http` and `httpProxy` are injected so the module itself has no
|
|
278
|
+
* runtime imports and stays unit-testable with plain node fakes. The thin
|
|
279
|
+
* CLI entry `lib/staging-gateway.js` wires the real modules.
|
|
280
|
+
*
|
|
281
|
+
* Behavior:
|
|
282
|
+
* - Matching staging host → resolve via Mango → http-proxy to the workspace port.
|
|
283
|
+
* - WebSocket upgrade (Vite HMR) → same resolution → proxy.ws to the same port.
|
|
284
|
+
* - Non-staging host → 404 with a clear message (no misroute).
|
|
285
|
+
* - Resolver errors (401/403/404/502) → surfaced as the resolver's HTTP status.
|
|
286
|
+
* - Upgrade-time resolver failure → socket destroyed (Vite retries the WS).
|
|
287
|
+
*
|
|
288
|
+
* Returns { server, proxy, close() } — caller owns server.listen(port, host).
|
|
289
|
+
*/
|
|
290
|
+
export function createStagingGatewayServer({
|
|
291
|
+
http,
|
|
292
|
+
httpProxy,
|
|
293
|
+
stagingDomain,
|
|
294
|
+
stagingHostPrefix,
|
|
295
|
+
backendPort,
|
|
296
|
+
resolverPath,
|
|
297
|
+
timeoutMs,
|
|
298
|
+
onError,
|
|
299
|
+
} = {}) {
|
|
300
|
+
if (!http || typeof http.createServer !== 'function') {
|
|
301
|
+
throw new Error('createStagingGatewayServer: http module with createServer() is required')
|
|
302
|
+
}
|
|
303
|
+
if (!httpProxy || typeof httpProxy.createProxyServer !== 'function') {
|
|
304
|
+
throw new Error('createStagingGatewayServer: httpProxy module with createProxyServer() is required')
|
|
305
|
+
}
|
|
306
|
+
if (!Number.isInteger(backendPort)) {
|
|
307
|
+
throw new Error('createStagingGatewayServer: backendPort (integer) is required')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const resolve = createHttpResolver({ backendPort, resolverPath, timeoutMs, http })
|
|
311
|
+
const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true })
|
|
312
|
+
|
|
313
|
+
const log = (where, err, req) => {
|
|
314
|
+
if (typeof onError === 'function') onError(where, err, req)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
proxy.on('error', (err, req, res) => {
|
|
318
|
+
log('proxy', err, req)
|
|
319
|
+
if (res && typeof res.writeHead === 'function' && !res.headersSent) {
|
|
320
|
+
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
321
|
+
res.end(`Bad gateway: ${err?.message || err}`)
|
|
322
|
+
} else if (res && typeof res.destroy === 'function') {
|
|
323
|
+
// Upgrade path: `res` is a Socket — close it, browser retries.
|
|
324
|
+
try { res.destroy() } catch { /* noop */ }
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const parseHostOpts = { stagingDomain, stagingHostPrefix }
|
|
329
|
+
function parseHost(req) {
|
|
330
|
+
return parseStagingHost(req?.headers?.host, parseHostOpts)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function sendError(res, status, message) {
|
|
334
|
+
if (!res || res.headersSent) return
|
|
335
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
336
|
+
res.end(message)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function handleRequest(req, res) {
|
|
340
|
+
const parsed = parseHost(req)
|
|
341
|
+
if (!parsed) {
|
|
342
|
+
sendError(res, 404, `Unknown host: ${req?.headers?.host || '(missing Host header)'}`)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
|
|
346
|
+
if (outcome.error) {
|
|
347
|
+
sendError(res, outcome.status || 502, outcome.message || outcome.error)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
proxy.web(req, res, { target: outcome.target })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function handleUpgrade(req, socket, head) {
|
|
354
|
+
const parsed = parseHost(req)
|
|
355
|
+
if (!parsed) {
|
|
356
|
+
try { socket.destroy() } catch { /* noop */ }
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const outcome = await resolveStagingTarget({ req, slug: parsed.slug, resolve })
|
|
360
|
+
if (outcome.error) {
|
|
361
|
+
// Don't write an HTTP response onto an upgrade socket — Vite retries.
|
|
362
|
+
try { socket.destroy() } catch { /* noop */ }
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
proxy.ws(req, socket, head, { target: outcome.target })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const server = http.createServer((req, res) => {
|
|
369
|
+
handleRequest(req, res).catch((err) => {
|
|
370
|
+
log('request', err, req)
|
|
371
|
+
sendError(res, 502, `Staging gateway error: ${err?.message || err}`)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
server.on('upgrade', (req, socket, head) => {
|
|
376
|
+
handleUpgrade(req, socket, head).catch((err) => {
|
|
377
|
+
log('upgrade', err, req)
|
|
378
|
+
try { socket.destroy() } catch { /* noop */ }
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
server,
|
|
384
|
+
proxy,
|
|
385
|
+
close() {
|
|
386
|
+
return new Promise((res) => {
|
|
387
|
+
try { proxy.close(() => {}) } catch { /* noop */ }
|
|
388
|
+
server.close(() => res())
|
|
389
|
+
})
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mango Vibe Staging Gateway (HAP-1165)
|
|
5
|
+
*
|
|
6
|
+
* Standalone server that fronts the multi-user staging editing surface. nginx
|
|
7
|
+
* proxy_passes the staging hostname (e.g. `staging-vibe.generations.org`) to
|
|
8
|
+
* this process on a loopback port; the gateway demuxes by selected branch
|
|
9
|
+
* (HAP-1164) into the right Vibe workspace's dev-server port, including the
|
|
10
|
+
* Vite HMR WebSocket upgrade.
|
|
11
|
+
*
|
|
12
|
+
* Configuration (env vars):
|
|
13
|
+
* BACKEND_PORT (required, int) Mango port that owns /system/vibe/staging-resolve.
|
|
14
|
+
* LISTEN_PORT (default 7123, int) Loopback port to bind.
|
|
15
|
+
* LISTEN_HOST (default 127.0.0.1) Bind host — keep loopback so nginx fronts it.
|
|
16
|
+
* STAGING_DOMAIN (optional) Exact staging hostname; when set, only that host
|
|
17
|
+
* (and `staging.*` prefix matches) are routed.
|
|
18
|
+
* STAGING_HOST_PREFIX (default "staging.") Prefix fallback for slug demux.
|
|
19
|
+
* RESOLVER_PATH (default /system/vibe/staging-resolve)
|
|
20
|
+
* RESOLVER_TIMEOUT_MS (default 2000, int)
|
|
21
|
+
*
|
|
22
|
+
* Usage (typical droplet pm2):
|
|
23
|
+
* BACKEND_PORT=7122 LISTEN_PORT=7123 \
|
|
24
|
+
* STAGING_DOMAIN=staging-vibe.generations.org \
|
|
25
|
+
* node node_modules/mango-cms/lib/staging-gateway.js
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import http from 'http'
|
|
29
|
+
import httpProxy from 'http-proxy'
|
|
30
|
+
import { createStagingGatewayServer } from './devProxyGateway.js'
|
|
31
|
+
|
|
32
|
+
function intEnv(name, fallback) {
|
|
33
|
+
const raw = process.env[name]
|
|
34
|
+
if (raw === undefined || raw === '') return fallback
|
|
35
|
+
const n = parseInt(raw, 10)
|
|
36
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
37
|
+
console.error(`[staging-gateway] ${name} must be a positive integer (got "${raw}")`)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
return n
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BACKEND_PORT = intEnv('BACKEND_PORT', null)
|
|
44
|
+
if (BACKEND_PORT === null) {
|
|
45
|
+
console.error('[staging-gateway] BACKEND_PORT (int) is required')
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LISTEN_PORT = intEnv('LISTEN_PORT', 7123)
|
|
50
|
+
const LISTEN_HOST = process.env.LISTEN_HOST || '127.0.0.1'
|
|
51
|
+
const STAGING_DOMAIN = process.env.STAGING_DOMAIN || ''
|
|
52
|
+
const STAGING_HOST_PREFIX = process.env.STAGING_HOST_PREFIX || undefined
|
|
53
|
+
const RESOLVER_PATH = process.env.RESOLVER_PATH || undefined
|
|
54
|
+
const RESOLVER_TIMEOUT_MS = intEnv('RESOLVER_TIMEOUT_MS', 2000)
|
|
55
|
+
|
|
56
|
+
const { server, close } = createStagingGatewayServer({
|
|
57
|
+
http,
|
|
58
|
+
httpProxy,
|
|
59
|
+
stagingDomain: STAGING_DOMAIN || undefined,
|
|
60
|
+
stagingHostPrefix: STAGING_HOST_PREFIX,
|
|
61
|
+
backendPort: BACKEND_PORT,
|
|
62
|
+
resolverPath: RESOLVER_PATH,
|
|
63
|
+
timeoutMs: RESOLVER_TIMEOUT_MS,
|
|
64
|
+
onError: (where, err) => {
|
|
65
|
+
console.error(`[staging-gateway] ${where} error: ${err?.message || err}`)
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
|
70
|
+
const target = STAGING_DOMAIN || `${STAGING_HOST_PREFIX || 'staging.'}*`
|
|
71
|
+
console.log(`[staging-gateway] listening on ${LISTEN_HOST}:${LISTEN_PORT}`)
|
|
72
|
+
console.log(`[staging-gateway] ${target} -> per-branch Vibe workspace (resolved via 127.0.0.1:${BACKEND_PORT})`)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
function shutdown(signal) {
|
|
76
|
+
console.log(`[staging-gateway] ${signal} — closing server`)
|
|
77
|
+
close().then(() => process.exit(0)).catch(() => process.exit(1))
|
|
78
|
+
setTimeout(() => process.exit(1), 5000).unref()
|
|
79
|
+
}
|
|
80
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
81
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|