OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / 5eccd0a

http transport: add prefix option, strip before /rpc/ match
Daemons reverse-proxied or accessed directly under a path prefix (e.g. pear-git-v3 served at /git/) used to hang on /<prefix>/rpc/* requests: hyper-svc's listener only matched '/rpc/' and the consumer's listener strips the prefix but skips '/rpc/' assuming hyper-svc handled it. Neither responded. HttpServer now accepts opts.prefix; _onRequest computes a prefix-stripped path locally (without mutating req.url, so consumer listeners' own prefix handling is unaffected) and uses it to match '/rpc/'. Service threads opts.http.prefix through. Includes a regression test with a 4 s ceiling so the failure mode (a hang) is loud rather than silent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5eccd0ae76cedc9ab36ae86b857ceaf6aec9ac3c
by plankatron <fleeky@gmail.com> · 2026-05-07T00:45:07.000Z

Changed files (3) +100 -2

lib/service.js +1 -0
……@@ -83,6 +83,7 @@ export class Service extends ReadyResource {
8383 this._http = new HttpServer({
8484 port: this._httpOpt.port,
8585 host: this._httpOpt.host,
86 prefix: this._httpOpt.prefix,
8687 ctxFactory: this._httpOpt.ctxFactory,
8788 dispatch
8889 })
lib/transports/http.js +35 -2
……@@ -27,6 +27,7 @@ export class HttpServer {
2727 * @param {object} opts
2828 * @param {number} [opts.port=0] - 0 = random port
2929 * @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')
3031 * @param {(method: string, params: any, ctx: object) => Promise<any>} opts.dispatch - request handler
3132 */
3233 constructor (opts = {}) {
……@@ -35,6 +36,14 @@ export class HttpServer {
3536 this._port = opts.port || 0
3637 this._host = opts.host || '127.0.0.1'
3738 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)
3847 // Optional consumer-provided ctx builder. Called per /rpc/* request to
3948 // populate fields like identity from request headers/cookies. Receives
4049 // (req) and may return either an object or a Promise<object>. The
……@@ -86,8 +95,21 @@ export class HttpServer {
8695 }
8796
8897 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]
91113 let body
92114 try {
93115 body = await collectBody(req)
……@@ -179,3 +201,14 @@ function jsonError (res, status, message) {
179201 res.writeHead(status, { 'Content-Type': 'application/json' })
180202 res.end(JSON.stringify({ error: message }))
181203}
204
205function 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}
test/basic.test.js +64 -0
……@@ -266,6 +266,39 @@ test('HTTP unknown method returns 404', async (t) => {
266266 }
267267})
268268
269test('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
269302test('HTTP app routes coexist with /rpc', async (t) => {
270303 const svc = new Service({ name: 'test', http: { port: 0 } })
271304 svc.handle('ping', async () => 'pong')
……@@ -430,6 +463,37 @@ function fetchText (url) {
430463 })
431464}
432465
466function 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
433497// ── Swarm-RPC ─────────────────────────────────────────────────
434498
435499test('swarm-RPC: client calls handler on server via Hyperswarm', async (t) => {