chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
A Cloudflare PDS reaches Workers on its own account through service bindings
.changeset/fetch-bindings.mdnew+8−0
| @@ -0,0 +1,8 @@ | ||
--- | ||
'@pdsjs/cloudflare': patch | ||
--- | ||
| ||
`PDS_FETCH_BINDINGS` names hosts that the lexicon and DID resolvers reach | ||
through a service binding, as `host=BINDING` pairs. A Worker cannot fetch | ||
another Worker's `workers.dev` address on the same account, so a PDS whose | ||
lexicons or DIDs are published on such a Worker read them as not published. | ||
docs/deploy-cloudflare.md+17−0
| @@ -70,6 +70,23 @@ Your `wrangler.toml` needs the same bindings as | ||
The Durable Object needs the `new_sqlite_classes` migration. Keep `BACKUPS` in a | ||
separate bucket from `BLOBS`. The retention policy deletes freely inside it. | ||
| ||
## Other Workers on your account | ||
| ||
A Worker cannot fetch another Worker's `workers.dev` address on the same | ||
Cloudflare account. When a lexicon or a DID the PDS resolves is on a PDS at | ||
such an address, bind that Worker as a service and name its host in | ||
`PDS_FETCH_BINDINGS`, as `host=BINDING` pairs separated by commas. The lexicon | ||
and DID resolvers then reach that host through the binding. | ||
| ||
```toml | ||
services = [{ binding = "LEXICONS", service = "lexicons-pds" }] | ||
| ||
[vars] | ||
PDS_FETCH_BINDINGS = "lexicons-pds.example.workers.dev=LEXICONS" | ||
``` | ||
| ||
Without the binding, every lexicon on that PDS reads as not published. | ||
| ||
## Email | ||
| ||
Account verification mail (confirming an address, authorizing a PLC operation) | ||
packages/cloudflare/src/index.js+38−6
| @@ -1243,9 +1243,40 @@ export function createEmailer(binding, from) { | ||
* @property {string} [PDS_EXPERIMENTAL_DRIVE] - "true" backs the account page's Drive section over dev.pdsjs.drive.file records | ||
* @property {string} [PDS_EMAIL_FROM] - The From address account mail is sent as; its domain must be onboarded for Email Sending. Absent, no-reply@HOSTNAME is used. Neither set, mail cannot be sent and the account page reports email as unavailable. | ||
* @property {CloudflareEmailer} [EMAIL] - Email Service send_email binding. Present only when the binding is configured; without it mail cannot be sent. | ||
* @property {string} [PDS_FETCH_BINDINGS] - Hosts the lexicon and DID resolvers reach through a service binding, as comma-separated host=BINDING pairs. A Worker cannot fetch another workers.dev Worker on its own account, so a PDS on that account needs one. | ||
* @property {DurableObjectNamespace} PDS - Durable Object namespace | ||
*/ | ||
| ||
/** | ||
* A fetch that sends each host named in `PDS_FETCH_BINDINGS` to its service | ||
* binding, and every other request to the network. | ||
* @param {Record<string, unknown>} env - The worker's env | ||
* @returns {typeof fetch} | ||
*/ | ||
export function createBoundFetch(env) { | ||
/** @type {Map<string, { fetch: typeof fetch }>} */ | ||
const bound = new Map(); | ||
for (const pair of String(env.PDS_FETCH_BINDINGS ?? '').split(',')) { | ||
const [host, name] = pair.split('=').map((part) => part.trim()); | ||
const binding = /** @type {{ fetch?: unknown } | undefined} */ ( | ||
name ? env[name] : undefined | ||
); | ||
if (host && typeof binding?.fetch === 'function') { | ||
bound.set( | ||
host.toLowerCase(), | ||
/** @type {{ fetch: typeof fetch }} */ (binding), | ||
); | ||
} | ||
} | ||
/** @type {typeof fetch} */ | ||
const boundFetch = (input, init) => { | ||
const url = input instanceof Request ? input.url : input; | ||
const binding = bound.get(new URL(url).hostname); | ||
return binding ? binding.fetch(input, init) : fetch(input, init); | ||
}; | ||
return boundFetch; | ||
} | ||
| ||
/** | ||
* Durable Object class for per-user PDS instances | ||
*/ | ||
| @@ -1293,11 +1324,12 @@ export class PDSDurableObject { | ||
// Initialize WebSocket adapter with hibernation support | ||
const webSocket = createWebSocket(state); | ||
| ||
// Initialize lexicon resolver for record validation | ||
// Wrap fetch to preserve `this` binding in Cloudflare Workers | ||
const lexiconResolver = new LexiconResolver({ | ||
fetch: (url, init) => fetch(url, init), | ||
}); | ||
// The resolvers reach a sibling Worker on this account through its | ||
// service binding. A wrapped fetch keeps the global's `this` binding. | ||
const boundFetch = createBoundFetch( | ||
/** @type {Record<string, unknown>} */ (/** @type {unknown} */ (env)), | ||
); | ||
const lexiconResolver = new LexiconResolver({ fetch: boundFetch }); | ||
| ||
// Signature verification, shared by createAccount (migration service | ||
// tokens) and spaces (credentials). P-256 is built into core; secp256k1 — | ||
| @@ -1310,7 +1342,7 @@ export class PDSDurableObject { | ||
// which reads its issuer's key. | ||
const didResolver = createCachedDidResolver({ | ||
plcUrl: env.PLC_URL, | ||
fetch: (url, init) => fetch(url, init), | ||
fetch: boundFetch, | ||
}); | ||
| ||
// Permissioned data (proposal 0016), off by default. The storage is this | ||
packages/cloudflare/test/fetch-bindings.test.jsnew+50−0
| @@ -0,0 +1,50 @@ | ||
// A Worker cannot fetch another workers.dev Worker on its own account, so the | ||
// resolvers reach such a host through a service binding named for it. | ||
| ||
import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
import { createBoundFetch } from '../src/index.js'; | ||
| ||
const LEXICONS = 'https://lexicons-pds.example.workers.dev'; | ||
| ||
afterEach(() => vi.unstubAllGlobals()); | ||
| ||
describe('createBoundFetch', () => { | ||
it('sends a bound host to its binding and the rest to the network', async () => { | ||
const binding = { fetch: vi.fn(async () => new Response('bound')) }; | ||
const network = vi.fn(async () => new Response('network')); | ||
vi.stubGlobal('fetch', network); | ||
const boundFetch = createBoundFetch({ | ||
PDS_FETCH_BINDINGS: ' Lexicons-PDS.example.workers.dev = LEXICONS ,', | ||
LEXICONS: binding, | ||
}); | ||
| ||
const url = `${LEXICONS}/xrpc/com.atproto.repo.getRecord`; | ||
expect(await (await boundFetch(url)).text()).toBe('bound'); | ||
expect(binding.fetch).toHaveBeenCalledWith(url, undefined); | ||
| ||
const request = new Request(`${LEXICONS}/xrpc/_health`); | ||
await boundFetch(request); | ||
expect(binding.fetch).toHaveBeenLastCalledWith(request, undefined); | ||
| ||
const elsewhere = 'https://plc.directory/did:plc:abc'; | ||
const init = { method: 'GET' }; | ||
expect(await (await boundFetch(new URL(elsewhere), init)).text()).toBe( | ||
'network', | ||
); | ||
expect(network).toHaveBeenCalledWith(new URL(elsewhere), init); | ||
}); | ||
| ||
it('fetches over the network when a pair names no binding', async () => { | ||
const network = vi.fn(async () => new Response('network')); | ||
vi.stubGlobal('fetch', network); | ||
const boundFetch = createBoundFetch({ | ||
PDS_FETCH_BINDINGS: 'lexicons-pds.example.workers.dev=MISSING,=X,bare', | ||
X: 'not a binding', | ||
}); | ||
| ||
await boundFetch(`${LEXICONS}/xrpc/_health`); | ||
expect(network).toHaveBeenCalledTimes(1); | ||
await createBoundFetch({})(`${LEXICONS}/xrpc/_health`); | ||
expect(network).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||