OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 7ad02e9

0.9.56: HTTP path extensions (plain GET pages another role serves)
extensions.http: pathname -> async ({ req, url, host }) => ({ status, headers, body }) | null, checked before the XRPC dispatch. A handler that returns null declines and the request falls through unchanged, so a page can be served on one host only (the gateway's public status page on the server host, not on the *.7rnx.net handle hosts). Tests first: 2 in extensions.test.js (red); Bare 25/25, Node 73/73. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7ad02e95daa98d1c6fe4b4ce7338450f55eb77b5
by plankatron <fleeky@gmail.com> · 2026-10-14T01:12:36.000Z

Changed files (4) +41 -3

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.55",
3 "version": "0.9.56",
44 "description": "Stateless keyless atproto MEMBRANE \u2014 fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
55 "type": "module",
66 "main": "index.js",
pds/lib/membrane.js +1 -1
……@@ -149,7 +149,7 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null }
149149 // 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers.
150150 // XRPC methods and WebSocket paths another role of this process serves through the membrane (the
151151 // gateway's lens). Empty until a role registers; did.json advertises #bsky_fg once a skeleton is served.
152 const extensions = { xrpc: new Map(), ws: new Map() }
152 const extensions = { xrpc: new Map(), ws: new Map(), http: new Map() }
153153 let rooms = null
154154 const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], extensions })
155155 await new Promise((resolve, reject) => {
pds/lib/server-bare.js +16 -1
……@@ -113,8 +113,11 @@ const headerSafe = (s) => String(s).replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g
113113// Mutable Maps, exposed as `server.extensions`, so a role can register after the server is listening.
114114// xrpc: nsid -> async ({ query, req }) => ({ status, body, headers? })
115115// ws: pathname -> (ws, url, req) => void
116// http: pathname -> async ({ req, url, host }) => ({ status, headers, body }) | null (plain GET pages;
117// null declines and the request falls through unchanged — e.g. a page served on one host only)
116118export function createPdsServer ({ peers = null, serverDid = null, availableUserDomains = [], extensions = null } = {}) {
117 const ext = extensions || { xrpc: new Map(), ws: new Map() }
119 const ext = extensions || { xrpc: new Map(), ws: new Map(), http: new Map() }
120 if (!ext.http) ext.http = new Map()
118121 const resolveHandle = (h) => (peers ? peers.resolveHandle(h) : null)
119122 const resolveDid = async (idOrHandle) => {
120123 if (!idOrHandle) return null
……@@ -174,6 +177,18 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
174177
175178 // did:web resolution: GET /.well-known/did.json → the membrane's own did doc, advertising #bsky_fg
176179 // exactly when a feed skeleton is served here (a registered extension).
180 // A plain page another role serves (the gateway's public status page). Checked before the XRPC
181 // dispatch; a handler that returns null declines, and the request continues exactly as before.
182 if (req.method === 'GET' && ext.http.has(url.pathname)) {
183 const host = (req.headers.host || '').split(':')[0].toLowerCase()
184 const out = await ext.http.get(url.pathname)({ req, url, host })
185 if (out) {
186 const b = Buffer.from(String(out.body == null ? '' : out.body))
187 res.writeHead(out.status || 200, { 'content-type': 'text/html; charset=utf-8', ...(out.headers || {}), 'content-length': b.length })
188 return res.end(b)
189 }
190 }
191
177192 if (req.method === 'GET' && url.pathname === '/.well-known/did.json') {
178193 const doc = buildMembraneDidDoc(serverDid, { feedGenerator: ext.xrpc.has('app.bsky.feed.getFeedSkeleton') })
179194 if (!doc) return sendErr(res, 404, 'NotFound', 'no did:web served here')
pds/test/bare/extensions.test.js +23 -0
……@@ -77,3 +77,26 @@ test('buildMembraneDidDoc: did:web only', (t) => {
7777 t.is(buildMembraneDidDoc(null, {}), null)
7878 t.alike(buildMembraneDidDoc(SERVER_DID, { feedGenerator: false }).service, [])
7979})
80
81test('★ an HTTP path extension serves a plain page, and is told the request host', async (t) => {
82 const server = await listening({})
83 t.teardown(() => server.close())
84 const hosts = []
85 server.extensions.http.set('/status', async ({ host }) => {
86 hosts.push(host)
87 return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: '<h1>ok</h1>' }
88 })
89 const r = await get(server.address().port, '/status')
90 t.is(r.status, 200)
91 t.is(r.body, '<h1>ok</h1>')
92 t.ok(/text\/html/.test(r.headers['content-type']))
93 t.is(hosts[0], '127.0.0.1', 'the Host header, without the port')
94})
95
96test('an HTTP extension may decline (null) and the request falls through as before', async (t) => {
97 const server = await listening({})
98 t.teardown(() => server.close())
99 server.extensions.http.set('/', async () => null)
100 const r = await get(server.address().port, '/')
101 t.is(r.status, 404, 'unchanged behaviour for a declined path')
102})