diff --git a/.gitignore b/.gitignore index 6ce340f..7fa9df7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ vite.config.ts.timestamp-* # Playwright test-results -.contrail/ \ No newline at end of file +.contrail/ +/src/lib/contrail-active/ diff --git a/README.md b/README.md index 31e3edf..7bfeb80 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,20 @@ review movies and tv shows with your atmosphere account, using popfeed.social's ## API Worker -The public Contrail API is a separate Cloudflare Worker under [`api/`](api/README.md). Use `pnpm api:dev`, `pnpm api:check`, and `pnpm api:deploy` from the repository root. +The public Contrail API is a separate Cloudflare Worker under [`api/`](api/README.md). The web app has explicit production and local Contrail modes: + +```sh +pnpm dev # use the deployed API at https://api.atmo.watch +pnpm api:dev # start only the local API +pnpm dev:local # use an already-running local API +pnpm dev:stack # start the local API and web app together +``` + +Local mode waits for `http://127.0.0.1:8787` by default and generates an ignored consumer contract under `.contrail/local-consumer`. The API command creates the ignored `api/.dev.vars` from `api/.dev.vars.example` when needed. For a custom URL, set both `ATMO_LOCAL_API_URL` and `CONTRAIL_PUBLIC_ENDPOINT` in that vars file. Local mode never falls back to production when the API is unavailable. + +The tracked generated contract under `src/lib/contrail` represents the API source tree. `src/lib/contrail-targets/prod.ts` pins the currently deployed production runtime contract. After deploying an API contract change, run `pnpm contrail:update:prod` and commit the regenerated production target and consumer files before deploying the web app. + +Use `pnpm api:check` and `pnpm api:deploy` for API validation and deployment. ## Cloudflare deployment diff --git a/api/.dev.vars.example b/api/.dev.vars.example new file mode 100644 index 0000000..531f9e0 --- /dev/null +++ b/api/.dev.vars.example @@ -0,0 +1 @@ +CONTRAIL_PUBLIC_ENDPOINT="http://127.0.0.1:8787" diff --git a/api/.gitignore b/api/.gitignore index 0dcc8a4..ba1a2cd 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1,2 +1,5 @@ node_modules/ .wrangler/ +.dev.vars +.dev.vars.* +!.dev.vars.example diff --git a/api/README.md b/api/README.md index fac7cb4..baaf7d2 100644 --- a/api/README.md +++ b/api/README.md @@ -37,7 +37,7 @@ pnpm api:dev pnpm --dir api backfill:dev ``` -Wrangler stores the local D1 database under `api/.wrangler/`. +Wrangler stores the local D1 database under `api/.wrangler/`. The dev command creates an ignored `api/.dev.vars` with the loopback public-service endpoint so local consumers receive a contract for `http://127.0.0.1:8787`; production continues to advertise `https://api.atmo.watch` from `wrangler.jsonc`. ## Updating the contract diff --git a/api/package.json b/api/package.json index 45d4681..e58587e 100644 --- a/api/package.json +++ b/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "contrail dev --wrangler", + "dev": "node ../scripts/ensure-api-dev-vars.mjs && contrail dev --wrangler", "deploy": "wrangler deploy", "backfill:dev": "contrail backfill", "backfill:remote": "contrail backfill --remote", diff --git a/api/src/worker.ts b/api/src/worker.ts index 19521bb..d593494 100644 --- a/api/src/worker.ts +++ b/api/src/worker.ts @@ -2,9 +2,30 @@ import { createWorker } from '@atmo-dev/contrail/worker'; import { lexicons } from '../lexicons/generated'; import { config } from './contrail.config'; -export default createWorker(config, { - lexicons, - publicService: { - endpoint: 'https://api.atmo.watch' +const productionEndpoint = 'https://api.atmo.watch'; +const workers = new Map>(); +type WorkerEnv = Record & { CONTRAIL_PUBLIC_ENDPOINT?: string }; + +function getWorker(endpoint: string) { + let worker = workers.get(endpoint); + if (!worker) { + worker = createWorker(config, { + lexicons, + publicService: { + endpoint, + allowInsecureHttp: endpoint.startsWith('http://') + } + }); + workers.set(endpoint, worker); + } + return worker; +} + +export default { + fetch(request: Request, env: WorkerEnv) { + return getWorker(env.CONTRAIL_PUBLIC_ENDPOINT ?? productionEndpoint).fetch(request, env); + }, + scheduled(event: ScheduledEvent, env: WorkerEnv, ctx: ExecutionContext) { + return getWorker(env.CONTRAIL_PUBLIC_ENDPOINT ?? productionEndpoint).scheduled(event, env, ctx); } -}); +}; diff --git a/api/wrangler.jsonc b/api/wrangler.jsonc index 5df669d..4c6b3ce 100644 --- a/api/wrangler.jsonc +++ b/api/wrangler.jsonc @@ -6,6 +6,9 @@ "observability": { "enabled": true }, + "vars": { + "CONTRAIL_PUBLIC_ENDPOINT": "https://api.atmo.watch" + }, "routes": [ { "pattern": "api.atmo.watch", diff --git a/package.json b/package.json index 14c77da..530846e 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,23 @@ "atproto:setup": "atproto-oauth setup", "atproto:keygen": "atproto-oauth keygen", "atproto:secret": "atproto-oauth secret", - "dev": "vite dev", - "build": "vite build", + "contrail:use:prod": "node scripts/select-contrail.mjs prod", + "contrail:use:local": "node scripts/select-contrail.mjs local", + "contrail:update:prod": "node scripts/update-prod-contrail.mjs", + "dev": "node scripts/select-contrail.mjs prod && vite dev", + "dev:prod": "node scripts/select-contrail.mjs prod && vite dev", + "dev:local": "node scripts/select-contrail.mjs local && vite dev", + "dev:stack": "node scripts/dev-stack.mjs", + "build": "node scripts/select-contrail.mjs prod && vite build", "preview": "vite preview", - "deploy": "pnpm run build && wrangler deploy", - "api:dev": "pnpm --dir api dev", - "api:check": "pnpm --dir api check", - "api:deploy": "pnpm --dir api deploy", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "deploy": "node scripts/select-contrail.mjs prod && vite build && wrangler deploy", + "api:dev": "node scripts/ensure-api-dev-vars.mjs && cd api && contrail dev --wrangler", + "api:check": "cd api && contrail lexicons check --public && tsc --noEmit", + "api:deploy": "cd api && wrangler deploy", + "prepare": "node scripts/select-contrail.mjs prod && (svelte-kit sync || echo '')", + "check": "node scripts/select-contrail.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:local": "node scripts/select-contrail.mjs local && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "node scripts/select-contrail.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "prettier --write .", "test:unit": "vitest", @@ -66,7 +73,6 @@ "@svelte-atproto/oauth": "^0.3.0", "add": "^2.0.9", "bits-ui": "^2.18.1", - "pnpm": "^11.21.0", "valibot": "^1.0.0" } } diff --git a/patches/@atmo-dev__contrail@0.17.0.patch b/patches/@atmo-dev__contrail@0.17.0.patch index 3fb9bb8..8d02377 100644 --- a/patches/@atmo-dev__contrail@0.17.0.patch +++ b/patches/@atmo-dev__contrail@0.17.0.patch @@ -205,3 +205,16 @@ index 17dbf762cee1c563435300cd1a9395acc3ec17a3..9404a89bc40c836e67cd81b7bd73ac03 audience: string; /** Built-in methods that require a method-bound AT Protocol service token. */ methods: AtprotoServiceAuthMethod[]; +diff --git a/dist/worker/index.js b/dist/worker/index.js +index a5f20d65d608bb3d7254242d1090f91d5054df52..3ed71769608206514842d0cb49e6bd8dd528de03 100644 +--- a/dist/worker/index.js ++++ b/dist/worker/index.js +@@ -15,7 +15,7 @@ import "../chunk-KN4SCF66.js"; + function createWorker(config, options = {}) { + const binding = options.binding ?? "DB"; + if (options.publicService) { +- normalizePublicServiceEndpoint(options.publicService.endpoint); ++ normalizePublicServiceEndpoint(options.publicService.endpoint, options.publicService); + validatePublicServiceLexicons(config, options.lexicons ?? []); + } + const contrail = new Contrail({ ...config, lexicons: options.lexicons }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75cd4d0..351bf9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@atmo-dev/contrail@0.17.0': - hash: 977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326 + hash: b565c0190062e0ffd64d16f502018a892b4f9d4461eb666f844bbc22eca56690 path: patches/@atmo-dev__contrail@0.17.0.patch importers: @@ -24,7 +24,7 @@ importers: version: 2.0.3 '@atmo-dev/contrail': specifier: ^0.17.0 - version: 0.17.0(patch_hash=977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) + version: 0.17.0(patch_hash=b565c0190062e0ffd64d16f502018a892b4f9d4461eb666f844bbc22eca56690)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) '@ethercorps/sveltekit-og': specifier: ^4.3.0 version: 4.3.0(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))) @@ -49,9 +49,6 @@ importers: bits-ui: specifier: ^2.18.1 version: 2.18.1(@internationalized/date@3.12.3)(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) - pnpm: - specifier: ^11.21.0 - version: 11.21.0 valibot: specifier: ^1.0.0 version: 1.4.2(typescript@6.0.3) @@ -142,7 +139,7 @@ importers: dependencies: '@atmo-dev/contrail': specifier: ^0.17.0 - version: 0.17.0(patch_hash=977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) + version: 0.17.0(patch_hash=b565c0190062e0ffd64d16f502018a892b4f9d4461eb666f844bbc22eca56690)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) devDependencies: '@atcute/lex-cli': specifier: ^3.2.1 @@ -2201,11 +2198,6 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - pnpm@11.21.0: - resolution: {integrity: sha512-UhcFvOaJkk6scvWjWHEi82JonvZXHlW6gAdv1jfBETLs/62ib61Op5xIW/3b/T1aKlsFgFp36JPeceyKbMo7sQ==} - engines: {node: '>=22.13'} - hasBin: true - postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -3063,7 +3055,7 @@ snapshots: - '@atcute/cid' - typescript - '@atmo-dev/contrail@0.17.0(patch_hash=977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1))': + '@atmo-dev/contrail@0.17.0(patch_hash=b565c0190062e0ffd64d16f502018a892b4f9d4461eb666f844bbc22eca56690)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1))': dependencies: '@atcute/atproto': 4.0.4(@atcute/lexicons@2.0.3) '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) @@ -4787,8 +4779,6 @@ snapshots: pngjs@7.0.0: {} - pnpm@11.21.0: {} - postcss-load-config@3.1.4(postcss@8.5.26): dependencies: lilconfig: 2.1.0 diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 0000000..4b26d40 --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,77 @@ +import { spawn } from 'node:child_process'; +import { selectContrail } from './select-contrail.mjs'; + +const children = new Set(); +let stopping = false; + +function spawnPnpm(args) { + const npmExecPath = process.env.npm_execpath; + const command = npmExecPath ? process.execPath : 'pnpm'; + const commandArgs = npmExecPath ? [npmExecPath, ...args] : args; + const child = spawn(command, commandArgs, { + stdio: 'inherit', + detached: process.platform !== 'win32' + }); + children.add(child); + child.once('exit', () => children.delete(child)); + return child; +} + +function stopChild(child, signal = 'SIGTERM') { + if (!child.pid || child.killed) return; + try { + if (process.platform === 'win32') child.kill(signal); + else process.kill(-child.pid, signal); + } catch { + // The process may already have exited. + } +} + +function stopAll(signal = 'SIGTERM') { + if (stopping) return; + stopping = true; + for (const child of children) stopChild(child, signal); +} + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + const exitCode = signal === 'SIGINT' ? 130 : 143; + stopAll(signal); + process.exitCode = exitCode; + setTimeout(() => process.exit(exitCode), 1_500).unref(); + }); +} + +// A first local run may backfill before Wrangler starts listening. Keep the +// standalone dev:local timeout short, but let the combined stack wait for it. +process.env.ATMO_LOCAL_API_WAIT_MS ??= String(15 * 60 * 1_000); + +const api = spawnPnpm(['--dir', 'api', 'run', 'dev']); +const apiStartupFailure = new Promise((_, reject) => { + api.once('exit', (code, signal) => { + reject(new Error(`Local API exited with ${signal ?? `code ${code}`}`)); + }); +}); + +try { + await Promise.race([selectContrail('local'), apiStartupFailure]); + + const web = spawnPnpm(['exec', 'vite', 'dev']); + const result = await Promise.race([ + new Promise((resolve) => + api.once('exit', (code, signal) => resolve({ service: 'API', code, signal })) + ), + new Promise((resolve) => + web.once('exit', (code, signal) => resolve({ service: 'web app', code, signal })) + ) + ]); + + if (!stopping && result.code !== 0) { + throw new Error(`${result.service} exited with ${result.signal ?? `code ${result.code}`}`); + } +} catch (cause) { + console.error(cause instanceof Error ? cause.message : cause); + process.exitCode = 1; +} finally { + stopAll(); +} diff --git a/scripts/ensure-api-dev-vars.mjs b/scripts/ensure-api-dev-vars.mjs new file mode 100644 index 0000000..ef77a1c --- /dev/null +++ b/scripts/ensure-api-dev-vars.mjs @@ -0,0 +1,19 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const varsPath = path.join(root, 'api/.dev.vars'); +const endpointLine = 'CONTRAIL_PUBLIC_ENDPOINT="http://127.0.0.1:8787"'; + +let contents = ''; +try { + contents = await readFile(varsPath, 'utf8'); +} catch { + // The local vars file is created on first use and remains ignored. +} + +if (!/^CONTRAIL_PUBLIC_ENDPOINT=/m.test(contents)) { + const separator = contents && !contents.endsWith('\n') ? '\n' : ''; + await writeFile(varsPath, `${contents}${separator}${endpointLine}\n`); +} diff --git a/scripts/select-contrail.mjs b/scripts/select-contrail.mjs new file mode 100644 index 0000000..31505b5 --- /dev/null +++ b/scripts/select-contrail.mjs @@ -0,0 +1,147 @@ +import { spawn } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const activeDir = path.join(root, 'src/lib/contrail-active'); +const localConsumerRoot = path.join(root, '.contrail/local-consumer'); +const localLockPath = path.join(localConsumerRoot, 'contrail.lock.json'); +const localClientPath = path.join(localConsumerRoot, 'index.ts'); +const defaultLocalEndpoint = 'http://127.0.0.1:8787'; + +function normalizeEndpoint(value) { + return value.replace(/\/$/, ''); +} + +async function writeActiveModule(content, target) { + await rm(activeDir, { recursive: true, force: true }); + await mkdir(activeDir, { recursive: true }); + await writeFile(path.join(activeDir, 'index.ts'), content); + await mkdir(path.join(root, '.contrail'), { recursive: true }); + await writeFile(path.join(root, '.contrail/active-target'), `${target}\n`); +} + +async function selectProd() { + await writeActiveModule( + `// Generated by scripts/select-contrail.mjs. Do not edit.\nexport { contrail, contrailMethods } from '../contrail-targets/prod.js';\nexport const contrailTarget = 'prod' as const;\n`, + 'prod' + ); + console.log('Contrail target: production (https://api.atmo.watch)'); +} + +function assertLoopbackEndpoint(endpoint) { + const url = new URL(endpoint); + if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)) { + throw new Error(`Local Contrail endpoint must be loopback, received ${endpoint}`); + } +} + +async function waitForLocalApi(endpoint) { + const waitMs = Number(process.env.ATMO_LOCAL_API_WAIT_MS ?? 30_000); + const deadline = Date.now() + waitMs; + const discoveryUrl = `${endpoint}/.well-known/contrail`; + + while (Date.now() < deadline) { + try { + const response = await fetch(discoveryUrl, { signal: AbortSignal.timeout(1_500) }); + if (response.ok) return; + } catch { + // The local API may still be starting. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `Local Contrail API did not become ready at ${endpoint}. Run "pnpm api:dev" first or use "pnpm dev:stack".` + ); +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with ${signal ?? `code ${code}`}`)); + }); + }); +} + +async function connectLocal(endpoint) { + let update = false; + try { + const lock = JSON.parse(await readFile(localLockPath, 'utf8')); + if (normalizeEndpoint(lock.endpoint) === endpoint) update = true; + else await rm(localConsumerRoot, { recursive: true, force: true }); + } catch { + await rm(localConsumerRoot, { recursive: true, force: true }); + } + + await mkdir(localConsumerRoot, { recursive: true }); + const contrailBin = path.join( + root, + 'node_modules/.bin', + process.platform === 'win32' ? 'contrail.cmd' : 'contrail' + ); + const args = [ + 'connect', + endpoint, + '--root', + localConsumerRoot, + '--out', + 'lexicons', + '--lock', + 'contrail.lock.json', + '--client', + 'index.ts', + '--client-types', + 'types/index.ts', + '--allow-insecure-http' + ]; + if (update) args.push('--update'); + await run(contrailBin, args); + + // The app's tracked API types represent the source tree. Reusing them avoids + // loading a second set of ambient XRPC declarations from the ignored local bundle. + const client = await readFile(localClientPath, 'utf8'); + const patchedClient = client.replace( + 'import type {} from "./types/index.js";', + 'import type {} from "../../src/lib/contrail/types/index.js";' + ); + if (patchedClient === client) { + throw new Error('Could not redirect the generated local Contrail client types'); + } + await writeFile(localClientPath, patchedClient); +} + +async function selectLocal() { + const endpoint = normalizeEndpoint(process.env.ATMO_LOCAL_API_URL ?? defaultLocalEndpoint); + assertLoopbackEndpoint(endpoint); + await waitForLocalApi(endpoint); + await connectLocal(endpoint); + + const lock = JSON.parse(await readFile(localLockPath, 'utf8')); + const protectedMethods = (lock.serviceAuth?.methods ?? []).map((method) => method.id); + const methods = [...new Set([...lock.methods, ...protectedMethods])].sort(); + await writeActiveModule( + `// Generated by scripts/select-contrail.mjs. Do not edit.\nexport { contrail } from '../../../.contrail/local-consumer/index.js';\nexport const contrailMethods = ${JSON.stringify(methods, null, '\t')} as const;\nexport const contrailTarget = 'local' as const;\n`, + 'local' + ); + console.log(`Contrail target: local (${endpoint})`); +} + +export async function selectContrail(target = process.env.ATMO_API_TARGET ?? 'prod') { + if (target === 'prod') return selectProd(); + if (target === 'local') return selectLocal(); + throw new Error(`Unknown Contrail target "${target}"; expected "prod" or "local"`); +} + +const isMain = + process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (isMain) { + selectContrail(process.argv[2]).catch((cause) => { + console.error(cause instanceof Error ? cause.message : cause); + process.exitCode = 1; + }); +} diff --git a/scripts/update-prod-contrail.mjs b/scripts/update-prod-contrail.mjs new file mode 100644 index 0000000..78a3112 --- /dev/null +++ b/scripts/update-prod-contrail.mjs @@ -0,0 +1,79 @@ +import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format } from 'prettier'; +import { selectContrail } from './select-contrail.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const endpoint = 'https://api.atmo.watch'; +const clientPath = path.join(root, 'src/lib/contrail/index.ts'); +const lockPath = path.join(root, 'contrail.lock.json'); +const prodTargetPath = path.join(root, 'src/lib/contrail-targets/prod.ts'); + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with ${signal ?? `code ${code}`}`)); + }); + }); +} + +const contrailBin = path.join( + root, + 'node_modules/.bin', + process.platform === 'win32' ? 'contrail.cmd' : 'contrail' +); + +const sourceLock = JSON.parse(await readFile(lockPath, 'utf8')); +const discoveryResponse = await fetch(`${endpoint}/.well-known/contrail`); +if (!discoveryResponse.ok) { + throw new Error(`Could not read the production Contrail manifest (${discoveryResponse.status})`); +} +const discovery = await discoveryResponse.json(); +if (discovery.contract?.digest !== sourceLock.contractDigest) { + throw new Error( + `Production still advertises ${discovery.contract?.digest ?? 'no contract digest'}, but the source contract is ${sourceLock.contractDigest}. Deploy the API first.` + ); +} + +await run(contrailBin, [ + 'connect', + endpoint, + '--root', + root, + '--out', + 'src/lib/contrail/lexicons', + '--lock', + 'contrail.lock.json', + '--client', + 'src/lib/contrail/index.ts', + '--client-types', + 'src/lib/contrail/types/index.ts', + '--update' +]); + +const lock = JSON.parse(await readFile(lockPath, 'utf8')); +const protectedMethods = (lock.serviceAuth?.methods ?? []).map((method) => method.id); +const methods = [...new Set([...lock.methods, ...protectedMethods])].sort(); +let client = await readFile(clientPath, 'utf8'); +client = client + .replace( + '// Generated by `contrail connect`. Re-run the command to update; do not edit.', + '// Generated from the deployed Contrail contract by scripts/update-prod-contrail.mjs.' + ) + .replace( + 'import type {} from "./types/index.js";', + "import type {} from '$lib/contrail/types/index.js';" + ) + .replace( + 'export const contrail = createPublicServiceClient({', + `export const contrailMethods = ${JSON.stringify(methods, null, '\t')} as const;\n\nexport const contrail = createPublicServiceClient({` + ); + +await writeFile(prodTargetPath, await format(client, { filepath: prodTargetPath })); +await selectContrail('prod'); +console.log(`Updated tracked production Contrail contract from ${endpoint}`); diff --git a/src/lib/atproto/index.ts b/src/lib/atproto/index.ts index 27b0ad5..19bdeb6 100644 --- a/src/lib/atproto/index.ts +++ b/src/lib/atproto/index.ts @@ -4,7 +4,7 @@ import { createAtprotoAuth } from '@svelte-atproto/oauth/server'; import { cloudflareKV } from '@svelte-atproto/oauth/server/stores/cloudflare'; import { building } from '$app/environment'; import { env } from '$env/dynamic/private'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; // To enable signup, add: signupPDS: 'https://your-pds.example/' export const atproto = createAtprotoAuth({ diff --git a/src/lib/contrail-targets/prod.ts b/src/lib/contrail-targets/prod.ts new file mode 100644 index 0000000..36b0620 --- /dev/null +++ b/src/lib/contrail-targets/prod.ts @@ -0,0 +1,55 @@ +// Generated from the deployed Contrail contract by scripts/update-prod-contrail.mjs. +import { createPublicServiceClient } from "@atmo-dev/contrail/client"; +import type {} from "$lib/contrail/types/index.js"; + +export const contrailMethods = [ + "watch.atmo.comment.getRecord", + "watch.atmo.comment.listRecords", + "watch.atmo.getCursor", + "watch.atmo.getProfile", + "watch.atmo.like.getRecord", + "watch.atmo.like.listRecords", + "watch.atmo.list.getRecord", + "watch.atmo.list.listRecords", + "watch.atmo.listItem.getRecord", + "watch.atmo.listItem.listRecords", + "watch.atmo.notifyOfUpdate", + "watch.atmo.review.getRecord", + "watch.atmo.review.listRecords", + "watch.atmo.review.listWrittenRecords", +] as const; + +export const contrail = createPublicServiceClient({ + endpoint: "https://api.atmo.watch", + contractDigest: + "sha256:edfc8edad45272bc6a66e56d2ea4bc61d1d7f3724304fdd492f9486c33d4032c", + serviceDid: "did:web:api.atmo.watch#contrail", + scope: + "rpc?lxm=watch.atmo.notifyOfUpdate&aud=did:web:api.atmo.watch%23contrail", + serviceMethods: [ + "watch.atmo.comment.getRecord", + "watch.atmo.comment.listRecords", + "watch.atmo.getCursor", + "watch.atmo.getProfile", + "watch.atmo.like.getRecord", + "watch.atmo.like.listRecords", + "watch.atmo.list.getRecord", + "watch.atmo.list.listRecords", + "watch.atmo.listItem.getRecord", + "watch.atmo.listItem.listRecords", + "watch.atmo.notifyOfUpdate", + "watch.atmo.review.getRecord", + "watch.atmo.review.listRecords", + "watch.atmo.review.listWrittenRecords", + ], + collections: [ + "app.bsky.actor.profile", + "social.popfeed.actor.profile", + "social.popfeed.feed.comment", + "social.popfeed.feed.like", + "social.popfeed.feed.list", + "social.popfeed.feed.listItem", + "social.popfeed.feed.review", + ], + notifyMethod: "watch.atmo.notifyOfUpdate", +}); diff --git a/src/lib/list-write.remote.ts b/src/lib/list-write.remote.ts index 3d09ce6..dc6b9b8 100644 --- a/src/lib/list-write.remote.ts +++ b/src/lib/list-write.remote.ts @@ -5,7 +5,7 @@ import type { Client } from '@atcute/client'; import { isCanonicalResourceUri, parseCanonicalResourceUri } from '@atcute/lexicons'; import type { CanonicalResourceUri, Did, Nsid } from '@atcute/lexicons'; import * as v from 'valibot'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import type { Main as ListRecord } from '$lib/contrail/types/types/social/popfeed/feed/list'; import type { Main as ListItemRecord } from '$lib/contrail/types/types/social/popfeed/feed/listItem'; import { backdropUrl } from '$lib/images'; diff --git a/src/lib/lists.server.ts b/src/lib/lists.server.ts index 9797506..0b0cd7b 100644 --- a/src/lib/lists.server.ts +++ b/src/lib/lists.server.ts @@ -1,5 +1,5 @@ import { getAtprotoCdnImageUrl } from '$lib/atproto/images'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import type * as ListRecords from '$lib/contrail/types/types/watch/atmo/list/listRecords'; import type * as ListItemRecords from '$lib/contrail/types/types/watch/atmo/listItem/listRecords'; import type { ActorSummary, MediaImage, MediaListModel, MediaSummary } from '$lib/types'; diff --git a/src/lib/review-interactions.remote.ts b/src/lib/review-interactions.remote.ts index 03ec38f..e49595b 100644 --- a/src/lib/review-interactions.remote.ts +++ b/src/lib/review-interactions.remote.ts @@ -4,7 +4,7 @@ import { isCanonicalResourceUri, parseCanonicalResourceUri } from '@atcute/lexic import type { CanonicalResourceUri, Did } from '@atcute/lexicons'; import { createTID } from '@svelte-atproto/oauth/helper'; import * as v from 'valibot'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; const REVIEW_COLLECTION = 'social.popfeed.feed.review'; const LIKE_COLLECTION = 'social.popfeed.feed.like'; diff --git a/src/lib/review-write.remote.ts b/src/lib/review-write.remote.ts index 8a5b1a6..29dcb87 100644 --- a/src/lib/review-write.remote.ts +++ b/src/lib/review-write.remote.ts @@ -5,7 +5,7 @@ import type { Client } from '@atcute/client'; import { isCanonicalResourceUri, parseCanonicalResourceUri } from '@atcute/lexicons'; import type { CanonicalResourceUri, Did } from '@atcute/lexicons'; import * as v from 'valibot'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import type { Main as ListRecord } from '$lib/contrail/types/types/social/popfeed/feed/list'; import type { Main as ListItemRecord } from '$lib/contrail/types/types/social/popfeed/feed/listItem'; import type { Main as ReviewRecord } from '$lib/contrail/types/types/social/popfeed/feed/review'; diff --git a/src/lib/reviews.server.ts b/src/lib/reviews.server.ts index 633af92..c28ad6c 100644 --- a/src/lib/reviews.server.ts +++ b/src/lib/reviews.server.ts @@ -1,6 +1,6 @@ import { isCanonicalResourceUri, parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; import { getAtprotoCdnImageUrl } from '$lib/atproto/images'; -import { contrail } from '$lib/contrail'; +import { contrail, contrailMethods } from '$lib/contrail-active'; import type * as CommentListRecords from '$lib/contrail/types/types/watch/atmo/comment/listRecords'; import type * as LikeListRecords from '$lib/contrail/types/types/watch/atmo/like/listRecords'; import type * as ReviewListRecords from '$lib/contrail/types/types/watch/atmo/review/listRecords'; @@ -18,6 +18,9 @@ type ReviewRecord = Pick< 'uri' | 'did' | 'value' | 'likesCount' | 'commentsCount' >; +const REVIEW_LIST_METHOD = 'watch.atmo.review.listRecords' as const; +const WRITTEN_REVIEW_LIST_METHOD = 'watch.atmo.review.listWrittenRecords' as const; + function getCreativeWorkType(value: string): SupportedCreativeWorkType | undefined { if (value === 'movie' || value === 'tv_show') return value; return undefined; @@ -212,7 +215,11 @@ export async function getRecentReviewsPage({ limit: number; viewerDid?: Did | null; }): Promise { - const response = await contrail.get('watch.atmo.review.listWrittenRecords', { + const supportsWrittenReviewQuery = (contrailMethods as readonly string[]).includes( + WRITTEN_REVIEW_LIST_METHOD + ); + const method = supportsWrittenReviewQuery ? WRITTEN_REVIEW_LIST_METHOD : REVIEW_LIST_METHOD; + const response = await contrail.get(method as typeof REVIEW_LIST_METHOD, { params: { cursor, limit, diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 3045a40..a37c2d3 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,6 +1,6 @@ import type { Did } from '@atcute/lexicons'; import { getAtprotoCdnImageUrl } from '$lib/atproto/images'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import type { LayoutServerLoad } from './$types'; async function getViewerAvatar(did: Did) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 72bc6db..7e3529c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/src/routes/profile/[actor]/+layout.server.ts b/src/routes/profile/[actor]/+layout.server.ts index 3e1c539..66af07b 100644 --- a/src/routes/profile/[actor]/+layout.server.ts +++ b/src/routes/profile/[actor]/+layout.server.ts @@ -1,5 +1,5 @@ import { getAtprotoCdnImageUrl } from '$lib/atproto/images'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; diff --git a/src/routes/profile/[actor]/+page.server.ts b/src/routes/profile/[actor]/+page.server.ts index 60509b8..5adf01a 100644 --- a/src/routes/profile/[actor]/+page.server.ts +++ b/src/routes/profile/[actor]/+page.server.ts @@ -1,5 +1,5 @@ import { error } from '@sveltejs/kit'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import { getProfileMediaLists } from '$lib/lists.server'; import { getViewerReviewLikes, toReview } from '$lib/reviews.server'; import type { PageServerLoad } from './$types'; diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts index bd6c2cd..dadd278 100644 --- a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts +++ b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts @@ -1,7 +1,7 @@ import { error } from '@sveltejs/kit'; import type { ResourceUri } from '@atcute/lexicons'; import { isRecordKey } from '@atcute/lexicons/syntax'; -import { contrail } from '$lib/contrail'; +import { contrail } from '$lib/contrail-active'; import { getReviewInteractions, toReview } from '$lib/reviews.server'; import type { PageServerLoad } from './$types';