Fullstack KickJS workspace — typed end to end.
| Package | What |
|---|---|
server/ |
KickJS API (decorators, DI, kick dev with typegen watch) |
web/ |
Vite + React, typed against the API via @forinda/kickjs-client |
pnpm install
pnpm dev # server (kick dev) + web (vite), in parallelServer: http://localhost:3000 · Web: http://localhost:5173 (Vite proxies /api).
To vote from phones on the same Wi-Fi during development, just use pnpm dev:
the web dev server listens on your network, and the presenter screen's join
link and QR code use your machine's LAN address even when you opened it as
localhost. Set VITE_PUBLIC_URL (e.g. a tunnel URL) to override the address
shown; deployed builds use the page's own address.
The React app is served from Vercel's CDN and the KickJS API runs as one
serverless function (server/src/serverless.ts, built by
kick build:vercel). Functions don't share memory or hold connections, so two
services do that part:
- Upstash Redis stores sessions, questions, votes and drafts.
- Ably pushes live updates to the projector and phones, and counts who has joined.
SessionInfraAdapter (server/src/adapters/session-infra.adapter.ts) picks
each implementation from env: Redis if its vars are set (else in-memory), and
Ably if its key is set (else screens poll every 2 s).
- Import the repo in Vercel. Root Directory:
./; Framework Preset: Other.vercel.jsonsets the build command (pnpm run build:vercel). - Add Upstash Redis: Project → Storage → Marketplace → Upstash Redis → connect to this project. It injects the Redis env vars.
- Add Ably: create an app at ably.com and copy an API key with publish,
subscribe and presence capabilities. Add it as
ABLY_API_KEYunder Project → Settings → Environment Variables. (Optional: without it, screens poll every 2 s and the projector shows no join count.) - Deploy (push to the connected branch, or
vercel --prod).
Check the function logs after the first request: they say which store and realtime service were picked. A warning that says "in-memory on Vercel" means Upstash isn't connected yet, and sessions will be lost or split.
Build locally: pnpm build:vercel writes .vercel/output (static files, the
api function, routes with the SPA fallback).
| Env | Needed on Vercel | Notes |
|---|---|---|
UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN |
Yes | Or KV_REST_API_URL / KV_REST_API_TOKEN, whichever names the integration injects. |
ABLY_API_KEY |
Recommended | Server-only; browsers get short-lived tokens from /api/v1/sessions/:code/realtime-token. |
The same app as one long-running Node process, which also serves the web app:
docker build -t euc-app .
docker run -p 3000:3000 euc-app # http://localhost:3000With no env it uses the in-memory store (fine for one instance; a restart
ends live sessions) and polling. Add the Upstash and Ably vars above to share
state across instances and push live updates. CLIENT_DIR (default
../web/dist, /app/web in the image) points at the built web app. Health
check: GET /api/v1/hello/health.
pnpm dev works with no accounts: in-memory store and polling. Put
ABLY_API_KEY=... in server/.env to try live updates. To test the Redis
store without Upstash, run Redis plus hiett/serverless-redis-http (an Upstash
REST emulator) in Docker; see server/src/modules/sessions/session.repository.contract.test.ts.
- Server handlers return their payloads (
return this.service.greet(...)). kick typegenemitsserver/.kickjs/types/kick__client.d.ts— the flat route map with every response type resolved to a literal shape.web/tsconfig.jsonlists that file intypes, soKickClientApiis ambient — no import, no bridge file.web/src/api.ts'screateClient<KickClientApi.Api>types every call site.
Because the map holds resolved types rather than references to controllers,
web never compiles server source: no experimentalDecorators, no path
aliases into server/src.
Rename a field in server/src/modules/hello/hello.service.ts → web/src/App.tsx
stops compiling. That's the point.
server/.kickjs/types/kick__client.d.ts is generated, and web reads it as
an ambient type package. Two things follow.
It is not refreshed by kick dev. Resolving it builds a whole TypeScript
program over the server, which is a build-step cost rather than a per-save one,
so a renamed response field surfaces on the next kick typegen rather than on
save. Everything else in .kickjs/types still updates on save. Add
kick typegen --check to CI and a stale map fails the build.
It needs a compiler API. TypeScript 7 ships none, so server depends on
@typescript/typescript6. Remove it and typegen skips this one file, saying
so; web then reports TS2688 because the file it lists in types is gone.
A types entry says "this is a global type package", which is what the map is,
and it fails loudly when the file is missing. If you would rather it be quiet
when absent — say, a repo where the map is not always generated — use
include instead:
{ "include": ["src", "../server/.kickjs/types/kick__client.d.ts"] }That tolerates the file not existing, at the cost of KickClientApi silently
not resolving. Note types replaces TypeScript's automatic @types
inclusion, so if you add entries there, list the ones you rely on too
("types": ["node", "../server/.kickjs/types/kick__client"]).
Or skip the global entirely — the same file exports the type:
import type { Api } from '../../server/.kickjs/types/kick__client'
export const api = createClient<Api>({ baseUrl: '/api/v1' })