Developing Arclight
Read CONVENTIONS.md first; this page is the how-to. Commands are in the
README (pnpm dev, build, test, typecheck, db:generate).
Add an api module
Section titled “Add an api module”-
Create
apps/api/src/modules/<name>/withschema.ts,service.ts,routes.ts, optionaljobs.ts,index.tsand colocated*.test.ts. -
The module type is
AppModulefromapps/api/src/app.ts:(ctx: AppContext) => FastifyPluginAsync.AppContextcarriesconfig,db,bus(event bus),scheduler,logandstartedAt. Routes are registered relative to/api/v1. A typical module:export const exampleModule: AppModule = (ctx) => async (app) => {const service = new ExampleService(ctx.db, ctx.bus);await app.register(exampleRoutes(service));registerExampleJobs(ctx.scheduler, service);};Use the Zod schemas from
@arclight/sharedin the routeschema(body,params,querystring,response); the Zod type provider validates and serializes. Keep routes thin and map service errors to HTTP in one place. -
Jobs:
scheduler.register({ name, cron, handler }). The handler may return a message string stored on the job run. Names are PascalCase (RssSync). -
Add the module to the
modulesarray inapps/api/src/app.ts. -
Tables: define them in
<name>/schema.ts, then addexport * from '../modules/<name>/schema.js';toapps/api/src/db/schema.ts(the aggregate). Other modules import tables from that aggregate, never from another module’sschema.ts. -
Migration: run
pnpm db:generate(drizzle-kit; output inapps/api/drizzle/) and commit the generated.sqlandmeta/files. Do not hand-edit generated SQL. -
Add the routes and jobs to API.md.
Testing a module
Section titled “Testing a module”createTestDb(extraTables?) in apps/api/src/db/test-utils.ts returns an in-memory
SQLite Db built from the table definitions (not from the shipped migrations), so you can
test before running db:generate. Pass your module’s tables as extraTables if they are
not yet in the aggregate. For route tests, build a Fastify instance with the Zod
compilers and register your module with a context made from the test db; see
modules/download-clients/routes.test.ts and test-support.ts. Fake external services
(TMDB, qBittorrent, indexers) with an injected fetch; never call live services.
Add a shared contract
Section titled “Add a shared contract”- Put the Zod schema and its inferred type in
packages/shared/src/<area>.ts:export const Thing = z.object({...}); export type Thing = z.infer<typeof Thing>; - Export the file from
packages/shared/src/index.ts(export * from './<area>.js';). - Use it in the api route schema and in the web client (
apps/web/src/api/), which parses responses with the same schema. Never define an API shape anywhere else. - A new WebSocket event goes into the
ServerEventunion inevents.ts; the web’slive.tshandles it. - Dates are ISO-8601 strings, durations are ms, sizes are bytes.
The shared package has no tests of its own; contracts are exercised through the api and web tests.
Add a package
Section titled “Add a package”packages/<name>/package.json:"name": "@arclight/<name>","private": true,"type": "module","exports": { ".": "./src/index.ts" }, scriptstypecheck(tsc --noEmit) andtest(vitest run). Copypackages/organizeras a template, including itstsconfig.json.- Depend on
@arclight/sharedwith"workspace:*"only if you use contract types. src/index.tsis the public API; keep everything else internal. No database or HTTP server code in packages.- In the consumer (
apps/apior another package) add"@arclight/<name>": "workspace:*"and runpnpm install. - The image ships the api as one esbuild bundle, so a workspace package is included automatically. If the package reads files at runtime (like the indexer definitions or the migrations) or depends on a native addon, see “Bundled build” below.
- Any new third-party dependency needs a reason in the commit message.
Bundled build (Docker image)
Section titled “Bundled build (Docker image)”pnpm --filter @arclight/api build runs apps/api/scripts/bundle.mjs, which uses esbuild
to inline the api, every @arclight/* package (TypeScript sources) and the third-party
JavaScript into apps/api/dist/server.mjs (ESM, node24). Next to it, it writes:
drizzle/(migrations) anddefinitions/builtin/(indexer YAML), copied from the sources;package.jsonlisting the externals the image installs (EXTERNALSin the script, today onlybetter-sqlite3, a native addon);bundle-meta.json, what the bundle still imports at runtime.
A banner provides require/__dirname for inlined CommonJS, defaults NODE_ENV to
production (so the logger never asks for the pino-pretty worker) and sets
ARCLIGHT_ASSET_ROOT to the bundle’s folder. Code that reads a file shipped with the sources
must resolve it from ARCLIGHT_ASSET_ROOT when set and from import.meta.url otherwise (see
migrationsFolder() in apps/api/src/db/index.ts and BUILTIN_DEFINITIONS_DIR in
packages/indexers), and the script must copy it. A new native or dynamic-require-heavy
dependency goes into EXTERNALS. src/bundle.test.ts builds the bundle and checks it
statically (no paths into src/, only the expected externals, assets present).
node apps/api/dist/server.mjs runs the bundle locally. pnpm dev and
scripts/local-test.ps1 keep running the TypeScript sources through tsx.
The Dockerfile builds web + bundle, installs the externals with npm in a separate stage
and prunes them to the addon, then copies the node binary, bundle, addon and web dist onto
debian:bookworm-slim (npm, corepack and yarn are left behind). Result: about 80 MB
compressed / 220 MB unpacked versus 103 MB / 365 MB for the old tsx image, and first HTTP
response about 1.7 s after docker run versus about 39 s (tsx compiling and chown -R over
a 100 MB node_modules).
Fixture-driven parser tests
Section titled “Fixture-driven parser tests”packages/parser/fixtures/<area>/*.json hold cases as data ({ "cases": [ ... ] }),
grouped by area: group, language, movies, path, quality, scene, series.
src/fixture-check.ts (checkCase) compares the parser output with a case’s
expectations. Fixtures are test inputs and expected values only; never copy upstream code
or regexes.
pnpm --filter @arclight/parser testruns the Vitest suites (group,language,quality).pnpm --filter @arclight/parser reportruns every fixture file and prints the pass rate per file with the first six failures.-- <substring>filters by path,-- --alllists every failure, and the last line is the total. Use it as the loop when improving the parser: the total should rise without any file regressing.- To add cases, append to the matching JSON file, or add a new
.jsonfile under the right folder (the report walks the folder recursively), then rerun the report.
Website
Section titled “Website”The public website (https://arclight.moviemethat.com) lives in site/: Astro + Starlight, its own
pnpm project outside the workspace. It publishes docs/ as-is, so edit the Markdown here, not in
site/. Run pnpm install && pnpm dev in site/; pnpm build fails on a broken link between
docs. Details: site/README.md.
Commit conventions
Section titled “Commit conventions”- Imperative subject, 72 characters or fewer; the body explains why. Recent subjects look like “Phase 1 wave 2: library, profiles, indexers, download clients, web shell”.
- Body bullets list what changed per area, with test counts where useful.
- Give a reason for any new dependency and name the package it is for.
- End with the Co-Authored-By line the session provides.
- Run
pnpm typecheckandpnpm testbefore committing; keep generated migrations in the same commit as the schema change.