| … | … | @@ -83,6 +83,7 @@ export class Service extends ReadyResource { |
| 83 | 83 | this._http = new HttpServer({ |
| 84 | 84 | port: this._httpOpt.port, |
| 85 | 85 | host: this._httpOpt.host, |
| 86 | prefix: this._httpOpt.prefix, | |
| 86 | 87 | ctxFactory: this._httpOpt.ctxFactory, |
| 87 | 88 | dispatch |
| 88 | 89 | }) |
| … | … | @@ -27,6 +27,7 @@ export class HttpServer { |
| 27 | 27 | * @param {object} opts |
| 28 | 28 | * @param {number} [opts.port=0] - 0 = random port |
| 29 | 29 | * @param {string} [opts.host='127.0.0.1'] - bind address (local-only by default) |
| 30 | * @param {string} [opts.prefix=''] - URL prefix to strip before matching /rpc/* (e.g. '/git') | |
| 30 | 31 | * @param {(method: string, params: any, ctx: object) => Promise<any>} opts.dispatch - request handler |
| 31 | 32 | */ |
| 32 | 33 | constructor (opts = {}) { |
| … | … | @@ -35,6 +36,14 @@ export class HttpServer { |
| 35 | 36 | this._port = opts.port || 0 |
| 36 | 37 | this._host = opts.host || '127.0.0.1' |
| 37 | 38 | this._dispatch = opts.dispatch |
| 39 | // Optional URL prefix (e.g. '/git') stripped before matching /rpc/*. Set | |
| 40 | // when the daemon is reverse-proxied OR accessed directly under a path | |
| 41 | // prefix that the browser bakes into outgoing fetch URLs. Without this, | |
| 42 | // direct (non-proxied) hits to `<host>/<prefix>/rpc/*` fall through both | |
| 43 | // hyper-svc's listener (which only matches '/rpc/') and the consumer's | |
| 44 | // listener (which strips the prefix and skips '/rpc/' assuming hyper-svc | |
| 45 | // already handled it) — neither responds and the request hangs. | |
| 46 | this._prefix = normalizePrefix(opts.prefix) | |
| 38 | 47 | // Optional consumer-provided ctx builder. Called per /rpc/* request to |
| 39 | 48 | // populate fields like identity from request headers/cookies. Receives |
| 40 | 49 | // (req) and may return either an object or a Promise<object>. The |
| … | … | @@ -86,8 +95,21 @@ export class HttpServer { |
| 86 | 95 | } |
| 87 | 96 | |
| 88 | 97 | async _onRequest (req, res) { |
| 89 | if (req.method === 'POST' && req.url.startsWith('/rpc/')) { | |
| 90 | const method = req.url.slice('/rpc/'.length).split('?')[0] | |
| 98 | // Compute the path with the configured prefix removed. Don't mutate | |
| 99 | // req.url — consumers' own request listeners may have their own | |
| 100 | // prefix-stripping logic and should see the original. | |
| 101 | let path = req.url | |
| 102 | if (this._prefix && path.startsWith(this._prefix)) { | |
| 103 | const rest = path.slice(this._prefix.length) | |
| 104 | // Only treat as a prefix match when the next char is '/' (or end of | |
| 105 | // URL). Otherwise '/gitfoo' would falsely match prefix '/git'. | |
| 106 | if (rest === '' || rest[0] === '/' || rest[0] === '?') { | |
| 107 | path = rest || '/' | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | if (req.method === 'POST' && path.startsWith('/rpc/')) { | |
| 112 | const method = path.slice('/rpc/'.length).split('?')[0] | |
| 91 | 113 | let body |
| 92 | 114 | try { |
| 93 | 115 | body = await collectBody(req) |
| … | … | @@ -179,3 +201,14 @@ function jsonError (res, status, message) { |
| 179 | 201 | res.writeHead(status, { 'Content-Type': 'application/json' }) |
| 180 | 202 | res.end(JSON.stringify({ error: message })) |
| 181 | 203 | } |
| 204 | ||
| 205 | function normalizePrefix (p) { | |
| 206 | if (!p || typeof p !== 'string') return '' | |
| 207 | // Strip trailing slashes so '/git/' and '/git' behave identically. | |
| 208 | let s = p.replace(/\/+$/, '') | |
| 209 | // A bare '/' is not a real prefix. | |
| 210 | if (s === '' || s === '/') return '' | |
| 211 | // Require a leading slash so callers can't accidentally pass 'git'. | |
| 212 | if (s[0] !== '/') s = '/' + s | |
| 213 | return s | |
| 214 | } |
| … | … | @@ -266,6 +266,39 @@ test('HTTP unknown method returns 404', async (t) => { |
| 266 | 266 | } |
| 267 | 267 | }) |
| 268 | 268 | |
| 269 | test('HTTP prefix strips and dispatches /rpc/* under /<prefix>/rpc/*', async (t) => { | |
| 270 | // Regression: when a daemon is reverse-proxied OR accessed directly under | |
| 271 | // a path prefix that the browser bakes into outgoing fetch URLs (e.g. | |
| 272 | // pear-git-v3 served at /git/), direct hits to `<host>/git/rpc/*` used to | |
| 273 | // hang — hyper-svc only matched '/rpc/' and the consumer's listener | |
| 274 | // strips '/git' but skips '/rpc/' assuming hyper-svc handled it. | |
| 275 | const svc = new Service({ name: 'test', http: { port: 0, prefix: '/git' } }) | |
| 276 | svc.handle('echo', async (ctx, params) => ({ from: ctx.transport, params })) | |
| 277 | ||
| 278 | await svc.listen() | |
| 279 | t.teardown(async () => { try { await svc.close() } catch {} }) | |
| 280 | ||
| 281 | const port = svc.http.port | |
| 282 | ||
| 283 | // Prefixed path must dispatch to the RPC handler. | |
| 284 | const prefixed = await fetchJson(`http://127.0.0.1:${port}/git/rpc/echo`, { y: 'hi' }) | |
| 285 | t.is(prefixed.status, 200) | |
| 286 | t.is(prefixed.body.result.from, 'http') | |
| 287 | t.alike(prefixed.body.result.params, { y: 'hi' }) | |
| 288 | ||
| 289 | // Bare path must continue to work too — proxies that strip the prefix | |
| 290 | // before forwarding remain supported. | |
| 291 | const bare = await fetchJson(`http://127.0.0.1:${port}/rpc/echo`, { y: 'hi' }) | |
| 292 | t.is(bare.status, 200) | |
| 293 | t.is(bare.body.result.from, 'http') | |
| 294 | ||
| 295 | // A URL that *starts with* the prefix string but isn't a prefix boundary | |
| 296 | // ('/gitfoo') must NOT be treated as prefixed — that would silently | |
| 297 | // mangle unrelated paths. | |
| 298 | const notPrefix = await fetchJson(`http://127.0.0.1:${port}/gitfoo/rpc/echo`, {}) | |
| 299 | t.is(notPrefix.status, 404, '/gitfoo/* falls through to the 404 handler') | |
| 300 | }) | |
| 301 | ||
| 269 | 302 | test('HTTP app routes coexist with /rpc', async (t) => { |
| 270 | 303 | const svc = new Service({ name: 'test', http: { port: 0 } }) |
| 271 | 304 | svc.handle('ping', async () => 'pong') |
| … | … | @@ -430,6 +463,37 @@ function fetchText (url) { |
| 430 | 463 | }) |
| 431 | 464 | } |
| 432 | 465 | |
| 466 | function fetchJson (url, body) { | |
| 467 | return new Promise((resolve, reject) => { | |
| 468 | import('http').then(({ default: http }) => { | |
| 469 | const u = new URL(url) | |
| 470 | const req = http.request({ | |
| 471 | method: 'POST', | |
| 472 | hostname: u.hostname, | |
| 473 | port: u.port, | |
| 474 | path: u.pathname + u.search, | |
| 475 | headers: { 'Content-Type': 'application/json' } | |
| 476 | }, (res) => { | |
| 477 | const parts = [] | |
| 478 | res.on('data', (c) => parts.push(c)) | |
| 479 | res.on('end', () => { | |
| 480 | const text = b4a.concat(parts).toString('utf8') | |
| 481 | let parsed = null | |
| 482 | try { parsed = JSON.parse(text) } catch {} | |
| 483 | resolve({ status: res.statusCode, body: parsed, text }) | |
| 484 | }) | |
| 485 | res.on('error', reject) | |
| 486 | }) | |
| 487 | req.on('error', reject) | |
| 488 | // 4 s ceiling — the bug we're guarding against is a hang, so keep | |
| 489 | // the failure mode loud rather than letting brittle's default | |
| 490 | // run-out-the-clock behavior swallow it. | |
| 491 | req.setTimeout(4000, () => req.destroy(new Error('request timed out'))) | |
| 492 | req.end(JSON.stringify(body || {})) | |
| 493 | }) | |
| 494 | }) | |
| 495 | } | |
| 496 | ||
| 433 | 497 | // ── Swarm-RPC ───────────────────────────────────────────────── |
| 434 | 498 | |
| 435 | 499 | test('swarm-RPC: client calls handler on server via Hyperswarm', async (t) => { |