From 8de8602b056adce76b69c8f53b899df44bbc534c Mon Sep 17 00:00:00 2001 From: axiosleo Date: Tue, 11 Aug 2026 13:38:30 +0800 Subject: [PATCH 1/4] feat: add TypeScript support for koapp, including typed contexts, router generics, and file upload examples --- assets/skills/koapp-apps/SKILL.md | 1 + assets/skills/koapp-apps/http-server.md | 28 ++ assets/skills/koapp-router/SKILL.md | 1 + assets/skills/koapp-typescript/SKILL.md | 229 +++++++++++++ assets/skills/koapp-typescript/examples.md | 358 +++++++++++++++++++++ assets/skills/koapp/SKILL.md | 1 + 6 files changed, 618 insertions(+) create mode 100644 assets/skills/koapp-typescript/SKILL.md create mode 100644 assets/skills/koapp-typescript/examples.md diff --git a/assets/skills/koapp-apps/SKILL.md b/assets/skills/koapp-apps/SKILL.md index fa563f7..f920cfc 100644 --- a/assets/skills/koapp-apps/SKILL.md +++ b/assets/skills/koapp-apps/SKILL.md @@ -123,3 +123,4 @@ If you just need to build one server, start with the matching doc: - Building a TCP service → [socket-server.md](socket-server.md) - Building a WebSocket service → [websocket-server.md](websocket-server.md) - Copy-paste-ready examples → [examples.md](examples.md) +- Typing contexts and configs in TypeScript → **koapp-typescript** diff --git a/assets/skills/koapp-apps/http-server.md b/assets/skills/koapp-apps/http-server.md index 5b96362..d63140e 100644 --- a/assets/skills/koapp-apps/http-server.md +++ b/assets/skills/koapp-apps/http-server.md @@ -122,6 +122,34 @@ router.post('/upload', async (context) => { Install with `npm install @koa/multer` (and `@types/koa__multer` for TS). +In TypeScript, uploaded files are **not** covered by the body generic - +they live on `context.koa.request.files`. Intersect a typed `koa` onto the +context so the multer types apply: + +```typescript +import multer from '@koa/multer'; +import type { ParameterizedContext } from 'koa'; +import { ContextFromSpec, success, failed } from '@axiosleo/koapp'; + +type UploadContext = ContextFromSpec<{ + params: { dir: string }; +}> & { koa: ParameterizedContext }; + +router.post('/upload/{:dir}', async (context) => { + const upload = multer({ storage: multer.memoryStorage() }); + await upload.any()(context.koa, async () => {}); + const files = context.koa.request.files; + const first = Array.isArray(files) ? files[0] : undefined; + if (!first) { + failed({}, '400;Bad Data', 400); + } + success({ name: first.originalname, size: first.size }); +}); +``` + +See **koapp-typescript** for the full typed-upload recipes (single file, +multiple files, echo-as-download). + ## Sessions When `session` config is present, the framework signs the cookie with diff --git a/assets/skills/koapp-router/SKILL.md b/assets/skills/koapp-router/SKILL.md index c16a3d2..7e0817a 100644 --- a/assets/skills/koapp-router/SKILL.md +++ b/assets/skills/koapp-router/SKILL.md @@ -208,3 +208,4 @@ For a request `PUT /users/42`: - Richer, copy-paste examples: [examples.md](examples.md) - Building a complete HTTP server around the router: **koapp-apps** - Sending responses from handlers: **koapp-response** +- Typed contexts and Router generics in TypeScript: **koapp-typescript** diff --git a/assets/skills/koapp-typescript/SKILL.md b/assets/skills/koapp-typescript/SKILL.md new file mode 100644 index 0000000..35d5ba2 --- /dev/null +++ b/assets/skills/koapp-typescript/SKILL.md @@ -0,0 +1,229 @@ +--- +name: koapp-typescript +description: Write type-safe @axiosleo/koapp code in TypeScript - typed contexts (ContextFromSpec, RequiredContext, SocketContext, WebSocketContext), Router generics, reusable typed middlewares, typed file uploads with @koa/multer, response helper generics, and typed application configs. Use when a koapp project uses TypeScript, when typing context.params/body/query, annotating route handlers or controllers, typing uploaded files, or importing types from @axiosleo/koapp. +--- + +# @axiosleo/koapp in TypeScript + +Source of truth: [`index.d.ts`](../../../index.d.ts) at the package root. + +The framework ships a full generic type system. This skill covers what is +importable, how to type contexts and handlers, and the recipes for cases the +generics do not cover directly (file uploads, shared middlewares). + +## Setup + +```bash +npm install -D typescript @types/koa @types/node +# for file uploads: +npm install @koa/multer && npm install -D @types/koa__multer +``` + +`@types/koa` must be a direct devDependency: `index.d.ts` and +`@types/koa__multer` both resolve `koa` types, and they must land on the +same installed copy for `context.koa` typing and the multer module +augmentation to line up. + +## What is (and is not) importable + +Importable from `@axiosleo/koapp`: + +| Category | Symbols | +| --- | --- | +| Classes | `KoaApplication`, `SocketApplication`, `WebSocketApplication`, `Application`, `Router`, `Controller`, `Model`, `HttpResponse`, `HttpError`, `SocketClient` | +| Context types | `ContextFromSpec`, `RequiredContext`, `SocketContext`, `WebSocketContext` | +| Config types | `KoaApplicationConfig`, `TypedKoaApplicationConfig`, `SocketAppConfiguration`, `TypedSocketAppConfiguration`, `WebSocketAppConfiguration`, `TypedWebSocketAppConfiguration`, `PingConfig`, `HttpResponseConfig` | +| Functions | `success`, `failed`, `result`, `response`, `error`, `initContext`, `middlewares.KoaSSEMiddleware` | + +**Not exported** (internal to `index.d.ts`): `KoaContext`, `AppContext`, +`ContextHandler`, `RouterOptions`, `RouterInfo`, `StatusCode`, +`AppConfiguration`. You cannot `import { KoaContext }` - use the recipes +below instead. Handlers on a plain `new Router()` still infer the full +`KoaContext` shape automatically, so this mostly matters when you want to +*name* a type. + +## Typing contexts: pick the right tool + +| Approach | Data typing | `context.koa` | When | +| --- | --- | --- | --- | +| Default inference (`new Router()`) | loose (`params?`, `body: any`) | fully typed (incl. `sse`) | quick handlers, SSE, redirects | +| `ContextFromSpec<{...}>` | strict, required, no `?.` | `any` (via index signature) | most HTTP data handlers - **preferred** | +| `RequiredContext` | strict, positional generics | `any` | same, if you prefer positional style | +| `ContextFromSpec<...> & { koa: ParameterizedContext }` | strict | fully typed | uploads, sessions, streaming with typed data | +| `SocketContext` / `WebSocketContext` | strict via generics | n/a (`socket` instead) | TCP / WebSocket handlers | + +`ContextFromSpec` takes an object spec - order-free, declare only what you +need: + +```typescript +import { Router, ContextFromSpec, success } from '@axiosleo/koapp'; + +type CreateUserContext = ContextFromSpec<{ + params: { id: string }; + body: { name: string; email: string; age?: number }; + query: { format?: 'json' | 'xml' }; +}>; + +const router = new Router('/api'); + +router.post('/users/{:id}', async (context) => { + const id = context.params.id; // string - required, no ?. + const name = context.body.name; // string + const format = context.query.format; // 'json' | 'xml' | undefined + success({ id, name, format }); +}); +``` + +`RequiredContext` is the positional equivalent; `P` and `Q` must +extend `Record` (optional string-literal props are fine). + +## Three levels of Router generics + +```typescript +// 1. Router-level: every handler on this router gets the type +const productRouter = new Router('/products'); + +// 2. Per-route override: any route can use its own context type +router.get('/profile/{:id}', async (context) => { /* ... */ }); + +// 3. Sub-router with a different context via router.new() +const admin = router.new('/admin', { + middlewares: [async (context) => { /* typed as AdminContext */ }], +}); +``` + +Validators attach per-route as the third argument, same as JavaScript: + +```typescript +router.post('/users/{:id}', handler, { + params: { rules: { id: 'required|integer' } }, + body: { rules: { name: 'required|string', email: 'required|email' } }, +}); +``` + +## Naming the full KoaContext (shared middlewares) + +`ContextHandler` and `KoaContext` are not exported, but the context type can +be recovered from `Router`'s default generic: + +```typescript +import { Router } from '@axiosleo/koapp'; + +/** The framework's KoaContext - koa, url, sse and all */ +type KoaCtx = Router extends Router ? C : never; + +const authMiddleware = async (context: KoaCtx): Promise => { + if (!context.koa.session?.user) { + // throw a response, see koapp-response + } +}; + +const secured = new Router('/secure', { middlewares: [authMiddleware] }); +``` + +Do **not** try `Omit & { body: B }` to retype the data +properties - the base context carries a `[key: string]: any` index +signature that breaks `Omit`. Use `ContextFromSpec` for typed data instead. + +## Typed file uploads + +Uploaded files live on `context.koa.request.files`, **not** on +`context.body` - the `TBody` generic never covers them. Intersect a typed +`koa` onto the spec so the multer augmentation applies: + +```typescript +import multer from '@koa/multer'; +import type { ParameterizedContext } from 'koa'; +import { Router, ContextFromSpec, success, failed } from '@axiosleo/koapp'; + +type UploadContext = ContextFromSpec<{ + params: { dir: string }; +}> & { koa: ParameterizedContext }; + +router.post('/upload/{:dir}', async (context) => { + const upload = multer({ storage: multer.memoryStorage() }); + await upload.any()(context.koa, async () => {}); + + // files: { [field: string]: File[] } | File[] | undefined - narrow it + const files = context.koa.request.files; + const first = Array.isArray(files) ? files[0] : undefined; + if (!first) { + failed({}, '400;Bad Data', 400); + } + success({ name: first.originalname, size: first.size }); +}); +``` + +With `upload.single('avatar')` the file is at `context.koa.request.file` +(type `multer.File`). See [examples.md](examples.md) for the full recipes. + +## Response helpers: generics and never + +All helpers are generic and return `never` - TypeScript knows execution +stops there, so calls double as type guards: + +```typescript +const user = await findUser(id); // User | null +if (!user) { + failed({ id }, '404;Not Found', 404); +} +user.name; // user narrowed to User - no ! needed +success(user); +``` + +The status-code parameter accepts any `";"` string; presets +like `'200;Success'`, `'404;Not Found'`, `'409;Data Already Exists'` are +listed in **koapp-response**. + +## Typed application configs + +```typescript +import { + KoaApplication, Router, + type KoaApplicationConfig, type TypedKoaApplicationConfig, +} from '@axiosleo/koapp'; + +const config: KoaApplicationConfig = { + listen_host: '0.0.0.0', + port: 8080, + routers: [router], // mixed Router<...> types allowed +}; +new KoaApplication(config).start(); + +// Strict variant: all routers must match the given type +type UserRouter = Router; +const strict: TypedKoaApplicationConfig = { + listen_host: 'localhost', + port: 8081, + routers: [userRouter1, userRouter2], +}; +``` + +`SocketAppConfiguration` adds `port` + `ping`; `WebSocketAppConfiguration` +additionally accepts every `ws` `ServerOptions` field (`maxPayload`, +`clientTracking`, ...). + +## Common pitfalls + +- `import { KoaContext }` fails - the type is not exported. Use default + inference, `ContextFromSpec`, or the `infer` recipe above. +- The base context has `[key: string]: any`, so typos like `context.bodyy` + compile silently as `any`. Prefer `ContextFromSpec` so real fields are + strictly typed. +- Intersecting onto the inferred `KoaCtx` (`KoaCtx & { body: B }`) does not + retype `body` - `any & B` collapses to `any`. +- Uploaded files are never in `TBody`; type them through `context.koa` + (see above). +- Two copies of `@types/koa` in the dependency tree make + `context.koa.request.files` "not exist" - install `@types/koa` directly + so everything resolves to one copy. +- `initContext` (custom transports) is exported and generic; it is an + advanced escape hatch and rarely needed - see `index.d.ts` if you build + your own transport. + +## See also + +- Copy-paste TypeScript recipes: [examples.md](examples.md) +- Route/validator basics (JavaScript): **koapp-router** +- Building and configuring servers: **koapp-apps** +- Response helpers and status codes: **koapp-response** diff --git a/assets/skills/koapp-typescript/examples.md b/assets/skills/koapp-typescript/examples.md new file mode 100644 index 0000000..4a2b6a0 --- /dev/null +++ b/assets/skills/koapp-typescript/examples.md @@ -0,0 +1,358 @@ +# TypeScript Examples + +Copy-paste-ready recipes. All snippets compile under `strict` mode with +`esModuleInterop` enabled and `@types/koa` installed (see SKILL.md Setup). + +## Typed CRUD router + +```typescript +import { Router, ContextFromSpec, success, failed } from '@axiosleo/koapp'; + +interface UserRow { + id: number; + name: string; + email: string; +} + +type ListContext = ContextFromSpec<{ + query: { page?: string; sort?: 'asc' | 'desc' }; +}>; + +type CreateContext = ContextFromSpec<{ + body: { name: string; email: string; age?: number }; +}>; + +type ItemContext = ContextFromSpec<{ + params: { id: string }; +}>; + +type UpdateContext = ContextFromSpec<{ + params: { id: string }; + body: { name?: string; email?: string }; +}>; + +const users = new Router('/users'); + +users.get('/', async (context) => { + const page = Number(context.query.page ?? 1); + const sort = context.query.sort ?? 'asc'; + success({ page, sort, items: [] as UserRow[] }); +}); + +users.post('/', async (context) => { + const row: UserRow = { id: 1, ...context.body }; + success(row); +}, { + body: { + rules: { name: 'required|string', email: 'required|email' }, + messages: { required: 'The :attribute field is required.' }, + }, +}); + +users.get('/{:id}', async (context) => { + const id = Number(context.params.id); + if (Number.isNaN(id)) { + failed({ id: context.params.id }, '400;Bad Data', 400); + } + success({ id }); +}, { + params: { rules: { id: 'required|integer' } }, +}); + +users.put('/{:id}', async (context) => { + success({ id: context.params.id, changes: context.body }); +}); + +users.delete('/{:id}', async (context) => { + success({ deleted: context.params.id }); +}); +``` + +## File uploads + +Files never appear in `TBody`; they live on `context.koa.request.files` +(`@koa/multer` + `@types/koa__multer`). Intersect a typed `koa` onto the +spec: + +```typescript +import multer from '@koa/multer'; +import type { ParameterizedContext } from 'koa'; +import { Router, ContextFromSpec, success, failed } from '@axiosleo/koapp'; + +const router = new Router('/files'); + +// Multiple files: upload.any() -> request.files needs narrowing +type UploadContext = ContextFromSpec<{ + params: { dir: string }; + query: { overwrite?: string }; +}> & { koa: ParameterizedContext }; + +router.post('/upload/{:dir}', async (context) => { + const upload = multer({ storage: multer.memoryStorage() }); + await upload.any()(context.koa, async () => {}); + + // { [field: string]: File[] } | File[] | undefined + const files = context.koa.request.files; + const list = Array.isArray(files) ? files : []; + if (list.length === 0) { + failed({}, '400;Bad Data', 400); + } + success({ + dir: context.params.dir, + uploaded: list.map((f) => ({ + name: f.originalname, + mime: f.mimetype, + size: f.size, + })), + }); +}); + +// Single file: upload.single(field) -> request.file (multer.File) +type AvatarContext = ContextFromSpec<{ + params: { userId: string }; +}> & { koa: ParameterizedContext }; + +router.post('/avatar/{:userId}', async (context) => { + const upload = multer({ storage: multer.memoryStorage() }); + await upload.single('avatar')(context.koa, async () => {}); + + const file: multer.File = context.koa.request.file; + const buffer: Buffer = file.buffer; // memoryStorage keeps it in RAM + success({ userId: context.params.userId, size: buffer.length }); +}); + +// Echo a file back as a download +router.post('/echo/{:userId}', async (context) => { + const upload = multer({ storage: multer.memoryStorage() }); + await upload.single('file')(context.koa, async () => {}); + const file = context.koa.request.file; + context.koa.set('content-type', file.mimetype); + context.koa.body = file.buffer; + context.koa.attachment(file.originalname); +}); +``` + +## Reusable typed middleware + +`ContextHandler` / `KoaContext` are not exported; recover the context type +from `Router` once and share it: + +```typescript +import { Router, error } from '@axiosleo/koapp'; + +type KoaCtx = Router extends Router ? C : never; + +const requestLogger = async (context: KoaCtx): Promise => { + console.log(`[${context.method}] ${context.pathinfo}`); +}; + +const requireAuth = async (context: KoaCtx): Promise => { + const token = context.headers?.authorization; + if (!token) { + error(401, 'Unauthorized'); + } +}; + +const api = new Router('/api', { + middlewares: [requestLogger], + afters: [ + async (context) => { + console.log('responded:', context.response?.status); + }, + ], +}); + +const secured = api.new('/admin', { middlewares: [requireAuth] }); +``` + +## Controllers in TypeScript + +```typescript +import { Controller, Router, ContextFromSpec } from '@axiosleo/koapp'; + +type FindContext = ContextFromSpec<{ params: { id: string } }>; +type CreateContext = ContextFromSpec<{ + body: { name: string; email: string }; +}>; + +interface UserRow { + id: number; + name: string; +} + +class UserController extends Controller { + async find(context: FindContext): Promise { + this.log('finding user', context.params.id); + const row: UserRow = { id: Number(context.params.id), name: 'Alice' }; + this.success(row); + } + + async create(context: CreateContext): Promise { + const exists = false; // await this.db... + if (exists) { + this.failed( + { email: context.body.email }, + '409;Data Already Exists', + 409, + ); + } + this.success({ created: context.body.name }); + } +} + +const controller = new UserController(); +const router = new Router('/users'); + +// Arrow wrappers keep `this` bound (same rule as JavaScript) +router.get('/{:id}', async (context) => controller.find(context)); +router.post('/', async (context) => controller.create(context)); +``` + +## Models in TypeScript + +Declare properties with `!` (they are assigned by the base constructor, not +in the subclass body): + +```typescript +import { Model, ContextFromSpec, Router, success } from '@axiosleo/koapp'; + +class UserModel extends Model { + name!: string; + email!: string; + age?: number; +} + +type SignUpContext = ContextFromSpec<{ + body: { name: string; email: string; age?: number }; +}>; + +const router = new Router('/auth'); + +router.post('/sign-up', async (context) => { + // Throws a 400 response on invalid data + const user = Model.create(context.body, { + name: 'required|string', + email: 'required|email', + age: 'integer|min:0', + }); + success({ name: user.name, email: user.email }); +}); + +// Manual validation without throwing +const draft = new UserModel({ name: 'a', email: 'not-an-email' }); +const validator = draft.validate({ email: 'required|email' }); +if (validator.fails()) { + console.log(validator.errors.all()); +} +``` + +## Typed Socket / WebSocket servers + +`SocketContext` and `WebSocketContext` are exported directly: + +```typescript +import { + Router, + SocketApplication, + WebSocketApplication, + SocketContext, + WebSocketContext, + success, +} from '@axiosleo/koapp'; + +// TCP: frames end with the @@@@@@ delimiter +type ChatContext = SocketContext< + { room: string }, + { message: string; type: 'text' | 'image' }, + { token?: string } +>; + +const chatRouter = new Router('/chat'); +chatRouter.any('/{:room}', async (context) => { + const room = context.params?.room; + const message = context.body?.message; + context.app.broadcast({ room, message, from: context.connection_id }, 'chat', 0); + success({}); +}); + +new SocketApplication({ + port: 8082, + routers: [chatRouter], + ping: { open: true, interval: 30000, data: 'ping' }, +}).start(); + +// WebSocket: plain JSON frames +type WsContext = WebSocketContext< + { channel: string }, + { event: string; data: unknown }, + { token?: string } +>; + +const wsRouter = new Router('/ws'); +wsRouter.any('/{:channel}', async (context) => { + context.socket.send(JSON.stringify({ ack: true })); + context.app.sendByConnectionId(context.connection_id, { ok: true }); + success({}); +}); + +new WebSocketApplication({ + port: 8083, + routers: [wsRouter], + maxPayload: 1024 * 1024, // ws ServerOptions fields are accepted + clientTracking: true, +}).start(); +``` + +## Typed application startup + +```typescript +import { + KoaApplication, + Router, + ContextFromSpec, + type KoaApplicationConfig, + type TypedKoaApplicationConfig, +} from '@axiosleo/koapp'; + +type UserContext = ContextFromSpec<{ params: { id: string } }>; +const userRouter = new Router('/users'); + +// Flexible: routers with different context types can mix +const config: KoaApplicationConfig = { + listen_host: '0.0.0.0', + port: 8080, + debug: false, + routers: [userRouter], + session: { maxAge: 1296000000, httpOnly: true, signed: true }, + static: { rootDir: './public' }, +}; +new KoaApplication(config).start(); + +// Strict: every router must be the same Router<...> type +type UserRouter = Router; +const strictConfig: TypedKoaApplicationConfig = { + listen_host: 'localhost', + port: 8081, + routers: [userRouter], +}; +``` + +## SSE in TypeScript + +Use default inference - the inferred context already types +`context.koa.sse` (as optional, hence the `!` after the middleware ran): + +```typescript +import { Router, middlewares } from '@axiosleo/koapp'; + +const router = new Router('/events'); + +router.any('/sse', async (context) => { + const sse = middlewares.KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + + context.koa.sse!.send({ event: 'tick', data: { at: Date.now() } }); + context.koa.sse!.send('plain string works too'); + context.koa.sse!.close(); +}); +``` diff --git a/assets/skills/koapp/SKILL.md b/assets/skills/koapp/SKILL.md index 68a18d4..f9aef0a 100644 --- a/assets/skills/koapp/SKILL.md +++ b/assets/skills/koapp/SKILL.md @@ -62,6 +62,7 @@ code-level guidance: - Organizing handlers into classes with shared helpers → **koapp-controller** - Validating and serializing structured payloads → **koapp-model** - Pushing real-time events to the browser over HTTP → **koapp-sse** +- Writing koapp code in TypeScript / typing contexts and routers → **koapp-typescript** ## Request Lifecycle (Koa path) From 17fb0700a4a1c059559bd835447e5c57a8ab8817 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Tue, 11 Aug 2026 13:53:45 +0800 Subject: [PATCH 2/4] refactor: export types in index.d.ts - export types in index.d.ts for better TypeScript support; - update documentation for KoaContext and file upload examples; --- assets/skills/koapp-apps/http-server.md | 13 +- assets/skills/koapp-typescript/SKILL.md | 160 +++++++++++++-------- assets/skills/koapp-typescript/examples.md | 69 ++++++--- index.d.ts | 30 ++-- 4 files changed, 176 insertions(+), 96 deletions(-) diff --git a/assets/skills/koapp-apps/http-server.md b/assets/skills/koapp-apps/http-server.md index d63140e..8098d81 100644 --- a/assets/skills/koapp-apps/http-server.md +++ b/assets/skills/koapp-apps/http-server.md @@ -123,17 +123,16 @@ router.post('/upload', async (context) => { Install with `npm install @koa/multer` (and `@types/koa__multer` for TS). In TypeScript, uploaded files are **not** covered by the body generic - -they live on `context.koa.request.files`. Intersect a typed `koa` onto the -context so the multer types apply: +they live on `context.koa.request.files`. Type the route through +`KoaContext` so the multer types apply: ```typescript import multer from '@koa/multer'; -import type { ParameterizedContext } from 'koa'; -import { ContextFromSpec, success, failed } from '@axiosleo/koapp'; +import { KoaContext, success, failed } from '@axiosleo/koapp'; -type UploadContext = ContextFromSpec<{ +type UploadContext = KoaContext<{ dir: string }> & { params: { dir: string }; -}> & { koa: ParameterizedContext }; +}; router.post('/upload/{:dir}', async (context) => { const upload = multer({ storage: multer.memoryStorage() }); @@ -143,7 +142,7 @@ router.post('/upload/{:dir}', async (context) => { if (!first) { failed({}, '400;Bad Data', 400); } - success({ name: first.originalname, size: first.size }); + success({ dir: context.params.dir, name: first.originalname }); }); ``` diff --git a/assets/skills/koapp-typescript/SKILL.md b/assets/skills/koapp-typescript/SKILL.md index 35d5ba2..50225bd 100644 --- a/assets/skills/koapp-typescript/SKILL.md +++ b/assets/skills/koapp-typescript/SKILL.md @@ -1,6 +1,6 @@ --- name: koapp-typescript -description: Write type-safe @axiosleo/koapp code in TypeScript - typed contexts (ContextFromSpec, RequiredContext, SocketContext, WebSocketContext), Router generics, reusable typed middlewares, typed file uploads with @koa/multer, response helper generics, and typed application configs. Use when a koapp project uses TypeScript, when typing context.params/body/query, annotating route handlers or controllers, typing uploaded files, or importing types from @axiosleo/koapp. +description: Write type-safe @axiosleo/koapp code in TypeScript - typed contexts (KoaContext, ContextFromSpec, RequiredContext, SocketContext, WebSocketContext), Router generics, ContextHandler middlewares, module augmentation, typed file uploads with @koa/multer, response helper generics, and typed application configs. Use when a koapp project uses TypeScript, when typing context.params/body/query, annotating route handlers or controllers, typing uploaded files, or importing types from @axiosleo/koapp. --- # @axiosleo/koapp in TypeScript @@ -9,7 +9,7 @@ Source of truth: [`index.d.ts`](../../../index.d.ts) at the package root. The framework ships a full generic type system. This skill covers what is importable, how to type contexts and handlers, and the recipes for cases the -generics do not cover directly (file uploads, shared middlewares). +generics do not cover directly (file uploads, extending the context). ## Setup @@ -24,58 +24,69 @@ npm install @koa/multer && npm install -D @types/koa__multer same installed copy for `context.koa` typing and the multer module augmentation to line up. -## What is (and is not) importable +## Importable symbols -Importable from `@axiosleo/koapp`: +All of these come straight from `import { ... } from '@axiosleo/koapp'`: | Category | Symbols | | --- | --- | | Classes | `KoaApplication`, `SocketApplication`, `WebSocketApplication`, `Application`, `Router`, `Controller`, `Model`, `HttpResponse`, `HttpError`, `SocketClient` | -| Context types | `ContextFromSpec`, `RequiredContext`, `SocketContext`, `WebSocketContext` | -| Config types | `KoaApplicationConfig`, `TypedKoaApplicationConfig`, `SocketAppConfiguration`, `TypedSocketAppConfiguration`, `WebSocketAppConfiguration`, `TypedWebSocketAppConfiguration`, `PingConfig`, `HttpResponseConfig` | +| Context types | `KoaContext`, `AppContext`, `ContextFromSpec`, `RequiredContext`, `SocketContext`, `WebSocketContext`, `ContextDataSpec`, `ContextHandler` | +| Router types | `RouterOptions`, `RouterInfo`, `RouterValidator`, `ValidatorConfig`, `HttpMethod` | +| Config types | `AppConfiguration`, `TypedAppConfiguration`, `KoaApplicationConfig`, `TypedKoaApplicationConfig`, `SocketAppConfiguration`, `TypedSocketAppConfiguration`, `WebSocketAppConfiguration`, `TypedWebSocketAppConfiguration`, `PingConfig`, `HttpResponseConfig` | +| Response / SSE | `StatusCode`, `IKoaSSE`, `IKoaSSEvent`, `SSEOptions` | | Functions | `success`, `failed`, `result`, `response`, `error`, `initContext`, `middlewares.KoaSSEMiddleware` | -**Not exported** (internal to `index.d.ts`): `KoaContext`, `AppContext`, -`ContextHandler`, `RouterOptions`, `RouterInfo`, `StatusCode`, -`AppConfiguration`. You cannot `import { KoaContext }` - use the recipes -below instead. Handlers on a plain `new Router()` still infer the full -`KoaContext` shape automatically, so this mostly matters when you want to -*name* a type. +On `@axiosleo/koapp` **1.3.1 and earlier** the context/router/config types +above (everything except `ContextFromSpec`, `RequiredContext`, +`SocketContext`, `WebSocketContext` and the `*ApplicationConfig` family) +were not exported; recover the context type there with +`type KoaCtx = Router extends Router ? C : never;`. ## Typing contexts: pick the right tool | Approach | Data typing | `context.koa` | When | | --- | --- | --- | --- | | Default inference (`new Router()`) | loose (`params?`, `body: any`) | fully typed (incl. `sse`) | quick handlers, SSE, redirects | -| `ContextFromSpec<{...}>` | strict, required, no `?.` | `any` (via index signature) | most HTTP data handlers - **preferred** | -| `RequiredContext` | strict, positional generics | `any` | same, if you prefer positional style | -| `ContextFromSpec<...> & { koa: ParameterizedContext }` | strict | fully typed | uploads, sessions, streaming with typed data | +| `KoaContext` | typed but optional (`params?.`) | fully typed | HTTP handlers touching `koa` | +| `KoaContext & { params: P; body: B; query: Q }` | strict, required | fully typed | uploads, sessions + typed data - **preferred for HTTP** | +| `ContextFromSpec<{...}>` | strict, required, object-style | `any` (via index signature) | transport-agnostic data handlers | +| `RequiredContext` | strict, positional | `any` | same, positional style | | `SocketContext` / `WebSocketContext` | strict via generics | n/a (`socket` instead) | TCP / WebSocket handlers | -`ContextFromSpec` takes an object spec - order-free, declare only what you -need: +Define the required-props HTTP variant once and reuse it: ```typescript -import { Router, ContextFromSpec, success } from '@axiosleo/koapp'; +import { Router, KoaContext, success } from '@axiosleo/koapp'; -type CreateUserContext = ContextFromSpec<{ - params: { id: string }; - body: { name: string; email: string; age?: number }; - query: { format?: 'json' | 'xml' }; -}>; +type HttpContext

, B = any, Q = any> = + KoaContext & { params: P; body: B; query: Q }; + +type CreateUserContext = HttpContext< + { id: string }, + { name: string; email: string; age?: number }, + { format?: 'json' | 'xml' } +>; const router = new Router('/api'); router.post('/users/{:id}', async (context) => { - const id = context.params.id; // string - required, no ?. - const name = context.body.name; // string + const id = context.params.id; // string - required, no ?. + const name = context.body.name; // string const format = context.query.format; // 'json' | 'xml' | undefined + context.koa.set('X-Handled-By', 'user-service'); // koa fully typed success({ id, name, format }); }); ``` -`RequiredContext` is the positional equivalent; `P` and `Q` must -extend `Record` (optional string-literal props are fine). +`ContextFromSpec` is the object-style alternative (order-free, declare only +what you need) when the handler never touches `context.koa`: + +```typescript +import { ContextFromSpec } from '@axiosleo/koapp'; + +type SearchContext = ContextFromSpec<{ body: { q: string } }>; +``` ## Three levels of Router generics @@ -101,44 +112,76 @@ router.post('/users/{:id}', handler, { }); ``` -## Naming the full KoaContext (shared middlewares) +## Typed shared middlewares -`ContextHandler` and `KoaContext` are not exported, but the context type can -be recovered from `Router`'s default generic: +`ContextHandler` (default `T = KoaContext`) is the type of every +middleware, handler, and after-handler: ```typescript -import { Router } from '@axiosleo/koapp'; +import { Router, error } from '@axiosleo/koapp'; +import type { ContextHandler } from '@axiosleo/koapp'; -/** The framework's KoaContext - koa, url, sse and all */ -type KoaCtx = Router extends Router ? C : never; +const requestLogger: ContextHandler = async (context) => { + console.log(`[${context.method}] ${context.pathinfo}`); +}; -const authMiddleware = async (context: KoaCtx): Promise => { +const requireAuth: ContextHandler = async (context) => { if (!context.koa.session?.user) { - // throw a response, see koapp-response + error(401, 'Unauthorized'); } }; -const secured = new Router('/secure', { middlewares: [authMiddleware] }); +const secured = new Router('/secure', { + middlewares: [requestLogger, requireAuth], +}); ``` -Do **not** try `Omit & { body: B }` to retype the data -properties - the base context carries a `[key: string]: any` index -signature that breaks `Omit`. Use `ContextFromSpec` for typed data instead. +## Extending KoaContext (module augmentation) + +`KoaContext` is an exported interface, so middlewares can attach their own +typed properties to it - the monorepo scaffold's auth middleware uses +exactly this pattern: + +```typescript +import { error } from '@axiosleo/koapp'; +import type { ContextHandler } from '@axiosleo/koapp'; + +export interface AuthInfo { + userId: string; + isAdmin?: boolean; +} + +declare module '@axiosleo/koapp' { + interface KoaContext { + auth?: AuthInfo; + } +} + +export const authMiddleware: ContextHandler = async (context) => { + const header = context.headers?.authorization; + if (!header) { + error(401, 'Unauthorized'); + } + context.auth = { userId: 'u_1' }; // typed as AuthInfo | undefined +}; +``` + +Every handler in the project now sees `context.auth` with full typing. ## Typed file uploads Uploaded files live on `context.koa.request.files`, **not** on -`context.body` - the `TBody` generic never covers them. Intersect a typed -`koa` onto the spec so the multer augmentation applies: +`context.body` - the `TBody` generic never covers them. Use the +`HttpContext` helper so both the data and `koa` are typed: ```typescript import multer from '@koa/multer'; -import type { ParameterizedContext } from 'koa'; -import { Router, ContextFromSpec, success, failed } from '@axiosleo/koapp'; +import { Router, KoaContext, success, failed } from '@axiosleo/koapp'; + +type HttpContext

, B = any, Q = any> = + KoaContext & { params: P; body: B; query: Q }; -type UploadContext = ContextFromSpec<{ - params: { dir: string }; -}> & { koa: ParameterizedContext }; +type UploadContext = HttpContext<{ dir: string }>; router.post('/upload/{:dir}', async (context) => { const upload = multer({ storage: multer.memoryStorage() }); @@ -150,7 +193,7 @@ router.post('/upload/{:dir}', async (context) => { if (!first) { failed({}, '400;Bad Data', 400); } - success({ name: first.originalname, size: first.size }); + success({ dir: context.params.dir, name: first.originalname }); }); ``` @@ -171,9 +214,9 @@ user.name; // user narrowed to User - no ! needed success(user); ``` -The status-code parameter accepts any `";"` string; presets -like `'200;Success'`, `'404;Not Found'`, `'409;Data Already Exists'` are -listed in **koapp-response**. +The `StatusCode` type accepts any `";"` string; presets like +`'200;Success'`, `'404;Not Found'`, `'409;Data Already Exists'` are listed +in **koapp-response**. ## Typed application configs @@ -205,13 +248,16 @@ additionally accepts every `ws` `ServerOptions` field (`maxPayload`, ## Common pitfalls -- `import { KoaContext }` fails - the type is not exported. Use default - inference, `ContextFromSpec`, or the `infer` recipe above. -- The base context has `[key: string]: any`, so typos like `context.bodyy` - compile silently as `any`. Prefer `ContextFromSpec` so real fields are - strictly typed. -- Intersecting onto the inferred `KoaCtx` (`KoaCtx & { body: B }`) does not - retype `body` - `any & B` collapses to `any`. +- Retype data via the **generic** form: `KoaContext & { params: P; ... }` + works, but intersecting the default-parameterized alias + (`KoaCtx & { body: B }`) collapses to `any` because the default `body` is + already `any`. +- Do not `Omit` - the base context carries a + `[key: string]: any` index signature that makes `Omit` drop every named + property. +- That same index signature means typos like `context.bodyy` compile + silently as `any`. Prefer strictly typed contexts so real fields are + checked. - Uploaded files are never in `TBody`; type them through `context.koa` (see above). - Two copies of `@types/koa` in the dependency tree make diff --git a/assets/skills/koapp-typescript/examples.md b/assets/skills/koapp-typescript/examples.md index 4a2b6a0..b47097f 100644 --- a/assets/skills/koapp-typescript/examples.md +++ b/assets/skills/koapp-typescript/examples.md @@ -71,21 +71,20 @@ users.delete('/{:id}', async (context) => { ## File uploads Files never appear in `TBody`; they live on `context.koa.request.files` -(`@koa/multer` + `@types/koa__multer`). Intersect a typed `koa` onto the -spec: +(`@koa/multer` + `@types/koa__multer`). Use the required-props `KoaContext` +helper so both the route data and `koa` are fully typed: ```typescript import multer from '@koa/multer'; -import type { ParameterizedContext } from 'koa'; -import { Router, ContextFromSpec, success, failed } from '@axiosleo/koapp'; +import { Router, KoaContext, success, failed } from '@axiosleo/koapp'; + +type HttpContext

, B = any, Q = any> = + KoaContext & { params: P; body: B; query: Q }; const router = new Router('/files'); // Multiple files: upload.any() -> request.files needs narrowing -type UploadContext = ContextFromSpec<{ - params: { dir: string }; - query: { overwrite?: string }; -}> & { koa: ParameterizedContext }; +type UploadContext = HttpContext<{ dir: string }, any, { overwrite?: string }>; router.post('/upload/{:dir}', async (context) => { const upload = multer({ storage: multer.memoryStorage() }); @@ -108,9 +107,7 @@ router.post('/upload/{:dir}', async (context) => { }); // Single file: upload.single(field) -> request.file (multer.File) -type AvatarContext = ContextFromSpec<{ - params: { userId: string }; -}> & { koa: ParameterizedContext }; +type AvatarContext = HttpContext<{ userId: string }>; router.post('/avatar/{:userId}', async (context) => { const upload = multer({ storage: multer.memoryStorage() }); @@ -134,19 +131,18 @@ router.post('/echo/{:userId}', async (context) => { ## Reusable typed middleware -`ContextHandler` / `KoaContext` are not exported; recover the context type -from `Router` once and share it: +`ContextHandler` (default `T = KoaContext`) types any middleware, +handler, or after-handler: ```typescript import { Router, error } from '@axiosleo/koapp'; +import type { ContextHandler } from '@axiosleo/koapp'; -type KoaCtx = Router extends Router ? C : never; - -const requestLogger = async (context: KoaCtx): Promise => { +const requestLogger: ContextHandler = async (context) => { console.log(`[${context.method}] ${context.pathinfo}`); }; -const requireAuth = async (context: KoaCtx): Promise => { +const requireAuth: ContextHandler = async (context) => { const token = context.headers?.authorization; if (!token) { error(401, 'Unauthorized'); @@ -165,6 +161,45 @@ const api = new Router('/api', { const secured = api.new('/admin', { middlewares: [requireAuth] }); ``` +## Extending KoaContext (module augmentation) + +Attach typed properties to the context from a middleware - the same +pattern the monorepo scaffold uses for Bearer auth: + +```typescript +import { error } from '@axiosleo/koapp'; +import type { ContextHandler } from '@axiosleo/koapp'; + +export interface AuthContext { + app_id: string | null; + key_id: string; + is_admin?: boolean; +} + +declare module '@axiosleo/koapp' { + interface KoaContext { + auth?: AuthContext; + } +} + +export const authMiddleware: ContextHandler = async (context) => { + const header = context.headers?.authorization; + if (!header || typeof header !== 'string') { + error(401, 'Unauthorized'); + } + const match = /^Bearer\s+(.+)$/i.exec(header as string); + if (!match) { + error(401, 'Unauthorized'); + } + context.auth = { app_id: null, key_id: match![1].trim() }; +}; + +// Downstream handlers see the typed property: +const whoami: ContextHandler = async (context) => { + console.log(context.auth?.key_id); // string | undefined +}; +``` + ## Controllers in TypeScript ```typescript diff --git a/index.d.ts b/index.d.ts index f60b2f0..c8461a9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -17,7 +17,7 @@ import type { ServerOptions, WebSocket } from "ws"; * Predefined status codes with format "code;message" * Used for standardized API responses */ -type StatusCode = +export type StatusCode = | string | "000;Unknown Error" | "200;Success" @@ -38,7 +38,7 @@ type StatusCode = * HTTP methods supported by the framework * Includes both uppercase and lowercase variants */ -type HttpMethod = +export type HttpMethod = | "ANY" | "GET" | "POST" @@ -239,7 +239,7 @@ export declare class Controller implements ControllerInterface { /** * Configuration for request validation */ -interface ValidatorConfig { +export interface ValidatorConfig { /** Validation rules */ rules: Rules; /** Custom error messages */ @@ -249,7 +249,7 @@ interface ValidatorConfig { /** * Validators for different parts of the request */ -interface RouterValidator { +export interface RouterValidator { /** Path parameter validation */ params?: ValidatorConfig; /** Query parameter validation */ @@ -290,7 +290,7 @@ interface RouterValidator { * }; * ``` */ -interface RouterInfo< +export interface RouterInfo< TParams = Record, TBody = any, TQuery = any, @@ -327,7 +327,7 @@ interface RouterInfo< /** * Server-sent event data structure */ -interface IKoaSSEvent { +export interface IKoaSSEvent { /** Event ID */ id?: number; /** Event data */ @@ -339,7 +339,7 @@ interface IKoaSSEvent { /** * Server-sent events interface extending Transform stream */ -interface IKoaSSE extends Transform { +export interface IKoaSSE extends Transform { /** Send SSE event */ send(data: IKoaSSEvent | string): void; /** Send keep-alive ping */ @@ -374,7 +374,7 @@ interface IKoaSSE extends Transform { * }; * ``` */ -interface AppContext< +export interface AppContext< TParams = Record, TBody = any, TQuery = any, @@ -476,7 +476,7 @@ interface AppContext< * }); * ``` */ -interface KoaContext< +export interface KoaContext< TParams = Record, TBody = any, TQuery = any, @@ -610,7 +610,7 @@ export interface WebSocketContext< * Interface for defining context data specification * This allows flexible type configuration without order dependency */ -interface ContextDataSpec< +export interface ContextDataSpec< TParams extends Record = Record, TBody = any, TQuery extends Record = Record, @@ -732,7 +732,7 @@ export type ContextFromSpec = * }; * ``` */ -type ContextHandler = KoaContext> = ( +export type ContextHandler = KoaContext> = ( context: T, ) => Promise; @@ -774,7 +774,7 @@ type ContextHandler = KoaContext> = ( * }; * ``` */ -interface RouterOptions = KoaContext> { +export interface RouterOptions = KoaContext> { /** Default HTTP method */ method?: HttpMethod; /** Route handlers */ @@ -967,7 +967,7 @@ export class Router = KoaContext> { /** * Options for Server-Sent Events middleware */ -type SSEOptions = { +export type SSEOptions = { /** Ping interval in milliseconds (default: 60000) */ pingInterval?: number; /** Event name for close event (default: 'close') */ @@ -1023,7 +1023,7 @@ export namespace middlewares { * }; * ``` */ -interface AppConfiguration { +export interface AppConfiguration { [key: string]: any; /** Enable debug mode */ debug?: boolean; @@ -1060,7 +1060,7 @@ interface AppConfiguration { * }; * ``` */ -interface TypedAppConfiguration< +export interface TypedAppConfiguration< TRouters extends Router[] = Router[], > extends Omit { /** Strictly typed application routers */ From 9b8295de6eaf82670b6749ab66d1566e794d2c57 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Tue, 11 Aug 2026 13:53:59 +0800 Subject: [PATCH 3/4] chore: remove gen.js command file - Deleted the gen.js file, which was responsible for generating TypeScript files from JSON schema files, as part of a cleanup process. --- commands/gen.js | 470 ------------------------------------------------ 1 file changed, 470 deletions(-) delete mode 100644 commands/gen.js diff --git a/commands/gen.js b/commands/gen.js deleted file mode 100644 index 3052915..0000000 --- a/commands/gen.js +++ /dev/null @@ -1,470 +0,0 @@ -'use strict'; - -const path = require('path'); -// eslint-disable-next-line no-unused-vars -const { Command, printer, debug } = require('@axiosleo/cli-tool'); -const { - // _select_multi, - _foreach -} = require('@axiosleo/cli-tool/src/helper/cmd'); -const { Emitter, _snake_case, _upper_first, _caml_case } = require('@axiosleo/cli-tool/src/helper/str'); -const { _write, _exists, _read_json, _search, _read } = require('@axiosleo/cli-tool/src/helper/fs'); -const is = require('@axiosleo/cli-tool/src/helper/is'); -const { _deep_clone } = require('@axiosleo/cli-tool/src/helper/obj'); - -class GenTsCommand extends Command { - constructor() { - super({ - name: 'generate', - desc: 'Generate ts files from json schema files', - alias: ['gen'] - }); - this.addArgument('name', 'Specify module name', 'optional'); - this.addOption('meta_dir', 'd', 'The koapp project directory. the project is generate by koapp-cli', 'optional', process.cwd()); - this.addOption('output', 'o', 'The output directory', 'optional', process.cwd()); - } - - /** - * @param {{name:string}} args - * @param {dir:string} options - * @param {string[]} argList - * @param {import('@axiosleo/cli-tool').App} app - */ - async exec(args, options) { - const methodsOption = ['Find', 'Page', 'Load', 'Create', 'Update', 'Patch', 'Delete', 'BatchCreate', 'BatchUpdate', 'BatchDelete']; - let methods = []; - if (args.name) { - // methods = await _select_multi('Please select the methods to generate', methodsOption, methodsOption); - methods = methodsOption; - await this.generate({ - methods, schema: { - '$schema': 'https://json-schema.org/draft/2020-12/schema', - '$id': 'https://example.com/product.schema.json', - 'title': _caml_case(args.name), - 'description': '', - 'type': 'object', - 'properties': {}, - 'required': [] - }, - targetDir: options.meta_dir, - subDir: 'services/src/modules/', - genFiles: ['model', 'controller', 'router'] - }); - return; - } - let metaDir = path.resolve(options.meta_dir); - const outputDir = path.resolve(options.output); - if (!await _exists(metaDir)) { - printer.println().error('The meta directory must be exists. [' + metaDir + ']'); - return; - } - if (await is.file(metaDir)) { - printer.println().error('The meta argument must be a directory'); - return; - } - let files = await _search(metaDir, 'json'); - if (!files.length) { - printer.println().error('No json schema files found in the meta directory : ' + metaDir); - return; - } - // methods = await _select_multi('Please select the methods to generate', methodsOption, methodsOption); - methods = methodsOption; - files = files.map((f) => { - let genFiles = f.endsWith('.schema.json') ? ['model', 'controller', 'router'] : ['model']; - return { metaFile: f, targetDir: outputDir, methods, genFiles }; - }); - if (!files.length) { - return; - } - await _foreach(files, async (config) => { - config.schema = await _read_json(config.metaFile); - await this.generate(config); - }); - } - - async generate(config) { - const { methods, targetDir, genFiles, schema } = config; - let name = null; - let title = ''; - let reqSchema = null; - let modelSchema = null; - if (!schema.title) { - throw new Error('Must be set title for schema'); - } - title = schema.title.split(' ').map(c => _upper_first(c)).join(''); - // 去掉每个字段的 title 属性 - Object.keys(schema.properties).forEach((p) => { - if (schema.properties[p].title) { - let desc = `${schema.properties[p].title};${schema.properties[p].description || ''}`; - delete schema.properties[p].title; - schema.properties[p].description = desc; - } - }); - name = _snake_case(title); - schema.fields = Object.keys(schema.properties).map(p => _snake_case(p)); - reqSchema = schema; - modelSchema = _deep_clone(schema); - // 为 modelSchema 增加 id, created_by, updated_by, created_at, updated_at 字段 - modelSchema.properties = { - ...modelSchema.properties, - id: { - type: 'integer' - }, - created_by: { - type: 'integer' - }, - updated_by: { - type: 'integer' - }, - created_at: { - type: 'string', - format: 'date-time' - }, - updated_at: { - type: 'string', - format: 'date-time' - }, - deleted_at: { - type: 'string', - format: 'date-time' - } - }; - modelSchema.required.push('id'); - - const context = { - name, // snake case - title, // camel case - methods, // array - reqSchema, - modelSchema, - targetDir - }; - - await _foreach(genFiles, async (f) => { - switch (f) { - case 'model': - await this.generateModel(context); - break; - case 'controller': - await this.generateController(context); - break; - case 'router': { - await this.generateRouter(context); - const routesFile = path.join(targetDir, 'index.ts'); - if (!await _exists(routesFile)) { - return; - } - let content = await _read(routesFile); - let rows = content.split('\n'); - const existRoute = rows.find((r) => r.indexOf(`root.add(${name})`) > -1); - if (!existRoute) { - rows.splice(1, 0, `import ${name} from './${name}.router';`); - rows.splice(rows.length - 2, 0, `root.add(${name});`); - await _write(routesFile, rows.join('\n')); - } - break; - } - default: - throw new Error('Invalid type for generate file ' + f); - } - }); - } - - async generateModel(context) { - const { name, reqSchema, modelSchema, title } = context; - const emitter = new Emitter(); - emitter.emitln('import { FromSchema } from \'json-schema-to-ts\';').emitln(); - emitter.emitln(`const ${title}ItemSchema = ${JSON.stringify(reqSchema, null, 2)} as const;`); - emitter.emitln(`export type ${title}Item = FromSchema;`); - - emitter.emitln(`const ${title}ModelSchema = ${JSON.stringify(modelSchema, null, 2)} as const;`); - emitter.emitln(`export type ${title}Model = FromSchema;`); - - await _write(path.join(context.targetDir, `${name}.model.ts`), emitter.output()); - } - - async generateController(context) { - const { name, title, methods } = context; - const filePath = path.join(context.targetDir, `${name}.controller.ts`); - if (await _exists(filePath)) { - printer.yellow('The file already exists: ').println(`${name}.controller.ts`); - return; - } - const emitter = new Emitter(); - emitter.emitln('import { success, error, failed } from \'@axiosleo/koapp\';'); - emitter.emitln('import { helper } from \'@axiosleo/cli-tool\';'); - emitter.emitln('import { BaseController } from \'./controller\';'); - emitter.emitln('const { _foreach } = helper.cmd;').emitln(); - emitter.emitln(`import { ${title}Item, ${title}Model } from './${name}.model';`); - - emitter.emitln(`class ${title} extends BaseController {`, 'open'); - let isBegin = true; - methods.forEach((method) => { - let m = `generate${method}Method`; - if (this[m]) { - if (!isBegin) { - emitter.emitln(); - } - this[m].call(this, context, emitter); - if (isBegin) { - isBegin = false; - } - } - }); - emitter.emitln('}', 'close').emitln(); - emitter.emitln(`export default new ${title}();`); - - await _write(filePath, emitter.output()); - } - - async generateRouter(context) { - const { name, methods } = context; - const emitter = new Emitter(); - emitter.emitln('import { KoaContext, Router } from \'@axiosleo/koapp\';'); - emitter.emitln(`import controller from './${name}.controller';`); - emitter.emitln(); - - emitter.emitln(`const root = new Router('/${name}');`).emitln(); - let isBegin = true; - methods.forEach((method) => { - let m = `generate${method}Router`; - if (this[m]) { - if (!isBegin) { - emitter.emitln(); - } - this[m].call(this, context, emitter); - if (isBegin) { - isBegin = false; - } - } - }); - - emitter.emitln().emitln('export default root;'); - - await _write(path.join(context.targetDir, `${name}.router.ts`), emitter.output()); - } - - generateFindMethod(context, emitter) { - const { name } = context; - emitter.emitln('async find(id: number) {', 'begin'); - emitter.emitln(`const item = await this.mainDB.table('${name}').where('id', id).find();`, true); - emitter.emitln('if (!item) {', 'begin'); - emitter.emitln('error(404, \'Not Found\');', true); - emitter.emitln('}', 'end'); - emitter.emitln('success(item);', true); - emitter.emitln('}', 'end'); - } - - generatePageMethod(context, emitter) { - emitter.emitln('async page(page: number, size: number, fields?: string[]) {', 'begin'); - emitter.emitRows( - `const query = this.mainDB.table('${context.name}');`, - 'if (fields) {', - emitter.config.indent + 'query.attr(...fields);', - '}', - 'const items = await query.page(size, size * (page - 1)).select();', - 'success(items);' - ); - emitter.emitln('}', 'end'); - } - - generateLoadMethod(context, emitter) { - emitter.emitln('async load(last_id: number, order: \'asc\' | \'desc\', fields?: string[]) {', 'begin'); - emitter.emitRows( - `const query = this.mainDB.table('${context.name}');`, - 'if (fields) {', - emitter.config.indent + 'query.attr(...fields);', - '}', - 'const items = await query.where(\'id\', last_id, \'<\').orderBy(\'id\', order).select();', - 'success(items);' - ); - emitter.emitln('}', 'end'); - } - - generateCreateMethod(context, emitter) { - const { title } = context; - emitter.emitln(`async create(data: ${title}Item) {`, 'begin'); - emitter.emitRows( - `const res = await this.mainDB.table('${context.name}').insert(data);`, - 'res.insertId ? success() : failed(data, \'500;Create Failed\');' - ); - emitter.emitln('}', 'end'); - } - - generateUpdateMethod(context, emitter) { - const { title } = context; - emitter.emitln(`async update(id: number, data: ${title}Item) {`, 'begin'); - emitter.emitRows( - `const res = await this.mainDB.table('${context.name}').where('id', id).update(data);`, - 'res.affectedRows || res.changedRows ? success() : failed(data, \'500;Update Failed\');' - ); - emitter.emitln('}', 'end'); - } - - generatePatchMethod(context, emitter) { - emitter.emitln('async patch(id: number, field_name: string, value: any) {', 'begin'); - emitter.emitRows( - `const res = await this.mainDB.table('${context.name}').where('id', id).update({ [field_name]: value });`, - 'res.affectedRows || res.changedRows ? success() : failed({ id, field_name, value }, \'500;Update Failed\');' - ); - emitter.emitln('}', 'end'); - } - - generateDeleteMethod(context, emitter) { - emitter.emitln('async delete(id: number) {', 'begin'); - emitter.emitRows( - `const res = await this.mainDB.table('${context.name}').where('id', id).delete();`, - 'res.affectedRows ? success() : failed({}, \'500;Delete Failed\');' - ); - emitter.emitln('}', 'end'); - } - - generateBatchCreateMethod(context, emitter) { - const { title } = context; - emitter.emitln(`async batchCreate(data: ${title}Item[]) {`, 'begin'); - emitter.emitRows( - `await _foreach(data, async (item: ${title}Item) => {`, - emitter.config.indent + `await this.mainDB.table('${context.name}').insert(item);`, - '});' - ); - emitter.emitln('success({});', true); - emitter.emitln('}', 'end'); - } - - generateBatchUpdateMethod(context, emitter) { - const { title } = context; - emitter.emitln(`async batchUpdate(data: ${title}Model[]) {`, 'begin'); - emitter.emitRows( - `await _foreach(data, async (item: ${title}Model) => {`, - emitter.config.indent + `await this.mainDB.table('${context.name}').where('id', item.id).update(item);`, - '});' - ); - emitter.emitln('success({});', true); - emitter.emitln('}', 'end'); - } - - generateBatchDeleteMethod(context, emitter) { - emitter.emitln('async batchDelete(ids: number[]) {', 'begin'); - emitter.emitRows( - 'await _foreach(ids, async (id: number) => {', - emitter.config.indent + `await this.mainDB.table('${context.name}').where('id', id).delete();`, - '});' - ); - emitter.emitln('success({});', true); - emitter.emitln('}', 'end'); - } - - generateFindRouter(context, emitter) { - emitter.emitln('root.new(\'/{:id}\', {', 'begin'); - emitter.emitln('method: \'get\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const id = parseInt(context.params.id || \'0\');', true); - emitter.emitln('await controller.find(id);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generatePageRouter(context, emitter) { - emitter.emitln('root.new(\'/page/{:page}\', {', 'begin'); - emitter.emitln('method: \'get\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const body = context.body || {};', true); - emitter.emitRows( - 'const size = body.size || 10;', - 'const page = parseInt(context.params.page) || 1;', - 'await controller.page(page, size, body.fields);' - ); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateLoadRouter(context, emitter) { - emitter.emitln('root.new(\'/list/{:last_id}\', {', 'begin'); - emitter.emitln('method: \'get\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const body = context.body || {};', true); - emitter.emitRows( - 'const order = context.query.order || \'desc\';', - 'const last_id = parseInt(context.params.last_id) || 0;', - 'await controller.load(last_id, order, body.fields);' - ); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateCreateRouter(context, emitter) { - emitter.emitln('root.new(\'/create\', {', 'begin'); - emitter.emitln('method: \'post\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const body = context.body || {};', true); - emitter.emitln('await controller.create(body);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateUpdateRouter(context, emitter) { - emitter.emitln('root.new(\'/{:id}\', {', 'begin'); - emitter.emitln('method: \'put\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const id = parseInt(context.params.id || \'0\');', true); - emitter.emitln('const body = context.body || {};', true); - emitter.emitln('await controller.update(id, body);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generatePatchRouter(context, emitter) { - emitter.emitln('root.new(\'/{:id}/{:field_name}\', {', 'begin'); - emitter.emitln('method: \'patch\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const id = parseInt(context.params.id || \'0\');', true); - emitter.emitln('const field_name = context.params.field_name;', true); - emitter.emitln('const body = context.body || {};', true); - emitter.emitln('const value = body.value || undefined;', true); - emitter.emitln('await controller.patch(id, field_name, value);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateDeleteRouter(context, emitter) { - emitter.emitln('root.new(\'/{:id}\', {', 'begin'); - emitter.emitln('method: \'delete\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const id = parseInt(context.params.id || \'0\');', true); - emitter.emitln('await controller.delete(id);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateBatchCreateRouter(context, emitter) { - emitter.emitln('root.new(\'/batch/create\', {', 'begin'); - emitter.emitln('method: \'post\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const body = context.body || [];', true); - emitter.emitln('await controller.batchCreate(body);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateBatchUpdateRouter(context, emitter) { - emitter.emitln('root.new(\'/batch/update\', {', 'begin'); - emitter.emitln('method: \'put\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const body = context.body || [];', true); - emitter.emitln('await controller.batchUpdate(body);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } - - generateBatchDeleteRouter(context, emitter) { - emitter.emitln('root.new(\'/batch/delete\', {', 'begin'); - emitter.emitln('method: \'delete\',', true); - emitter.emitln('handlers: [async (context: KoaContext) => {', 'begin'); - emitter.emitln('const ids = context.body || [];', true); - emitter.emitln('await controller.batchDelete(ids);', true); - emitter.emitln('}]', 'end'); - emitter.emitln('});', 'end'); - } -} - -module.exports = GenTsCommand; From 2a48e2b76e76fd283ec8d3d5c09af385abecbfe0 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Tue, 11 Aug 2026 13:54:51 +0800 Subject: [PATCH 4/4] feat: add documentation for application configuration, CLI commands, controller patterns, middleware patterns, and project structure - Introduced new markdown files detailing application setup, CLI command usage, controller conventions, middleware patterns, and overall project structure for the koapp framework. - Enhanced developer experience by providing clear guidelines and examples for each aspect of the framework. --- .../rules/application-config.mdc | 0 {.cursor => .agents}/rules/cli-commands.mdc | 0 .../rules/controller-patterns.mdc | 0 .../rules/development-workflow.mdc | 0 .agents/rules/git-commit.mdc.tmpl | 85 +++++++++++++++++++ .../rules/middleware-patterns.mdc | 0 .../rules/project-structure.mdc | 0 .../rules/router-patterns.mdc | 0 .agents/rules/services-backend.mdc.tmpl | 28 ++++++ .../rules/socket-application.mdc | 0 .../rules/testing-patterns.mdc | 0 11 files changed, 113 insertions(+) rename {.cursor => .agents}/rules/application-config.mdc (100%) rename {.cursor => .agents}/rules/cli-commands.mdc (100%) rename {.cursor => .agents}/rules/controller-patterns.mdc (100%) rename {.cursor => .agents}/rules/development-workflow.mdc (100%) create mode 100644 .agents/rules/git-commit.mdc.tmpl rename {.cursor => .agents}/rules/middleware-patterns.mdc (100%) rename {.cursor => .agents}/rules/project-structure.mdc (100%) rename {.cursor => .agents}/rules/router-patterns.mdc (100%) create mode 100644 .agents/rules/services-backend.mdc.tmpl rename {.cursor => .agents}/rules/socket-application.mdc (100%) rename {.cursor => .agents}/rules/testing-patterns.mdc (100%) diff --git a/.cursor/rules/application-config.mdc b/.agents/rules/application-config.mdc similarity index 100% rename from .cursor/rules/application-config.mdc rename to .agents/rules/application-config.mdc diff --git a/.cursor/rules/cli-commands.mdc b/.agents/rules/cli-commands.mdc similarity index 100% rename from .cursor/rules/cli-commands.mdc rename to .agents/rules/cli-commands.mdc diff --git a/.cursor/rules/controller-patterns.mdc b/.agents/rules/controller-patterns.mdc similarity index 100% rename from .cursor/rules/controller-patterns.mdc rename to .agents/rules/controller-patterns.mdc diff --git a/.cursor/rules/development-workflow.mdc b/.agents/rules/development-workflow.mdc similarity index 100% rename from .cursor/rules/development-workflow.mdc rename to .agents/rules/development-workflow.mdc diff --git a/.agents/rules/git-commit.mdc.tmpl b/.agents/rules/git-commit.mdc.tmpl new file mode 100644 index 0000000..58a7897 --- /dev/null +++ b/.agents/rules/git-commit.mdc.tmpl @@ -0,0 +1,85 @@ +--- +description: 起草或生成 git commit message 时使用(Conventional Commits + TaskID) +alwaysApply: false +--- + +# Git Commit Message 规范 + +对齐 `@commitlint/config-conventional` 与 Conventional Commits 1.0.0。起草 / 执行 `git commit` 时必须遵循本规范。 + +## 格式 + +```text +(): + + + +