@jeremy46231/ferry
v0.4.0
Published
Framework-agnostic library that automates the Hack Club YSWS submission flow.
Maintainers
Readme
Ferry
Framework-agnostic library that automates the Hack Club YSWS submission flow.
Mount it at /submit/* on any server-side route, feed it Requests, return the
Responses it hands back. It handles Hack Club Auth, Hackatime, Airtable, and the
Fillout hand-off.
See DESIGN.md for the architecture and decisions.
Status: the full flow works end to end — Hack Club Auth → eligibility → Airtable upsert → Hackatime (reuse-or-connect) → project sync → Fillout. Published as
@jeremy46231/ferry. SeeDESIGN.md§ Open items.
Usage
npm i @jeremy46231/ferryimport { createFerry } from '@jeremy46231/ferry'
const ferry = createFerry() // reads FERRY_* env; pass overrides to createFerry({...})
// in any server route mounted at /submit/*
const res = await ferry.handle(request)
if (res) return res
// null -> not a Ferry route -> respond 404handle(request) never throws: misconfiguration and internal errors come back as
an error Response (and a console.error), and non-Ferry paths return null.
It depends only on Web-standard APIs (fetch, crypto.subtle, …), so the same
build runs on Node 18+, Bun, Deno, Cloudflare Workers, and edge.
Configuration
Config comes from FERRY_* environment variables, overridable via
createFerry({ ... }). See .env.example for the full list;
the essentials:
| Variable | Notes |
| --------------------------------------------------- | ------------------------------------------------------------------------------- |
| FERRY_SECRET | Master secret, ≥32 chars (openssl rand -hex 32). Derives all encryption keys. |
| FERRY_BASE_URL | Public origin, for building OAuth redirect URIs. |
| FERRY_HCA_CLIENT_ID / _SECRET | Hack Club Auth OAuth app. |
| FERRY_HACKATIME_MODE | required or off. |
| FERRY_HACKATIME_CLIENT_ID / _SECRET | Hackatime OAuth app (when not off). |
| FERRY_AIRTABLE_API_KEY / FERRY_AIRTABLE_BASE_ID | Airtable token + base. |
| FERRY_FILLOUT_FORM_URL | Where submitters are sent to finish. |
| FERRY_EVENT_START_DATE | Optional YYYY-MM-DD; scopes Hackatime time to on/after this date. |
Register your OAuth callbacks at <FERRY_BASE_URL><basePath>/hca and
.../hackatime (default basePath is /submit).
Every route is one segment under basePath — /submit (or /submit/start),
/submit/hca, /submit/hackatime — and never nested. That means a host with
only single-segment dynamic routing can serve the whole flow from one file; see
sandbox/vercel.
Development
bun install
bun run test # vitest
bun run typecheck # tsc (library + tests)
bun run build # tsdown -> dist/ (ESM + CJS + types)
bun run format # biome (write)
bun run check # biome lint + format + import sorting (check only)
bun run check:fix # biome, with autofixes appliedIntegration sandboxes live in sandbox/ — one per host (vercel, vite,
sveltekit, nextjs, workers), each serving Ferry on port 5173. Put your dev
creds in a repo-root .env (copy .env.example), then:
bun run sandbox:setup # symlink .env into each sandbox and install
bun run sandbox:vercel # run one (also :vite, :sveltekit, :nextjs, :workers)sandbox/vercel is the reference setup for a typical YSWS site: a static
Vite build with Ferry as a Vercel Function.
sandbox:setup runs each sandbox's own setup script, which symlinks the
root .env to that host's env file (.env / .env.local / .dev.vars) — so
edits to the root .env are picked up on the next server start, no re-setup
needed — and installs. The sandboxes also import Ferry's src/ directly, so
library edits are picked up live too — no build or publish step. See each
sandbox's README.md.
Wiring Ferry into a host is a line or two:
| Host | Integration |
| ------------------------------- | ---------------------------------------------------------------------- |
| Vercel Function (api/…) | (await ferry.handle(request)) ?? new Response(null, { status: 404 }) |
| Node (Vite/Express/Connect) | server.middlewares.use(ferry.middleware()) |
| SvelteKit (hooks.server.ts) | (await ferry.handle(event.request)) ?? resolve(event) |
| Next.js (catch-all route) | (await ferry.handle(request)) ?? new Response(null, { status: 404 }) |
| Cloudflare Workers (fetch) | (await ferry.handle(request)) ?? env.ASSETS.fetch(request) |
The mount path is configurable: basePath drives both
inbound matching and the OAuth redirect URIs Ferry builds, so set it to wherever
the host mounts you (e.g. /api/submit on Vercel) and register the callbacks to
match.
Web-native hosts call handle(request) directly; Node servers use the
middleware() adapter. Runtimes without process.env (Workers) pass the env
bag: createFerry({ env }).
