- name
- kickjs-skill
- description
- Use whenever working in or with KickJS — the decorator-driven Node.js framework on Express 5 + TypeScript. Triggers on `@forinda/kickjs*` imports, `kick.config.ts`, `kick new`/`kick g`/`kick add` commands, decorators like `@Controller` / `@Service` / `@Module`, mentions of "KickJS" or "kickjs", or files matching `*.module.ts` / `*.controller.ts` patterns. Covers two modes — adopter (writing a user app on KickJS) and contributor (working in the kickjs monorepo itself) — and auto-detects which applies. Use even when the user does not explicitly name the framework, as long as the project shape clearly matches.
# KickJS Framework Skill
KickJS has strong conventions whose violation causes silent breakage — broken HMR, env values silently undefined, decorators firing at the wrong time, plugins dropped from the DI container, lockfile drift between packages. The framework's value is the conventions; the skill's job is to keep you aligned with them.
This skill operates in **two modes** and the right one depends on which repository you're in. Detect mode first, load the matching reference file, then apply the shared invariants below.
## Step 1 — Detect mode
Run these checks in order. The first match wins.
```
contributor → both pnpm-workspace.yaml AND packages/kickjs/ exist at repo root
adopter → kick.config.ts exists, OR package.json depends on @forinda/kickjs*
neither → don't trigger; this isn't a KickJS context, fall back to default behaviour
```
The reason contributor wins when both apply: if you're editing the framework itself, adopter conventions about "use the published `@forinda/kickjs` API" are misleading — you ARE the published API.
## Step 2 — Load the matching reference
- **Contributor mode** → read [`references/contributor.md`](references/contributor.md). Covers monorepo layout, Turbo + Vite + tsc build pipeline, pnpm + workspace deps, package add/remove flow, lockstep release scripts, BYO recipe pattern, the "only write to `@forinda/kickjs`" rule.
- **Adopter mode** → read [`references/adopter.md`](references/adopter.md). Covers `defineAdapter`/`definePlugin`, decorator usage, env wiring, module file naming for HMR, contributors vs middleware, tests via `Container.create()`, the named `app` export, BYO swaps for the 6 v5-removed wrappers.
Both modes also consult the shared invariants in Step 3 below — those apply everywhere.
## Step 3 — Shared invariants (both modes)
These are universal. Violating any of them produces silent breakage that the type system will not catch.
### `defineAdapter` / `definePlugin` factories — never class-based
Adapters and plugins are factories, not classes:
```ts
// Right
export const myAdapter = defineAdapter({
name: 'my-adapter',
beforeStart: ({ container }) => { /* … */ },
shutdown: () => { /* … */ },
})
// Wrong — class-based adapter is a v3 pattern, dropped in v4
class MyAdapter implements AppAdapter { /* … */ }
```
Why: factory shape lets the framework introspect every hook ahead of time (devtools, typegen, lifecycle ordering), narrows the dependency graph for shutdown phases, and keeps the v5 BYO recipes — and the user's adapters — using the same primitives the framework uses internally.
### `@Controller()` takes no path argument
```ts
@Controller() // Right — mount prefix comes from routes().path
@Controller('/users') // Wrong — path arg removed in v4
```
The mount prefix comes from `routes({ path: '/users' })` in the module, not the decorator. Mixing both produces double prefixes that look correct in tests and 404 in production.
### DI tokens use slash-delimited strings, not Symbols
```ts
// Right — adopter scope
export const USER_REPO = createToken<UserRepo>('app/users/repository')
// Right — first-party uses reserved 'kick/' prefix
export const PRISMA_CLIENT = createToken<PrismaClient>('kick/prisma/Client')
// Wrong — Symbol() doesn't survive serialization, devtools, or worker boundaries
export const USER_REPO = Symbol('UserRepo')
```
Adopter projects must NEVER use the `kick/` prefix — it's reserved for the framework. Use your own scope (typically the project name or `app/`). The contributor reference covers what scope to use for new first-party packages.
### `Container.create()` for test isolation
```ts
// Right — every test gets a fresh, isolated container
beforeEach(() => {
container = Container.create()
container.register(/* … */)
})
// Wrong — shared singleton bleeds state between tests; reset() is incomplete
beforeEach(() => {
Container.getInstance().reset()
})
```
Decorators fire at class-definition time and write to the global container. Tests that share `Container.getInstance()` race each other; tests that `reset()` lose decorator-registered metadata. `Container.create()` gives each test its own isolated container without losing the framework's setup.
### `getRequestValue<K>(key)` for service-level reads — never expose `setRequestValue`
```ts
// Right — services read context values via the typed helper
import { getRequestValue } from '@forinda/kickjs'
const tenant = getRequestValue('tenant')
// Wrong — internal store API, not part of the public surface
const store = requestStore.getStore()
const tenant = store?.values.get('tenant')
```
Writes flow either through `ctx.set('key', value)` inside a handler or as the return value of a `defineContextDecorator({ resolve })`. There is intentionally NO `setRequestValue` export — letting services mutate the per-request map produces ordering bugs that are expensive to debug. If you need a value to be available, contribute it via a context decorator instead.
### Context Contributors over `@Middleware()` for ctx-population
If the only job of a piece of middleware is to compute a value other code reads off `ctx`, write it as a `defineContextDecorator` (or `defineHttpContextDecorator` when HTTP-specific), not a `@Middleware()`:
```ts
// Right — typed pipeline with deps + dependsOn ordering
const LoadTenant = defineHttpContextDecorator({
key: 'tenant',
deps: { repo: TENANT_REPO },
resolve: (ctx, { repo }) => repo.findById(ctx.req.headers['x-tenant-id'] as string),
})
@LoadTenant
@Get('/me')
me(ctx: RequestContext) { ctx.json(ctx.get('tenant')) }
```
Middleware still wins for short-circuiting responses, response-stream mutation, and pre-route-matching work. The split is documented in `docs/guide/context-decorators.md`.
### BYO for the 6 removed wrappers (v5+)
`@forinda/kickjs-graphql`, `-otel`, `-cron`, `-mailer`, `-multi-tenant`, `-notifications` were removed in v5. Do NOT add them as dependencies in adopter projects, do NOT reference them as published packages in contributor docs/code. Use the BYO recipes at `docs/guide/{cron,mailer,multi-tenancy,notifications,otel,graphql}.md` — each shows how to wire the upstream library through `defineAdapter`/`definePlugin` directly. The `kick add cron` (etc.) command is wired to surface the BYO guide URL instead of erroring.
### Adapters and plugins are FACTORY CALLS, not references
```ts
// Right — call the factory; each instance owns its own state (Redis client, etc.)
bootstrap({ adapters: [redisAdapter({ url: env.REDIS_URL })], plugins: [authPlugin()] })
// Wrong — passing the factory itself; the framework will not invoke it for you
bootstrap({ adapters: [redisAdapter], plugins: [authPlugin] })
```
The closure-over-config pattern is *why* `defineAdapter`/`definePlugin` exist — every adapter instance owns isolated state, and that's how shutdown hooks know which Redis client to close.
### Per-request middleware/handler execution order
Within a single request, framework order is:
```
validation (Zod from route decorators)
→ file upload (@FileUpload)
→ class-level @Middleware()
→ method-level @Middleware()
→ context contributors (sorted by dependsOn topo order)
→ handler
```
Validation always runs first — you cannot put auth `@Middleware()` "before" Zod validation by reordering decorators. If you need work to happen pre-validation, use `bootstrap({ middleware: [...] })` (global) or an adapter's `beforeRoutes` phase.
### `RequestContext` — 3 instances per request, one shared bag
Each layer (middleware → contributors → handler) sees a *different* `RequestContext` JS object. They all read/write the same AsyncLocalStorage-backed bag, but object identity differs. Two consequences:
- `ctx.foo = bar` (direct property assignment) does NOT survive across layers. The next layer's ctx is a fresh object. Always `ctx.set('foo', bar)` / `ctx.get('foo')`.
- Services reaching outside a handler use `getRequestValue('foo')` (the typed ALS reader). Reading `ctx.foo` from a service is impossible by design — services don't see `ctx`.
### DI scope rules (silent breakage if violated)
- `Container.create()` for tests as established. For runtime: scope-rule reminder.
- `@Autowired()` on properties is **lazy-resolved** (first access). `@Inject(token)` in constructors is **eager** (DI bootstrap). Cycle detection only catches eager cycles; a lazy cycle errors at first access in production.
- A singleton service cannot inject a REQUEST-scoped service. The container detects this when the singleton tries to resolve it and throws — but only at that resolve point, not at startup. Design the graph to avoid the shape (move to TRANSIENT, or pass the value explicitly).
- `createToken<T>(name)` returns a unique frozen object **by reference**. Two files calling `createToken<X>('foo')` produce two different tokens. Always `export const X = createToken<...>('x')` and import the same const everywhere.
- Interface-based bindings need manual `module.register(container)` — `@Service` / `@Repository` auto-register only the concrete class.
### `dependsOn` typos fail at boot, not at request time
`defineHttpContextDecorator({ dependsOn: ['tenent'] })` (typo) throws `MissingContributorError` at `bootstrap()`. This is intentional — bad pipelines should fail fast. Don't try to silence the error; fix the spelling.
## Step 4 — Apply mode-specific guidance
After loading the matching reference and the invariants above, apply guidance in this order when responding:
1. **Hard rules from invariants** — non-negotiable; if you're about to write code that violates one, stop and reconsider.
2. **Mode-specific patterns** from the reference file — these are conventions; deviate only when the user has clearly chosen a different path and understands the trade-off.
3. **Project-local CLAUDE.md / AGENTS.md** — every adopter and contributor project has one. Treat it as authoritative; it overrides the skill when it disagrees on substantive points (and you should mention the disagreement to the user).
## When NOT to apply this skill
- **No KickJS signals at all** — file contents are pure Express / Hono / Fastify, no `@forinda/*` imports, no `kick.config.ts`. Don't inject KickJS conventions into a non-KickJS project.
- **Pure docs / blog edits** — adjust style to the repo's tone; framework conventions don't apply to prose.
- **The user explicitly opts out** — if they say "I know this isn't idiomatic but I want X anyway", do X and skip the lecture.
## Communication style
KickJS users are technical; they're picking a decorator framework on purpose and have likely used Nest. Don't over-explain decorators or DI. Do explain the *why* behind any KickJS-specific divergence — most rules exist because some adopter hit silent breakage and we wrote the rule down.
View on GitHub