/**
* `corpora serve` — run a built `dist/` over HTTP.
*
* This is the local development story: the same handler the Worker runs, in front of the
* same bytes, so what verifies here verifies in production.
*
* @see [INTERFACES](../../docs/INTERFACES.md#cli)
*/
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
import { serve } from './index.js'
const USAGE = `corpora serve — serve a built dist/ over HTTP
Usage:
corpora serve --dist
[--port 2583] [--host 127.0.0.1]
Options:
--dist directory produced by \`corpora build\` (must contain data.js)
--port port to bind, 0 for an ephemeral one (default 2583)
--host address to bind (default 127.0.0.1)
--quiet print nothing but errors
--help show this message
`
/**
* @param {string[]} argv
* @returns {Promise}
*/
export async function run(argv) {
const { values } = parseArgs({
args: argv,
options: {
dist: { type: 'string' },
port: { type: 'string', default: '2583' },
host: { type: 'string', default: '127.0.0.1' },
quiet: { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
allowPositionals: false,
})
if (values.help) {
process.stdout.write(USAGE)
return 0
}
if (!values.dist) {
process.stderr.write(`corpora serve: --dist is required\n\n${USAGE}`)
return 1
}
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`corpora serve: --port must be 0-65535, got '${values.port}'\n`)
return 1
}
const dataPath = resolve(values.dist, 'data.js')
if (!existsSync(dataPath)) {
process.stderr.write(
`corpora serve: no data.js in ${resolve(values.dist)}\n` +
` run \`corpora build --lexicons --out ${values.dist} --did \` first\n`,
)
return 1
}
/** @type {{ default: import('../serve/types.js').CorporaData }} */
const mod = await import(pathToFileURL(dataPath).href)
const data = mod.default
const { url, close } = await serve(data, { port, host: values.host })
if (!values.quiet) process.stdout.write(banner(url, data))
await stopSignal()
if (!values.quiet) process.stdout.write('\ncorpora serve: stopping\n')
await close()
return 0
}
/**
* Copy-pasteable requests against the data actually loaded, not against placeholders.
*
* Query strings are built with `URLSearchParams`, never by interpolation. A `did:web`
* carrying a port is spelled `did:web:localhost%3A2583`, so the DID already contains a
* percent-escape that must itself be escaped to survive a query string. Interpolating it
* yields `%3A`, which decodes back to a colon and produces a `RepoNotFound` naming a DID
* the user never typed — and a port-bearing `did:web` is exactly the local-development
* shape, so that would be the first command anyone copies.
*
* @param {string} url
* @param {import('../serve/types.js').CorporaData} data
* @returns {string}
*/
function banner(url, data) {
const nsids = Object.keys(data.records)
const rkey = nsids[0] ?? 'com.example.thing'
const { did, collection } = data
const xrpc = (/** @type {string} */ nsid, /** @type {Record} */ params) =>
`${url}/xrpc/${nsid}?${new URLSearchParams(params)}`
return [
`corpora serve: ${did} — ${nsids.length} lexicon${nsids.length === 1 ? '' : 's'} at rev ${data.rev}`,
` ${url}`,
'',
` curl -s ${url}/.well-known/did.json`,
` curl -s '${xrpc('com.atproto.repo.getRecord', { repo: did, collection, rkey })}'`,
` curl -sD- -o proof.car '${xrpc('com.atproto.sync.getRecord', { did, collection, rkey })}'`,
'',
' Ctrl-C to stop.',
'',
].join('\n')
}
/**
* Resolves on the first Ctrl-C or SIGTERM. `run` must not return before then: the CLI
* dispatcher exits the process on the value it resolves with.
* @returns {Promise}
*/
function stopSignal() {
return new Promise((done) => {
const stop = () => {
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
done()
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
})
}