diff --git a/README.md b/README.md index 50aff76..3079751 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,72 @@ app.start(); // Open http://localhost:8088/test ``` +## AI Skills + +`@axiosleo/koapp` ships a bundle of AI Agent Skills so tools like Cursor and +Claude Code can generate framework-correct code for you. Each skill is a +self-contained `SKILL.md` with YAML frontmatter, bundled under +`node_modules/@axiosleo/koapp/assets/skills/` after installation. + +### Install into a project + +```bash +# After: npm install @axiosleo/koapp +npx @axiosleo/koapp skills --install=cursor +npx @axiosleo/koapp skills --install=claude +``` + +This copies the skills into `./.cursor/skills/` or `./.claude/skills/` in the +current project, making them visible to the matching AI tool. + +### Install for the current user + +```bash +npx @axiosleo/koapp skills --install=cursor --scope=user +npx @axiosleo/koapp skills --install=claude --scope=user +``` + +Writes to `~/.cursor/skills/` or `~/.claude/skills/`, shared across every +project on this machine. + +### Options + +| Flag | Values | Default | Description | +| --- | --- | --- | --- | +| `--install`, `-i` | `cursor`, `claude` | required | Which tool's skills directory to target | +| `--scope`, `-s` | `project`, `user` | `project` | Where to write the skills | +| `--force`, `-f` | boolean | `false` | Overwrite existing skill directories without prompting | + +### Bundled skills + +| Skill | Purpose | +| --- | --- | +| `koapp` | Framework overview + navigation to other skills | +| `koapp-apps` | Choose and configure `KoaApplication` (HTTP), `SocketApplication` (TCP), or `WebSocketApplication` | +| `koapp-router` | Define routes, path params, validators, nested routers | +| `koapp-response` | Send responses via `success` / `failed` / `result` / `response` / `error` | +| `koapp-controller` | Organize handlers into classes by extending `Controller` | +| `koapp-model` | Validate and serialize structured data with `Model` | +| `koapp-sse` | Stream Server-Sent Events with `KoaSSEMiddleware` | + +### How the installer picks the source + +1. If `@axiosleo/koapp` is installed in the current project, skills are + copied from `node_modules/@axiosleo/koapp/assets/skills/`. +2. If the project does not depend on `@axiosleo/koapp`, the CLI prompts to + install it first. +3. If the local install is an older version without the skills assets, the + CLI falls back to the skills shipped inside the `npx`-executed copy and + reminds you to run `npm install @axiosleo/koapp@latest`. + +### Uninstall + +```bash +rm -rf ./.cursor/skills/koapp* # or ./.claude/skills/koapp* +# user scope +rm -rf ~/.cursor/skills/koapp* # or ~/.claude/skills/koapp* +``` + ## More Examples - Request Validation diff --git a/assets/skills/koapp-apps/SKILL.md b/assets/skills/koapp-apps/SKILL.md new file mode 100644 index 0000000..fa563f7 --- /dev/null +++ b/assets/skills/koapp-apps/SKILL.md @@ -0,0 +1,125 @@ +--- +name: koapp-apps +description: Choose and configure the right @axiosleo/koapp Application class - KoaApplication for HTTP, SocketApplication for TCP sockets, WebSocketApplication for WebSocket. Use when building a server with koapp, configuring ports, listen host, session, static files, body parser, ping heartbeat, managing socket connections, or deciding between HTTP/TCP/WS transport. +--- + +# @axiosleo/koapp Application Classes + +`@axiosleo/koapp` exposes three runtime application classes, all extending a +shared `Application` base. Pick one based on the transport you need. + +## Which class do I need? + +| Class | Transport | Use when | +| --- | --- | --- | +| `KoaApplication` | HTTP(S) via Koa | REST APIs, file uploads, SSR, Server-Sent Events | +| `SocketApplication` | Raw TCP via Node `net` | Custom TCP protocol, IoT gateways, line-delimited services | +| `WebSocketApplication` | WebSocket via `ws` | Real-time browser apps, chat, live dashboards | + +All three accept `{ port, routers, debug, app_id }` at minimum; see the +per-class docs for full options: + +- [http-server.md](http-server.md) - `KoaApplication` +- [socket-server.md](socket-server.md) - `SocketApplication` +- [websocket-server.md](websocket-server.md) - `WebSocketApplication` +- [examples.md](examples.md) - full server examples + +## Shared config keys + +All three apps normalize config through `Configuration` from +`@axiosleo/cli-tool`: + +```javascript +{ + port: 8080, // Port to listen on + listen_host: 'localhost', // '0.0.0.0' for public access (Koa only) + routers: [], // Array of Router instances + app_id: '', // Optional stable ID; auto uuid-v4 if empty + debug: false // Verbose logging +} +``` + +## Shared events + +All apps extend `EventEmitter` and emit: + +- `starting` - before the server binds +- `response` - after each response is produced (framework uses this internally to write the response) + +Socket apps also expose a separate `app.event` EventEmitter emitting: + +- `connection` - new client connected +- `listen` - server bound to the port + +## Shared lifecycle + +```javascript +const app = new KoaApplication({ ... }); +app.on('starting', () => console.log('about to listen')); +await app.start(); // all three classes implement .start() +``` + +## Connection management (Socket + WebSocket) + +`SocketApplication` implements the following methods, inherited by +`WebSocketApplication`: + +- `broadcast(data, msg, code, connections)` - send to many; pass `null` for all +- `send(connection, data, msg, code)` - send to one raw connection +- `close(connection)` - close one raw connection +- `sendByConnectionId(id, data, msg, code)` - send by tracked connection ID +- `closeByConnectionId(id)` - close by tracked connection ID +- `getConnection(id)` - returns the raw connection or `null` +- `ping(id)` - send a ping payload to one connection + +Connections are tracked in `app.connections` keyed by an auto-generated +`connection_id` (`_uuid_salt('connect:' + app_id)`). + +## Ping heartbeat + +Socket apps support opt-in periodic ping: + +```javascript +new SocketApplication({ + port: 8081, + routers: [root], + ping: { + open: true, // default false + interval: 1000 * 60 * 5, // default 5min + data: 'this is a ping' + } +}); +``` + +When `ping.open` is `true`, the app broadcasts `data` to all active +connections every `interval` ms. + +## Protocol differences + +| Aspect | Socket (TCP) | WebSocket | +| --- | --- | --- | +| Request framing | `{...json}@@@@@@` delimiter | Plain JSON string | +| `send(conn, data)` | `conn.write(data + '@@@@@@')` | `conn.send(data)` | +| `close(conn)` | `conn.end()` | `conn.close()` | +| headers in context | none | `context.headers` (from upgrade request) | + +## Common pitfalls + +- Calling `new KoaApplication({ static: false })` **disables** the built-in + static server. Set `static: { rootDir: './public' }` to enable it. +- `SocketApplication` requires every inbound message to end with `@@@@@@`. + Clients must append that delimiter. +- `WebSocketApplication.ping.open = true` triggers `broadcast` every + `interval` even when there are zero connections - the call is a no-op but + still schedules. +- Do **not** call `app.start()` inside a route handler - the app is already + running at that point. + +## Quick jump + +If you just need to build one server, start with the matching doc: + +- Building an HTTP API → [http-server.md](http-server.md) +- 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) diff --git a/assets/skills/koapp-apps/examples.md b/assets/skills/koapp-apps/examples.md new file mode 100644 index 0000000..d656084 --- /dev/null +++ b/assets/skills/koapp-apps/examples.md @@ -0,0 +1,188 @@ +# Application Examples + +Copy-paste-ready boilerplate for each transport. + +## HTTP server + +```javascript +'use strict'; + +const { KoaApplication, Router, success, failed } = require('@axiosleo/koapp'); + +const router = new Router('/api', { + middlewares: [ + async (context) => { + console.log(`[${context.method}] ${context.pathinfo}`); + } + ] +}); + +router.get('/health', async () => { + success({ status: 'ok' }); +}); + +router.post('/echo', async (context) => { + success({ received: context.body }); +}, { + body: { + rules: { text: 'required|string' } + } +}); + +router.get('/users/{:id}', async (context) => { + const id = Number(context.params.id); + if (!Number.isInteger(id)) { + failed({ id }, '400;Invalid id', 400); + } + success({ id, name: `User ${id}` }); +}); + +const app = new KoaApplication({ + port: 8088, + listen_host: '0.0.0.0', + routers: [router] +}); + +app.start(); +``` + +## TCP socket server + +```javascript +'use strict'; + +const { SocketApplication, Router, success } = require('@axiosleo/koapp'); + +const router = new Router('/', { + middlewares: [ + async (context) => { + console.log(`[tcp:${context.connection_id}] ${context.method} ${context.pathinfo}`); + } + ] +}); + +router.any('/ping', async (context) => { + success({ pong: true, connection_id: context.connection_id }); +}); + +router.any('/chat/{:room}', async (context) => { + // Broadcast the received message to everyone in the server + context.app.broadcast({ + from: context.connection_id, + room: context.params.room, + body: context.body + }, 'chat', 0, null); + success({ delivered: true }); +}); + +const app = new SocketApplication({ + port: 8081, + routers: [router], + ping: { + open: true, + interval: 1000 * 10, + data: 'keep-alive' + } +}); + +app.event.on('connection', (socket) => { + console.log('client from', socket.remoteAddress); +}); + +app.start(); +``` + +Matching TCP client: + +```javascript +const net = require('net'); + +const client = net.createConnection({ port: 8081 }); +client.write(JSON.stringify({ + path: '/chat/general', + method: 'POST', + body: { text: 'hi everyone' } +}) + '@@@@@@'); + +client.on('data', (buf) => { + buf.toString() + .split('@@@@@@') + .filter(Boolean) + .forEach((frame) => console.log(JSON.parse(frame))); +}); +``` + +## WebSocket server + +```javascript +'use strict'; + +const { WebSocketApplication, Router, success } = require('@axiosleo/koapp'); + +const router = new Router('/', { + middlewares: [ + async (context) => { + console.log(`[ws:${context.connection_id}] ${context.method} ${context.pathinfo}`); + } + ] +}); + +router.any('/chat/{:id}', async (context) => { + context.app.broadcast({ + from: context.connection_id, + chatId: context.params.id, + body: context.body + }, 'chat', 0, null); + success({ sent: true }); +}); + +const app = new WebSocketApplication({ + port: 8082, + routers: [router], + ping: { open: false } +}); + +setInterval(() => { + app.broadcast({ tick: Date.now() }, 'tick', 0, null); +}, 5000); + +app.start(); +``` + +Matching browser client: + +```javascript +const ws = new WebSocket('ws://localhost:8082/chat/42?token=abc'); +ws.onopen = () => ws.send(JSON.stringify({ body: { text: 'hi' } })); +ws.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +## Shared HTTP + WebSocket + +```javascript +'use strict'; + +const http = require('http'); +const { KoaApplication, WebSocketApplication, Router, success } = require('@axiosleo/koapp'); + +const httpRouter = new Router('/api'); +httpRouter.get('/health', async () => success({ status: 'ok' })); + +const koaApp = new KoaApplication({ port: 0, routers: [httpRouter] }); +const server = http.createServer(koaApp.koa.callback()); + +const wsRouter = new Router('/'); +wsRouter.any('/ws/{:id}', async (context) => { + success({ echo: context.body, connectionId: context.connection_id }); +}); + +const wsApp = new WebSocketApplication({ + server, + routers: [wsRouter] +}); + +(async () => { + await wsApp.start(); // binds the ws listener + server.listen(3000, () => console.log('listening on 3000')); +})(); +``` diff --git a/assets/skills/koapp-apps/http-server.md b/assets/skills/koapp-apps/http-server.md new file mode 100644 index 0000000..5b96362 --- /dev/null +++ b/assets/skills/koapp-apps/http-server.md @@ -0,0 +1,163 @@ +# KoaApplication (HTTP) + +Source: [`src/apps/koa.js`](../../../src/apps/koa.js). + +`KoaApplication` wraps Koa with session, body parser, static server, and the +framework's routing workflow. It is the default choice for REST APIs. + +## Constructor signature + +```javascript +const { KoaApplication } = require('@axiosleo/koapp'); + +const app = new KoaApplication(config); +``` + +## Full config with defaults + +```javascript +new KoaApplication({ + port: 8080, // Port to listen on + listen_host: 'localhost', // Use '0.0.0.0' for public access + debug: false, // Verbose logging + routers: [], // Array of Router instances + app_id: '', // Optional stable app ID + session_key: 'koa.sess', // Session cookie name + session: { + maxAge: 1296000000, // 15 days + overwrite: true, + httpOnly: true, + signed: true, + rolling: false, + renew: true, + secure: false // Enable in production (HTTPS) + }, + static: { + rootDir: path.join(process.cwd(), './public') + // uploadDir: './public/upload' // optional + }, + body_parser: {}, // passed to koa-bodyparser + sse: false // or { pingInterval: 60000, closeEvent: 'close' } +}); +``` + +Pass `session: false` to disable the session middleware, and `static: false` +to disable the built-in static server. + +## Starting the server + +```javascript +app.start(); // async +``` + +`start()` emits a `starting` event and calls `this.koa.listen(port, listen_host)`. +No arguments needed - everything comes from config. + +## Accessing the underlying Koa instance + +The raw Koa app is at `app.koa`. Use it to mount additional middlewares: + +```javascript +app.koa.use(async (ctx, next) => { + const t = Date.now(); + await next(); + console.log(`${ctx.method} ${ctx.url} - ${Date.now() - t}ms`); +}); +``` + +Mount custom middlewares **before** `app.start()` so they're in the chain. + +## Context inside handlers + +When a route handler runs, it receives an object shaped like +`KoaContext`: + +| Property | Meaning | +| --- | --- | +| `context.app` | The `KoaApplication` instance | +| `context.koa` | The raw Koa `ctx` (`ctx.request`, `ctx.response`, `ctx.session`, etc.) | +| `context.url` | `ctx.req.url` | +| `context.method` | Uppercased HTTP method | +| `context.params` | Path params from `/{:name}` | +| `context.query` | Parsed query string | +| `context.body` | Parsed request body (via koa-bodyparser) | +| `context.request_id` | Auto-generated uuid | +| `context.router` | Matched `RouterInfo` | + +## Response envelope + +The built-in response handler wraps JSON responses: + +```json +{ + "request_id": "...", + "timestamp": 1714651200000, + "code": "200;Success", + "message": "Success", + "data": { } +} +``` + +Call `success(data)` / `failed(...)` / `result(raw, status, headers)` from +`require('@axiosleo/koapp')` to emit responses. They **throw** the response +object; the workflow catches and writes it. See **koapp-response** for +details. + +## File uploads + +`@koa/multer` is the recommended uploader: + +```javascript +const multer = require('@koa/multer'); + +router.post('/upload', async (context) => { + const upload = multer(); + await upload.any()(context.koa, async () => {}); + const file = context.koa.request.files[0]; + context.koa.set('content-type', file.mimetype); + context.koa.body = file.buffer; + context.koa.attachment(file.originalname); +}); +``` + +Install with `npm install @koa/multer` (and `@types/koa__multer` for TS). + +## Sessions + +When `session` config is present, the framework signs the cookie with +`app.app_id`. Access inside handlers: + +```javascript +router.get('/login', async (context) => { + context.koa.session.user = { id: 1 }; + context.koa.session.save(); + context.koa.redirect('/'); +}); +``` + +## Static files + +If `static.rootDir` points to a readable directory, `koa-static-server` +serves its contents. For file downloads from a custom handler, set the +content type and use `ctx.body = fs.createReadStream(path)`. + +## Events + +`KoaApplication` emits `starting` before listening and `response` for every +processed request. Override behavior by listening to `response`: + +```javascript +app.on('response', (context) => { + // context.response holds the HttpResponse; the built-in listener writes it +}); +``` + +Internally the built-in `response` listener writes the final Koa response, +so avoid removing it unless you intend to fully customize output. + +## Troubleshooting + +- `ECONNREFUSED` on localhost from external clients → set `listen_host: '0.0.0.0'` +- Sessions lost across restarts → provide a stable `app_id` so signing key is stable +- Request body always undefined → ensure the request `Content-Type` matches what `koa-bodyparser` accepts (JSON / urlencoded / text) +- CORS → install and mount `@koa/cors` via `app.koa.use(...)` before `start()` diff --git a/assets/skills/koapp-apps/socket-server.md b/assets/skills/koapp-apps/socket-server.md new file mode 100644 index 0000000..5c4a172 --- /dev/null +++ b/assets/skills/koapp-apps/socket-server.md @@ -0,0 +1,147 @@ +# SocketApplication (TCP) + +Source: [`src/apps/socket.js`](../../../src/apps/socket.js). + +`SocketApplication` builds a raw TCP server via Node's `net` module and +routes inbound frames through the same `Router` stack as HTTP. + +## Constructor + +```javascript +const { SocketApplication } = require('@axiosleo/koapp'); + +const app = new SocketApplication({ + port: 8081, + routers: [router], + app_id: 'my-tcp-service', + debug: false, + ping: { + open: true, + interval: 1000 * 10, + data: 'this is a ping message' + } +}); +``` + +## Wire protocol + +Each inbound frame is a JSON string **followed by the 6-byte delimiter** +`@@@@@@`: + +``` +{"path":"/test","method":"GET","query":{"test":123}}@@@@@@ +``` + +The server strips the delimiter before `JSON.parse`. Outbound frames from +`send()` / `broadcast()` / handler responses append the same delimiter so +clients can split messages on `@@@@@@`. + +Client side (example in Node): + +```javascript +const net = require('net'); +const client = net.createConnection({ port: 8081 }); +client.write(JSON.stringify({ + path: '/chat/42', + method: 'GET', + query: { token: 'abc' }, + body: { text: 'hello' } +}) + '@@@@@@'); +client.on('data', (chunk) => { + chunk.toString().split('@@@@@@').filter(Boolean).forEach((frame) => { + const msg = JSON.parse(frame); + console.log(msg); + }); +}); +``` + +## Starting the server + +```javascript +await app.start(); +``` + +`start()` calls `net.createServer` and listens on `config.port`. It logs +`Server is running on port ` on successful bind. + +## Route handler context + +Inside handlers: + +| Property | Meaning | +| --- | --- | +| `context.app` | The `SocketApplication` instance | +| `context.socket` | Raw `net.Socket` connection | +| `context.connection_id` | Tracked ID inside `app.connections` | +| `context.method` | Uppercased from inbound frame (default `GET`) | +| `context.pathinfo` | Inbound `path` field | +| `context.query`, `context.body` | From the inbound frame | + +## Response envelope (default `json` format) + +```json +{ + "request_id": "...", + "timestamp": 1714651200000, + "code": "0", + "message": "ok", + "data": { } +} +``` + +Followed by `@@@@@@`. Use `success(data)` from `require('@axiosleo/koapp')` +or write raw data via `context.socket.write(str + '@@@@@@')`. + +## Broadcasting + +```javascript +// send to all active connections +app.broadcast({ event: 'price', value: 42.5 }, 'ok', 0, null); + +// send to a curated list of connection IDs +const someIds = ['id1', 'id2']; +const conns = someIds + .map((id) => app.getConnection(id)) + .filter(Boolean); +app.broadcast({ notice: 'shutdown' }, 'ok', 0, conns); +``` + +Pass `null` as the 4th argument to hit every active connection. Pass an +empty array `[]` and nothing is sent (the default). + +## Targeted send / close + +```javascript +app.sendByConnectionId('some-id', { hello: 'world' }, 'ok', 0); +app.closeByConnectionId('some-id'); +app.ping('some-id'); // sends { data: 'ping', message: 'ok', code: 0 } +``` + +`getConnection('some-id')` returns the raw `net.Socket` or `null`. + +## Ping heartbeat + +When `config.ping.open === true`, the app schedules a global broadcast every +`config.ping.interval` ms with payload `config.ping.data`. Leave it off +unless the protocol requires keep-alives - most TCP clients can manage +their own. + +## Connection events + +Use `app.event` (not `app` itself) for connection-level events: + +```javascript +app.event.on('connection', (socket) => { + console.log('new client from', socket.remoteAddress); +}); +app.event.on('listen', (port) => { + console.log('bound on', port); +}); +``` + +## Common pitfalls + +- Frames without the trailing `@@@@@@` are silently dropped and logged via `debug.log` +- Binary payloads need custom decoding (the default assumes utf-8 JSON) +- `app.connections` can grow if clients disconnect uncleanly - wire your own idle/timeout cleanup when needed (`connection.setTimeout(ms)`) +- `EADDRINUSE` is logged at debug level, not thrown - listen to server errors manually if you need to fail fast diff --git a/assets/skills/koapp-apps/websocket-server.md b/assets/skills/koapp-apps/websocket-server.md new file mode 100644 index 0000000..fbb646f --- /dev/null +++ b/assets/skills/koapp-apps/websocket-server.md @@ -0,0 +1,136 @@ +# WebSocketApplication (WebSocket) + +Source: [`src/apps/websocket.js`](../../../src/apps/websocket.js). + +`WebSocketApplication` **extends `SocketApplication`** and swaps the TCP +transport for a `ws` WebSocket server. All connection management methods +(`broadcast`, `send`, `close`, `sendByConnectionId`, +`closeByConnectionId`, `getConnection`, `ping`) are inherited and adapted +to WebSocket semantics. + +## Constructor + +```javascript +const { WebSocketApplication } = require('@axiosleo/koapp'); + +const app = new WebSocketApplication({ + port: 8081, + routers: [router], + app_id: 'my-ws-service', + ping: { + open: false, + interval: 1000 * 3, + data: 'this is a ping message' + } +}); +``` + +Any additional options are forwarded to `new WebSocketServer(opts)` from +the `ws` package (`noServer`, `server`, `path`, `perMessageDeflate`, etc.) - +the app strips `ping`, `routers`, `debug`, and `app_id` before passing. + +## Wire protocol + +Each inbound message is a plain JSON string - **no delimiter**: + +```json +{"path":"/chat/42","method":"GET","query":{"token":"abc"},"body":{"text":"hello"}} +``` + +Outbound frames are written via `ws.send(json)`. + +Browser client example: + +```javascript +const ws = new WebSocket('ws://localhost:8081'); +ws.onopen = () => { + ws.send(JSON.stringify({ + path: '/chat/42', + method: 'GET', + body: { text: 'hello' } + })); +}; +ws.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +## Starting the server + +```javascript +await app.start(); +``` + +`start()` spins up `new WebSocketServer(options)`, logs the listening port, +emits `listen`, and wires `connection` / `message` / `close` / `error` +handlers. + +## Context inside handlers + +| Property | Meaning | +| --- | --- | +| `context.app` | The `WebSocketApplication` instance | +| `context.socket` | The `ws` `WebSocket` instance | +| `context.connection_id` | Tracked ID inside `app.connections` | +| `context.method` | Uppercase, inferred from upgrade request or inbound `method` field | +| `context.pathinfo` | Parsed from the URL in the upgrade request | +| `context.query` | From the upgrade URL `searchParams` (NOT the inbound body) | +| `context.body` | Full parsed inbound JSON | +| `context.headers` | Upgrade request headers (`x-forwarded-proto`, cookies, auth) | + +Because `query` and `pathinfo` come from the **upgrade** request, each +WebSocket connection is bound to a single virtual route. Use `context.body` +for per-message data. + +## Broadcasting + +```javascript +// every active connection +app.broadcast({ kind: 'tick', now: Date.now() }, 'ok', 0, null); + +// curated subset +const conns = ['id1', 'id2'].map((id) => app.getConnection(id)).filter(Boolean); +app.broadcast({ hello: 'friends' }, 'ok', 0, conns); +``` + +Same argument shape as `SocketApplication.broadcast`. Internally: + +```javascript +connection.send(JSON.stringify(envelope)); +``` + +## Targeted send / close + +```javascript +app.sendByConnectionId('id1', { price: 99.9 }); +app.closeByConnectionId('id1'); // ws.close() +app.ping('id1'); // sends { data: 'ping', message: 'ok', code: 0 } +``` + +## TLS / reverse proxies + +When the app sits behind an HTTPS-terminating proxy, the dispatcher reads +`x-forwarded-proto: https` to switch the logical URL to `wss://`. Make sure +your reverse proxy forwards this header. + +## Sharing with an existing HTTP server + +Pass `noServer: true` or `server: httpServer` as part of the config to share +a port with an HTTP server. These options flow to `WebSocketServer` via +`app.websocketOptions`. + +```javascript +const http = require('http'); +const server = http.createServer(); +const app = new WebSocketApplication({ + server, + routers: [router] +}); +await app.start(); +server.listen(3000); +``` + +## Common pitfalls + +- `context.query` is not per-message - it reflects the URL used during the upgrade handshake +- Parsing errors (non-JSON messages) are silently logged via `debug.log`; drop ill-formed clients explicitly if needed +- Use `ping.open` for server-push keep-alives, or rely on `ws`'s built-in ping/pong at the protocol level +- When using a shared HTTP server, do **not** call `server.listen` before `app.start()` is called diff --git a/assets/skills/koapp-controller/SKILL.md b/assets/skills/koapp-controller/SKILL.md new file mode 100644 index 0000000..ad15d19 --- /dev/null +++ b/assets/skills/koapp-controller/SKILL.md @@ -0,0 +1,161 @@ +--- +name: koapp-controller +description: Organize route handlers into reusable classes by extending the @axiosleo/koapp Controller base class. Use when grouping related handlers, reusing response helpers (this.success/failed/result/response/error/log), sharing state across handlers, or scaffolding service modules for a koapp project. +--- + +# @axiosleo/koapp Controller + +Source: [`src/controller.js`](../../../src/controller.js). + +`Controller` is a thin base class that mirrors the +[response helpers](../koapp-response/SKILL.md) as instance methods. Extend +it to keep related handlers together and share auxiliary state. + +## Import + +```javascript +const { Controller } = require('@axiosleo/koapp'); +``` + +## Instance API + +| Method | Equivalent function | Notes | +| --- | --- | --- | +| `this.success(data?, headers?)` | `success(data, headers)` | 200 OK JSON envelope | +| `this.failed(data?, code?, status?, headers?)` | `failed(data, code, status, headers)` | Non-2xx JSON envelope | +| `this.result(data, status?, headers?)` | `result(data, status, headers)` | Raw body pass-through | +| `this.response(data, code, status?, headers?)` | `response(data, code, status, headers)` | Full control envelope | +| `this.error(status, msg, headers?)` | `error(status, msg, headers)` | Shortcut error envelope | +| `this.log(...data)` | `debug.log` from `@axiosleo/cli-tool` | Conditional logging | + +All `this.success / failed / result / response / error` throw, same as the +functional helpers. See **koapp-response** for envelope details. + +## Basic usage + +```javascript +const { Controller, Router } = require('@axiosleo/koapp'); + +class HealthController extends Controller { + async ping(context) { + this.log('ping from', context.request_id); + this.success({ pong: true }); + } + + async version() { + this.success({ version: require('../package.json').version }); + } +} + +const health = new HealthController(); +const router = new Router('/health'); +router.get('/ping', (ctx) => health.ping(ctx)); +router.get('/version', () => health.version()); +``` + +## Why use Controller over plain functions? + +- **Shared state**: load DB handles, caches, or config once in the + constructor and reuse across methods. +- **Inheritance**: define a `BaseController` with common middlewares or + helpers, then extend it per module (the `init` scaffolding template at + [`assets/tmpl/services/src/modules/controller.ts.tmpl`](../../tmpl/services/src/modules/controller.ts.tmpl) does this). +- **Testability**: easier to mock a controller instance than free-floating + functions. + +## Binding handlers to routes + +`Controller` methods expect a `context` argument and may use `this`, so bind +them explicitly when registering with the router: + +```javascript +router.get('/users/{:id}', (ctx) => controller.find(ctx)); +router.post('/users', (ctx) => controller.create(ctx)); + +// or with arrow methods +class UsersController extends Controller { + find = async (ctx) => { + const id = Number(ctx.params.id); + this.success({ id }); + }; +} +``` + +Pure wrappers `(ctx) => controller.method(ctx)` preserve `this` and avoid +`.bind` clutter. + +## Error handling + +Since `this.success / failed / ...` throw, any code after them does not +run. The workflow catches the throw and writes the response. Keep try / +catch only where you intend to shape a different error: + +```javascript +async create(context) { + try { + const user = await this.db.users.insert(context.body); + this.success({ id: user.id }); + } catch (err) { + if (err.code === 'DUP_ENTRY') { + this.failed({ email: context.body.email }, '409;Email Already Registered', 409); + } + throw err; // let the workflow convert to 500 + } +} +``` + +## Logging + +`this.log` is wired to `debug.log` from `@axiosleo/cli-tool`. Enable it by +running with `DEBUG=1` (or the cli-tool equivalent), or by setting +`debug: true` on the application. + +```javascript +this.log('loading user', context.params.id); +``` + +## Composing with validators + +Validation stays on the router: + +```javascript +router.push('post', '/users', (ctx) => controller.create(ctx), { + body: { + rules: { + name: 'required|string', + email: 'required|email' + } + } +}); +``` + +The validator runs before the handler, so by the time `controller.create` +executes you already have a validated `context.body`. + +## Generator output + +Running `npx @axiosleo/koapp gen -d ./meta -o ./services/src/modules` +produces `.controller.ts` files that extend a project-local +`BaseController`. That `BaseController` typically extends `Controller` and +adds database handles: + +```typescript +import { Controller } from '@axiosleo/koapp'; +import { mainDB } from '../services/db'; + +export class BaseController extends Controller { + protected mainDB = mainDB; +} +``` + +## Common pitfalls + +- Forgetting to pass `context` into instance methods: `router.get('/x', controller.handler)` loses `this` when the router calls the method as a free function. Use arrow wrappers. +- Returning `this.success(...)` from a handler is redundant - the throw short-circuits execution. +- Controllers are long-lived across requests; never stash per-request data on `this`. Use `context` or local variables instead. + +## See also + +- Response helper details: **koapp-response** +- Grouping controllers under a router tree: **koapp-router** +- Concrete controller recipes: [examples.md](examples.md) diff --git a/assets/skills/koapp-controller/examples.md b/assets/skills/koapp-controller/examples.md new file mode 100644 index 0000000..8463abe --- /dev/null +++ b/assets/skills/koapp-controller/examples.md @@ -0,0 +1,212 @@ +# Controller Examples + +## Minimal controller + +```javascript +const { Controller, Router, KoaApplication } = require('@axiosleo/koapp'); + +class HelloController extends Controller { + async sayHi(context) { + const name = context.query.name || 'world'; + this.success({ message: `Hello, ${name}!` }); + } +} + +const hello = new HelloController(); +const router = new Router('/hello'); +router.get('/', (ctx) => hello.sayHi(ctx)); + +const app = new KoaApplication({ port: 8088, routers: [router] }); +app.start(); +``` + +## Shared state via constructor + +```javascript +class UsersController extends Controller { + constructor(db) { + super(); + this.db = db; + } + + async find(context) { + const id = Number(context.params.id); + if (!Number.isInteger(id)) this.error(400, 'Invalid id'); + const user = await this.db.findUser(id); + if (!user) this.error(404, 'User Not Found'); + this.success(user); + } + + async create(context) { + const user = await this.db.createUser(context.body); + this.success({ id: user.id }); + } + + async remove(context) { + const id = Number(context.params.id); + await this.db.deleteUser(id); + this.success({ removed: id }); + } +} + +const controller = new UsersController(myDb); +const router = new Router('/users'); +router.get('/{:id}', (ctx) => controller.find(ctx)); +router.post('/', (ctx) => controller.create(ctx)); +router.delete('/{:id}', (ctx) => controller.remove(ctx)); +``` + +## Base controller with project-wide helpers + +```javascript +const { Controller } = require('@axiosleo/koapp'); +const { mainDB } = require('../services/db'); + +class BaseController extends Controller { + constructor() { + super(); + this.mainDB = mainDB; + } + + ensureAuth(context) { + const user = context.koa.session.user; + if (!user) { + this.error(401, 'Please log in'); + } + return user; + } +} + +class PostsController extends BaseController { + async publish(context) { + const user = this.ensureAuth(context); + const post = await this.mainDB.table('posts').insert({ + author_id: user.id, + title: context.body.title, + body: context.body.body + }); + this.success({ id: post.insertId }); + } +} +``` + +## CRUD controller + validators + +```javascript +class ProductsController extends Controller { + async find(context) { + const id = Number(context.params.id); + const item = await db.table('products').where('id', id).find(); + if (!item) this.error(404, 'Not Found'); + this.success(item); + } + + async create(context) { + const res = await db.table('products').insert(context.body); + res.insertId + ? this.success({ id: res.insertId }) + : this.failed(context.body, '500;Create Failed', 500); + } + + async update(context) { + const id = Number(context.params.id); + const res = await db.table('products').where('id', id).update(context.body); + res.affectedRows || res.changedRows + ? this.success({ updated: id }) + : this.failed({ id }, '500;Update Failed', 500); + } + + async remove(context) { + const id = Number(context.params.id); + const res = await db.table('products').where('id', id).delete(); + res.affectedRows + ? this.success({ removed: id }) + : this.failed({ id }, '500;Delete Failed', 500); + } +} + +const products = new ProductsController(); +const router = new Router('/products'); + +router.get('/{:id}', (ctx) => products.find(ctx), { + params: { rules: { id: 'required|integer' } } +}); +router.post('/', (ctx) => products.create(ctx), { + body: { + rules: { + name: 'required|string', + price: 'required|numeric|min:0' + } + } +}); +router.put('/{:id}', (ctx) => products.update(ctx), { + params: { rules: { id: 'required|integer' } } +}); +router.delete('/{:id}', (ctx) => products.remove(ctx), { + params: { rules: { id: 'required|integer' } } +}); +``` + +## Logging through this.log + +```javascript +class OrdersController extends Controller { + async place(context) { + this.log('placing order', context.body); + const order = await submitOrder(context.body); + this.log('order id', order.id); + this.success({ id: order.id }); + } +} +``` + +Enable debug output by launching the app with `debug: true` or the env +variable expected by `@axiosleo/cli-tool`'s `debug.log`. + +## Sharing a controller across HTTP and Socket + +A controller does not care about the transport; as long as the context has +the fields you need, the same method works: + +```javascript +class ChatController extends Controller { + async send(context) { + const { body } = context; + await saveMessage(body); + context.app.broadcast({ from: context.connection_id, body }, 'chat', 0, null); + this.success({ sent: true }); + } +} + +const chat = new ChatController(); + +// HTTP mount +httpRouter.post('/chat', (ctx) => chat.send(ctx)); + +// WebSocket mount +wsRouter.any('/chat/{:room}', (ctx) => chat.send(ctx)); +``` + +## Testing a controller in isolation + +```javascript +const { expect } = require('chai'); +const { HttpResponse } = require('@axiosleo/koapp'); + +it('returns 200 with payload', async () => { + const controller = new HelloController(); + const fakeContext = { query: { name: 'Alice' } }; + try { + await controller.sayHi(fakeContext); + } catch (err) { + expect(err).to.be.instanceOf(HttpResponse); + expect(err.status).to.equal(200); + expect(err.data.message).to.equal('Hello, Alice!'); + return; + } + throw new Error('expected throw'); +}); +``` + +Because helpers throw, use `try/catch` in unit tests and inspect the +`HttpResponse` instance. diff --git a/assets/skills/koapp-model/SKILL.md b/assets/skills/koapp-model/SKILL.md new file mode 100644 index 0000000..be08e8a --- /dev/null +++ b/assets/skills/koapp-model/SKILL.md @@ -0,0 +1,192 @@ +--- +name: koapp-model +description: Validate and serialize structured data with the @axiosleo/koapp Model class powered by validatorjs. Use when creating typed payload objects, running rules like required/integer/email/min/max, converting between JSON and plain objects, listing properties, or automatically raising HttpError(400) on validation failure inside a koapp handler. +--- + +# @axiosleo/koapp Model + +Source: [`src/model.js`](../../../src/model.js). + +`Model` is a lightweight data container around +[validatorjs](https://github.com/mikeerickson/validatorjs). It assigns +incoming fields onto itself, optionally validates them, and provides +utility methods for serialization. + +## Import + +```javascript +const { Model } = require('@axiosleo/koapp'); +``` + +## Constructor + +```javascript +new Model(obj?, rules?, messages?); +``` + +| Arg | Type | Purpose | +| --- | --- | --- | +| `obj` | `object` | Source data; each key is copied onto the instance via `Object.assign` | +| `rules` | `Rules` | validatorjs rule set, e.g. `{ name: 'required|string' }` | +| `messages` | `ErrorMessages` | Custom error messages keyed by rule, e.g. `{ required: 'The :attribute is required.' }` | + +When `rules` are provided and validation fails, the constructor throws +`HttpError(400, )`. Inside a koapp handler that +translates to a standard 400 response without any extra plumbing. + +## Static helper + +```javascript +Model.create(obj, rules, messages); +``` + +Shortcut for `new Model(obj, rules, messages)` that benefits from subclass +context (same as `new this(...)`). + +## Instance methods + +| Method | Returns | Notes | +| --- | --- | --- | +| `toJson()` | `string` | `JSON.stringify(this)` | +| `toObj()` | `object` | `JSON.parse(this.toJson())`; strips class identity | +| `properties()` | `string[]` | Enumerable own keys | +| `count()` | `number` | `properties().length` | +| `validate(rules, messages?)` | `Validator` | Runs validatorjs manually and returns the raw Validator object | + +`validate()` does not throw - use it when you need the full error map: + +```javascript +const m = new Model({ email: 'not-an-email' }); +const v = m.validate({ email: 'required|email' }); +if (v.fails()) { + console.log(v.errors.all()); // { email: ['The email format is invalid.'] } +} +``` + +## Extending Model + +```javascript +class UserModel extends Model { + constructor(data) { + super(data, { + name: 'required|string', + email: 'required|email', + age: 'integer|min:0' + }, { + required: 'The :attribute field is required.' + }); + } + + greet() { + return `Hi, ${this.name}`; + } +} + +const user = new UserModel(context.body); +// If the body fails validation, HttpError(400, ...) is thrown automatically. +``` + +When `user` is created successfully, `user.name`, `user.email`, `user.age` +are all available, plus any custom methods. + +## Validation rules cheat sheet + +Common rules from validatorjs: + +``` +required - present and non-empty +string - typeof === 'string' +integer - integer number (accepts strings that parse) +numeric - finite number (accepts strings) +boolean - true/false/0/1/'0'/'1'/'true'/'false' +email - RFC-style email +url - absolute URL +in:a,b,c - value in enumerated list +not_in:a,b,c - value not in list +min:N - string length / number value ≥ N +max:N - string length / number value ≤ N +between:a,b - inclusive range +size:N - exact length / value +regex:/pat/ - matches regex +array - is an array +alpha - letters only +alpha_num - letters or digits +alpha_dash - letters, digits, _, - +required_if:field,value +required_unless:field,value +confirmed - pairs with `_confirmation` +date - parseable date +``` + +Combine with `|`: `required|string|min:3|max:64`. + +## Nesting Models + +`Model` instances serialize via JSON, so nesting works naturally: + +```javascript +const address = new Model({ + street: '1 Main St', + city: 'Springfield' +}); + +const user = new Model({ + name: 'Alice', + address +}); + +console.log(user.toObj()); +// { name: 'Alice', address: { street: '1 Main St', city: 'Springfield' } } +console.log(user.properties()); // ['name', 'address'] +console.log(user.count()); // 2 +``` + +Note that `toObj()` returns plain objects - class identity is stripped, +so `user.toObj().address instanceof Model === false`. + +## Validating without Model + +If you only need to validate a payload once without persisting an +instance, `Model.create(data, rules)` and discard the return value: + +```javascript +router.post('/login', async (context) => { + Model.create(context.body, { + email: 'required|email', + password: 'required|min:8' + }); + // Reaches here only if validation passes + const { success } = require('@axiosleo/koapp'); + success({ ok: true }); +}); +``` + +## Comparison with router-level validators + +| Router validators | Model validation | +| --- | --- | +| Declarative, lives next to the route | Imperative, lives in code | +| Fields split into `params` / `query` / `body` | Arbitrary object shape | +| Runs automatically before the handler | Runs where you call it | +| Limited to the request payload | Can be reused for DB rows, intermediate data, etc. | + +Use router validators for request shape enforcement, and `Model` when you +need to pass the validated data around or serialize it. + +## Common pitfalls + +- Validators that look at missing fields (e.g. `integer`) still pass when + the field is absent unless paired with `required`. +- The 400 message is the **first** failing rule's message; the others are + discarded. Use `model.validate(...)` manually if you need all errors. +- `Object.assign` (used in the constructor) copies own enumerable + properties; prototype getters/setters on `obj` are not preserved. +- `toObj()` and `toJson()` include **all** own properties. If you want to + hide fields, delete them before serialization or implement a custom + `toJSON()`. + +## See also + +- Response helpers used after a successful `Model.create`: **koapp-response** +- Validating fields declaratively on the route: **koapp-router** +- Concrete model recipes: [examples.md](examples.md) diff --git a/assets/skills/koapp-model/examples.md b/assets/skills/koapp-model/examples.md new file mode 100644 index 0000000..700c8d8 --- /dev/null +++ b/assets/skills/koapp-model/examples.md @@ -0,0 +1,176 @@ +# Model Examples + +## Simple validated model + +```javascript +const { Model } = require('@axiosleo/koapp'); + +const user = new Model({ + name: 'Alice', + email: 'alice@example.com' +}, { + name: 'required|string', + email: 'required|email' +}); + +console.log(user.name); // 'Alice' +console.log(user.toObj()); // { name: 'Alice', email: 'alice@example.com' } +console.log(user.toJson()); // '{"name":"Alice","email":"alice@example.com"}' +console.log(user.properties()); // ['name', 'email'] +console.log(user.count()); // 2 +``` + +## Validation failure short-circuits + +```javascript +try { + new Model({}, { email: 'required|email' }); +} catch (err) { + console.log(err.name); // 'Error' (HttpError class) + console.log(err.status); // 400 + console.log(err.message); // 'The email field is required.' +} +``` + +Inside a router handler: + +```javascript +router.post('/sign-up', async (context) => { + Model.create(context.body, { + email: 'required|email', + password: 'required|min:8' + }); + // Only runs if rules pass; otherwise the framework returns 400 automatically. + const { success } = require('@axiosleo/koapp'); + success({ ok: true }); +}); +``` + +## Custom messages + +```javascript +Model.create(context.body, { + username: 'required|alpha_num|min:3|max:16' +}, { + required: 'Please choose a :attribute.', + 'min.string': ':attribute must be at least :min characters.' +}); +``` + +## Subclassing for domain logic + +```javascript +class ProductModel extends Model { + constructor(data) { + super(data, { + name: 'required|string|min:1|max:128', + price: 'required|numeric|min:0', + currency: 'required|in:USD,EUR,GBP,CNY' + }); + } + + get priceLabel() { + return `${this.price.toFixed(2)} ${this.currency}`; + } +} + +router.post('/products', async (context) => { + const product = new ProductModel(context.body); + await db.table('products').insert(product.toObj()); + const { success } = require('@axiosleo/koapp'); + success({ label: product.priceLabel }); +}); +``` + +## Manual validation without throwing + +```javascript +const m = new Model({ email: 'nope', age: 'old' }); + +const v = m.validate({ + email: 'required|email', + age: 'integer|min:0' +}); + +if (v.fails()) { + const errors = v.errors.all(); + // { email: [...], age: [...] } + const { failed } = require('@axiosleo/koapp'); + failed({ errors }, '400;Bad Data', 400); +} +``` + +Use this when you need every failure (not just the first). + +## Nesting + +```javascript +const addr = new Model({ city: 'Springfield', zip: '12345' }); +const user = new Model({ name: 'Alice', addr }); + +console.log(user.toObj()); +// { name: 'Alice', addr: { city: 'Springfield', zip: '12345' } } +``` + +`toObj()` flattens nested models into plain objects. + +## Reusing rules across requests + +```javascript +const USER_RULES = { + name: 'required|string|min:1|max:64', + email: 'required|email', + role: 'in:admin,user,guest' +}; + +router.post('/users', async (context) => { + Model.create(context.body, USER_RULES); + const { success } = require('@axiosleo/koapp'); + success({ ok: true }); +}); + +router.put('/users/{:id}', async (context) => { + Model.create(context.body, USER_RULES); + const { success } = require('@axiosleo/koapp'); + success({ ok: true }); +}); +``` + +Factor rule constants out so POST and PUT share the same schema. + +## Property introspection + +```javascript +const m = new Model({ name: 'Alice', email: 'a@b.com' }); + +m.properties().forEach((key) => { + console.log(key, '=', m[key]); +}); +// name = Alice +// email = a@b.com + +console.log(m.count()); // 2 +``` + +Useful when you want to iterate over fields without knowing them ahead +of time (e.g. for dynamic form builders). + +## Hiding fields before serialization + +```javascript +class SafeUserModel extends Model { + constructor(data) { + super(data, { email: 'required|email', password: 'required|min:8' }); + } + + toObj() { + const raw = super.toObj(); + delete raw.password; + return raw; + } +} + +const { success } = require('@axiosleo/koapp'); +const u = new SafeUserModel(context.body); +success(u.toObj()); // password stripped +``` diff --git a/assets/skills/koapp-response/SKILL.md b/assets/skills/koapp-response/SKILL.md new file mode 100644 index 0000000..de161f8 --- /dev/null +++ b/assets/skills/koapp-response/SKILL.md @@ -0,0 +1,209 @@ +--- +name: koapp-response +description: Send structured responses from @axiosleo/koapp handlers using success/failed/result/response/error helpers and HttpError/HttpResponse classes. Use when returning JSON envelopes, custom HTTP status codes, raw HTML/text, error responses, or when setting response headers from a koapp route handler or controller method. +--- + +# @axiosleo/koapp Response Helpers + +Source: [`src/response.js`](../../../src/response.js). + +`@axiosleo/koapp` uses **throw-based response helpers**: you call one of the +helpers and it throws a typed object that the framework's workflow catches +and writes to the wire. This keeps route handlers linear (no need to +`return`) and lets middlewares/afters run consistently. + +## Import + +```javascript +const { + success, + failed, + result, + response, + error, + HttpError, + HttpResponse +} = require('@axiosleo/koapp'); +``` + +## Helper reference + +| Helper | Signature | Purpose | +| --- | --- | --- | +| `success(data?, headers?)` | `(data: any, headers: Record) => never` | 200 OK, JSON envelope with `code: '200;Success'` | +| `failed(data?, code?, status?, headers?)` | `(data: any, code?: string, status?: number, headers?) => never` | Non-2xx with JSON envelope; defaults `code: '501;Internal Server Error'`, `status: 501` | +| `response(data, code?, status?, headers?, format?)` | Full control JSON envelope | Explicit `code`, `status`, `headers`, `format` (`'json' | 'text'`) | +| `result(data, status?, headers?)` | Raw body pass-through | Bypass the JSON envelope and write `data` as-is | +| `error(status, msg, headers?)` | Error with empty payload | Shortcut for `response({}, ';', status, headers)` | + +All helpers **never return** - they throw. + +## JSON envelope + +`success`, `failed`, `response`, and `error` produce the framework's +standard envelope: + +```json +{ + "request_id": "uuid", + "timestamp": 1714651200000, + "code": "200;Success", + "message": "Success", + "data": { } +} +``` + +`code` follows the `";"` convention: + +- `"200;Success"` +- `"400;Bad Request"` +- `"404;Not Found"` +- `"500;Internal Server Error"` + +Both parts are mirrored into `data` (so `message` is still readable when +clients only unpack `code`). Custom codes are free-form strings. + +## Raw pass-through with `result()` + +Use `result()` when you need to emit HTML, plain text, binary, or a pre-built +JSON body without the envelope wrapping: + +```javascript +const { result } = require('@axiosleo/koapp'); + +router.get('/page', async () => { + result('

Hello

', 200, { 'Content-Type': 'text/html' }); +}); + +router.get('/raw-json', async () => { + result(JSON.stringify({ hello: 'world' }), 200, { + 'Content-Type': 'application/json' + }); +}); +``` + +`result()` sets `notResolve: true` internally so the envelope is skipped. + +## Error helpers + +```javascript +const { failed, error } = require('@axiosleo/koapp'); + +router.post('/login', async (context) => { + const user = await findUser(context.body.email); + if (!user) { + error(404, 'User Not Found'); + } + if (!verify(context.body.password, user.password)) { + failed({ email: context.body.email }, '401;Unauthorized', 401); + } + // ... success response ... +}); +``` + +## HttpError for validation / system errors + +`HttpError` is primarily used internally (e.g. by `Model` when validation +fails), but handlers may throw it to short-circuit with a plain error +body: + +```javascript +const { HttpError } = require('@axiosleo/koapp'); + +router.get('/guarded', async (context) => { + if (!context.koa.session.user) { + throw new HttpError(401, 'Please log in', { 'WWW-Authenticate': 'Bearer' }); + } +}); +``` + +The framework catches it in the workflow and writes +`status=401`, the message, and any headers. + +## HttpResponse class + +`HttpResponse` is the class behind all helpers. You normally won't +instantiate it directly, but it's useful to know its shape: + +```javascript +new HttpResponse({ + status: 200, + data: { ... }, + code: '200;Success', + headers: { 'X-Whatever': '1' }, + format: 'json', // or 'text' + notResolve: false // if true, skip the envelope +}); +``` + +## Status code presets (TypeScript) + +The type definitions in `index.d.ts` list convenient string literals: + +``` +'200;Success' +'400;Bad Data' +'400;Invalid Signature' +'401;Unauthorized' +'403;Not Authorized' +'404;Not Found' +'409;Data Already Exists' +'500;Internal Server Error' +'501;Failed' +'000;Unknown Error' +``` + +Any `";"` string is valid - the presets are just common ones. + +## Setting headers per response + +Every helper accepts a `headers` argument: + +```javascript +success({ ok: true }, { + 'X-Request-Source': 'public-api', + 'Cache-Control': 'no-store' +}); + +result(buffer, 200, { + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename="report.pdf"' +}); +``` + +## Direct Koa response + +Inside an `HTTP` handler you can still write to `context.koa` directly when +you need streaming, redirects, or pipelining: + +```javascript +router.get('/redirect', async (context) => { + context.koa.redirect('/elsewhere'); +}); + +router.get('/stream', async (context) => { + context.koa.type = 'application/octet-stream'; + context.koa.body = fs.createReadStream(file); +}); +``` + +Mixing raw Koa output with the throw helpers: the **last** to write wins. +If you need raw streaming, skip the helpers entirely. + +## Common pitfalls + +- Forgetting `success/failed/result/...` throws, leading to code that runs + after the "response" line. Treat them as terminal. +- Calling `success` in a middleware (not a handler) stops execution just + like in a handler - the workflow still catches and emits the response. +- `failed(data, status)` does **not** work - the second arg is `code`, not + `status`. Use `failed(data, '400;Bad Data', 400)` or switch to + `response(data, '400;Bad Data', 400)`. +- Returning `success(...)` from a handler is redundant (it throws) and + confusing to readers - do not `return` helper calls. + +## See also + +- Organize handlers into classes with wrapped helpers: **koapp-controller** +- Validate body/params before responding: **koapp-router** +- Concrete response recipes: [examples.md](examples.md) diff --git a/assets/skills/koapp-response/examples.md b/assets/skills/koapp-response/examples.md new file mode 100644 index 0000000..dcff8bb --- /dev/null +++ b/assets/skills/koapp-response/examples.md @@ -0,0 +1,197 @@ +# Response Examples + +## Success with data + +```javascript +const { success } = require('@axiosleo/koapp'); + +router.get('/users/{:id}', async (context) => { + success({ id: context.params.id, name: 'Alice' }); +}); +``` + +Wire output: + +```json +{ + "request_id": "...", + "timestamp": 1714651200000, + "code": "200;Success", + "message": "Success", + "data": { "id": "1", "name": "Alice" } +} +``` + +## Success with custom headers + +```javascript +success({ ok: true }, { + 'X-Correlation-Id': context.request_id, + 'Cache-Control': 'no-store' +}); +``` + +## Failure with explicit status and code + +```javascript +const { failed } = require('@axiosleo/koapp'); + +router.post('/login', async (context) => { + const user = await db.users.findByEmail(context.body.email); + if (!user) { + failed({ email: context.body.email }, '404;User Not Found', 404); + } + if (!verify(context.body.password, user.password)) { + failed({ email: context.body.email }, '401;Unauthorized', 401, { + 'WWW-Authenticate': 'Bearer realm="api"' + }); + } + success({ token: signJwt(user) }); +}); +``` + +## error() shortcut + +```javascript +const { error } = require('@axiosleo/koapp'); + +router.get('/teapot', async () => { + error(418, "I'm a teapot"); +}); +``` + +Emits: + +```json +{ + "code": "418;I'm a teapot", + "message": "I'm a teapot", + "data": {} +} +``` + +## Raw HTML page + +```javascript +const { result } = require('@axiosleo/koapp'); + +router.get('/', async () => { + const html = ` + + Hi +

Hello, world!

+ `; + result(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); +}); +``` + +## Raw JSON without the envelope + +```javascript +const { result } = require('@axiosleo/koapp'); + +router.get('/proxy/echo', async (context) => { + result(JSON.stringify({ query: context.query, body: context.body }), 200, { + 'Content-Type': 'application/json' + }); +}); +``` + +## File download + +```javascript +const fs = require('fs'); +const path = require('path'); +const stat = require('util').promisify(fs.stat); + +router.get('/files/{:name}', async (context) => { + const filePath = path.resolve('./files', context.params.name); + const info = await stat(filePath); + context.koa.type = path.extname(filePath); + context.koa.set('Content-Length', String(info.size)); + context.koa.set( + 'Content-Disposition', + 'attachment; filename=' + encodeURIComponent(context.params.name) + ); + context.koa.body = fs.createReadStream(filePath); +}); +``` + +The helpers are not used here because we stream straight to Koa. + +## response() for full control + +```javascript +const { response } = require('@axiosleo/koapp'); + +router.post('/batch', async (context) => { + const { accepted, rejected } = await enqueue(context.body); + response( + { accepted, rejected }, + '202;Accepted', + 202, + { 'X-Job-Count': String(accepted.length) }, + 'json' + ); +}); +``` + +## Throwing HttpError + +```javascript +const { HttpError } = require('@axiosleo/koapp'); + +router.get('/secret', async (context) => { + if (!context.koa.session.user) { + throw new HttpError(401, 'Please log in'); + } + const { success } = require('@axiosleo/koapp'); + success({ secret: 'shhh' }); +}); +``` + +The framework catches the thrown `HttpError` and writes +`status=401`, `message='Please log in'`. + +## Validation-triggered errors + +`Model` throws `HttpError(400, msg)` when rules fail, so using +`Model.create(data, rules)` inside a handler automatically yields a 400 +response without any explicit `failed(...)` call: + +```javascript +const { Model } = require('@axiosleo/koapp'); + +router.post('/sign-up', async (context) => { + Model.create(context.body, { + email: 'required|email', + password: 'required|min:8' + }); + const { success } = require('@axiosleo/koapp'); + success({ ok: true }); +}); +``` + +See **koapp-model** for more. + +## Redirects + +```javascript +router.get('/legacy', async (context) => { + context.koa.redirect('/new-location'); +}); +``` + +Koa's redirect sets `Location` header and HTTP 302. The framework's +workflow will not overwrite the status once you write to `context.koa` +directly. + +## Cookies via Koa + +```javascript +router.get('/set-cookie', async (context) => { + context.koa.cookies.set('remember_me', '1', { maxAge: 86400000 }); + const { success } = require('@axiosleo/koapp'); + success({ ok: true }); +}); +``` diff --git a/assets/skills/koapp-router/SKILL.md b/assets/skills/koapp-router/SKILL.md new file mode 100644 index 0000000..c16a3d2 --- /dev/null +++ b/assets/skills/koapp-router/SKILL.md @@ -0,0 +1,210 @@ +--- +name: koapp-router +description: Define routes, path parameters, validators, and nested routers with the @axiosleo/koapp Router class. Use when declaring HTTP/WebSocket/TCP routes in koapp, adding path params like /users/{:id}, composing nested routers, attaching per-route middlewares or after-handlers, validating params/query/body with validatorjs rules, or using shortcut helpers (get/post/put/patch/delete/any). +--- + +# @axiosleo/koapp Router + +Source: [`src/router.js`](../../../src/router.js). + +`Router` is the single route-definition primitive for all three application +types (`KoaApplication`, `SocketApplication`, `WebSocketApplication`). A +router can be flat or a tree of sub-routers, and it carries middlewares, +handlers, after-handlers, and validators. + +## Constructor + +```javascript +const { Router } = require('@axiosleo/koapp'); + +const router = new Router(prefix, options); +``` + +| Arg | Type | Default | Notes | +| --- | --- | --- | --- | +| `prefix` | `string` | `''` | Path prefix like `/api/v1` or `/users/{:id}` | +| `options.method` | `string` | `''` | HTTP method. Use `'any'` for all methods. Empty string disables the route | +| `options.handlers` | `Function[]` | `[]` | Async handler functions `async (context) => { ... }` | +| `options.middlewares` | `Function[]` | `[]` | Run before handlers | +| `options.afters` | `Function[]` | `[]` | Run after handlers (even on thrown response) | +| `options.validators` | `object` | `{}` | `{ params, query, body }` - each `{ rules, messages? }` | + +Any unknown keys on `options` are kept on the router instance as-is. + +## Path params + +Use `{:name}` for path parameters. Legacy `:name` also works but `{:name}` +is preferred: + +```javascript +new Router('/users/{:id}/posts/{:postId}', { + method: 'GET', + handlers: [async (context) => { + const { id, postId } = context.params; + }] +}); +``` + +Wildcards: + +- `/*` - matches one segment (captured as `params.` if named) +- `/**` - matches one segment without capture +- `/***` - matches any remaining path, ideal for fallback handlers + +## HTTP method shortcuts + +Each method shortcut creates a nested sub-router: + +```javascript +const root = new Router('/api'); + +root.get('/users', listUsers); +root.post('/users', createUser, { + body: { rules: { name: 'required|string', email: 'required|email' } } +}); +root.put('/users/{:id}', updateUser); +root.patch('/users/{:id}', patchUser); +root.delete('/users/{:id}', deleteUser); +root.any('/health', healthcheck); +``` + +Under the hood every shortcut delegates to `router.push(method, prefix, handler, validator)`. + +## Custom methods + +```javascript +root.push('copy', '/resources/{:id}', async (context) => { ... }); +``` + +The framework uppercases and stores the method; match it by sending the +same method string. + +## Combining multiple methods + +```javascript +root.push('get|post', '/multi', async (context) => { + // handles both GET and POST +}); +``` + +## Nested routers + +```javascript +const api = new Router('/api'); +const v1 = new Router('/v1'); +const users = new Router('/users'); + +users.get('/{:id}', async (context) => { ... }); + +v1.add(users); // /api/v1/users/{:id} +api.add(v1); +``` + +`router.add(...subrouters)` attaches other Router instances. If the first +argument is a string prefix, sub-routers are added under it: + +```javascript +api.add('/admin', adminUserRouter, adminRoleRouter); +``` + +The helper `router.new(prefix, options)` creates and mounts a sub-router +in one call: + +```javascript +api.new('/internal', { middlewares: [authMiddleware] }); +``` + +## Middlewares and afters + +```javascript +const root = new Router(null, { + middlewares: [ + async (context) => { + console.log(`[${context.method}] ${context.pathinfo}`); + } + ], + afters: [ + async (context) => { + console.log('response:', context.response); + } + ] +}); +``` + +- Middlewares inherit down the tree (children + grandchildren see them) +- Afters also inherit and run after **any** handler in the subtree +- Throwing inside a handler still runs afters + +## Request validation + +Validation uses [validatorjs](https://github.com/mikeerickson/validatorjs) rules. + +```javascript +root.new('/items/{:id}', { + method: 'PUT', + handlers: [updateItem], + validators: { + params: { + rules: { id: 'required|integer' } + }, + query: { + rules: { include: 'in:profile,settings' } + }, + body: { + rules: { + name: 'required|string', + price: 'required|numeric|min:0' + }, + messages: { + required: 'The :attribute field is required.' + } + } + } +}); +``` + +On failure the framework throws `HttpError(400, )` +which the workflow converts into a `400` response. + +Common validatorjs rules: `required`, `string`, `integer`, `numeric`, `email`, +`url`, `min:N`, `max:N`, `in:a,b,c`, `regex:/pattern/`, `boolean`, `array`. + +## The catch-all fallback + +To handle anything that did not match: + +```javascript +root.any('/***', async (context) => { + result('Not Found', 404, { 'Content-Type': 'text/plain' }); +}); +``` + +`'/***'` matches any remaining path. The framework falls back to the +deepest matching `/***` route when no exact match is found. + +## Method resolution order + +For a request `PUT /users/42`: + +1. Walk the router tree by path segments, preferring static matches over `*` / `**` / `***` +2. When multiple candidates remain, prefer the one whose `method` list includes the request method (or `ANY`) +3. If nothing matches, follow the nearest `/***` fallback +4. If even that misses, the framework lets the next Koa middleware run (typically 404) + +## Common pitfalls + +- Leaving `method: ''` (or unset) on a `router.push` creates a route that + matches no HTTP method - requests fall through to fallback handlers. + Always use `'any'` or a concrete method unless you know you want this. +- `new Router('/')` and `new Router('')` behave differently; prefer `null` + or `''` for the root. +- Path params must use `{:name}` inside route segments - `{:name}` **must + be the entire segment**, not a substring (`/a-{:x}` does not work). +- Validators bubble with the router; nested routers inherit parent middlewares + and afters, so avoid double-registering auth middlewares. + +## See also + +- Richer, copy-paste examples: [examples.md](examples.md) +- Building a complete HTTP server around the router: **koapp-apps** +- Sending responses from handlers: **koapp-response** diff --git a/assets/skills/koapp-router/examples.md b/assets/skills/koapp-router/examples.md new file mode 100644 index 0000000..202e1f0 --- /dev/null +++ b/assets/skills/koapp-router/examples.md @@ -0,0 +1,185 @@ +# Router Examples + +## Basic REST routes + +```javascript +const { Router, success, failed } = require('@axiosleo/koapp'); + +const router = new Router('/api'); + +router.get('/hello', async () => { + success({ message: 'Hello, world!' }); +}); + +router.post('/users', async (context) => { + success({ created: context.body }); +}, { + body: { + rules: { + name: 'required|string', + email: 'required|email' + } + } +}); + +router.put('/users/{:id}', async (context) => { + success({ id: Number(context.params.id), body: context.body }); +}); + +router.delete('/users/{:id}', async (context) => { + success({ deleted: context.params.id }); +}); + +router.any('/multi', async (context) => { + success({ method: context.method }); +}); +``` + +## Path parameters + +```javascript +router.get('/a/{:a}/b/{:b}', async (context) => { + success({ params: context.params }); +}); + +router.get('/users/{:id}/posts/{:postId}', async (context) => { + const { id, postId } = context.params; + success({ userId: id, postId }); +}); +``` + +## Multi-method match + +```javascript +router.push('get|post', '/search', async (context) => { + const term = context.query.q || context.body.q; + success({ hits: [], term }); +}); +``` + +## Nested routers and shared middlewares + +```javascript +const root = new Router(null, { + middlewares: [ + async (context) => { + console.log(`[${context.method}] ${context.pathinfo}`); + } + ], + afters: [ + async (context) => { + console.log('response:', context.response); + } + ] +}); + +const users = new Router('/users'); +users.get('/{:id}', async (context) => success({ id: context.params.id })); +users.post('/', async (context) => success({ created: context.body })); + +const posts = new Router('/posts'); +posts.get('/', async () => success({ list: [] })); + +const api = new Router('/api/v1'); +api.add(users); +api.add(posts); + +root.add(api); +``` + +Request flow for `GET /api/v1/users/42`: + +1. `root.middlewares` log the hit +2. `api`, `users` middlewares (none here) +3. `users` handler returns `success({ id: '42' })` +4. `root.afters` log the response + +## Validators with custom messages + +```javascript +router.push('post', '/validate/{:param1}/{:param2}', async (context) => { + success({ + params: context.params, + query: context.query, + body: context.body + }); +}, { + params: { + rules: { param1: 'required', param2: 'required' } + }, + query: { + rules: { a: 'required', b: 'integer' } + }, + body: { + rules: { bodyA: 'required', bodyB: 'integer' }, + messages: { required: 'The :attribute field is required.' } + } +}); +``` + +A bad request like `POST /validate/x/y?a=hi&b=not-int` with +`{"bodyA":"","bodyB":"oops"}` yields: + +```json +{ + "request_id": "...", + "code": "400", + "message": "Bad Request", + "data": null +} +``` + +## Auth middleware on a sub-tree only + +```javascript +const authed = new Router('/admin', { + middlewares: [ + async (context) => { + const token = context.koa.request.headers['authorization']; + if (token !== 'Bearer secret') { + const { error } = require('@axiosleo/koapp'); + error(401, 'Unauthorized'); + } + } + ] +}); + +authed.get('/dashboard', async () => success({ secret: 42 })); +authed.get('/users', async () => success({ users: [] })); + +const root = new Router('/'); +root.add(authed); +``` + +Only requests under `/admin/*` run the auth middleware. + +## Catch-all fallback + +```javascript +const root = new Router(null); +root.get('/api/users', listUsers); +root.get('/api/posts', listPosts); + +root.any('/***', async () => { + const { result } = require('@axiosleo/koapp'); + result('

404

', 404, { 'Content-Type': 'text/html' }); +}); +``` + +## Dynamic registration + +```javascript +function registerCrud(root, name, controller) { + const r = new Router(`/${name}`); + r.get('/{:id}', (ctx) => controller.find(ctx)); + r.get('/', (ctx) => controller.list(ctx)); + r.post('/', (ctx) => controller.create(ctx)); + r.put('/{:id}', (ctx) => controller.update(ctx)); + r.delete('/{:id}', (ctx) => controller.remove(ctx)); + root.add(r); +} + +const api = new Router('/api/v1'); +registerCrud(api, 'users', usersController); +registerCrud(api, 'posts', postsController); +``` diff --git a/assets/skills/koapp-sse/SKILL.md b/assets/skills/koapp-sse/SKILL.md new file mode 100644 index 0000000..54cc6a1 --- /dev/null +++ b/assets/skills/koapp-sse/SKILL.md @@ -0,0 +1,203 @@ +--- +name: koapp-sse +description: Stream Server-Sent Events (SSE) from a @axiosleo/koapp Koa route using the KoaSSEMiddleware. Use when pushing real-time events to a browser over HTTP, implementing progress bars, live logs, notifications, or EventSource endpoints with text/event-stream, including ctx.koa.sse.send/close/keepAlive APIs. +--- + +# @axiosleo/koapp Server-Sent Events Middleware + +Source: [`src/middlewares/sse.js`](../../../src/middlewares/sse.js). + +`KoaSSEMiddleware` upgrades a Koa request to a Server-Sent Events (SSE) +stream, setting the right headers and exposing `ctx.sse.send(...)` for +pushing events to the browser over plain HTTP. + +Use SSE when: + +- You need server → client streaming (notifications, logs, live charts) +- You do NOT need client → server streaming (use **koapp-apps** WebSocket instead) +- You want a simple, proxy-friendly transport - SSE works over HTTP/1.1 + +Browsers reconnect automatically; most reverse proxies handle it fine. + +## Import + +```javascript +const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; +``` + +## Middleware factory + +```javascript +const middleware = KoaSSEMiddleware({ + pingInterval: 60000, // ms between heartbeat comments (default 60s) + closeEvent: 'close' // event name sent on stream close +}); +``` + +The factory returns a standard Koa middleware `(ctx, next) => {}`. + +## Wiring into a router handler + +Because the framework already dispatches routes via its workflow, invoke +the middleware **inside** the handler, passing `context.koa`: + +```javascript +const { Router } = require('@axiosleo/koapp'); +const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; + +const router = new Router('/'); + +router.any('/sse', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + + context.koa.sse.send({ data: 'hello, world!' }); + + process.nextTick(async () => { + for (let i = 0; i < 5; i++) { + context.koa.sse.send({ id: i, event: 'tick', data: { i } }); + await new Promise((r) => setTimeout(r, 1000)); + } + context.koa.sse.close(); + }); +}); +``` + +Key points: + +1. Call `await sse(context.koa, async () => {})` - pass a no-op `next`. +2. After that, `context.koa.sse` exists. +3. Push events with `context.koa.sse.send(event)`; end with `.close()`. +4. Do **not** call `success / failed / result` in the same handler - SSE + takes over the response body. + +## Event shape + +`sse.send(data)` accepts either a string or an object: + +```javascript +// String payload - becomes `data:\n\n` +context.koa.sse.send('hello'); + +// Object payload - id/event are optional +context.koa.sse.send({ + id: 42, + event: 'progress', + data: { percent: 75 } // objects are JSON-stringified +}); +``` + +Raw wire output: + +``` +id:42 +event:progress +data:{"percent":75} + +``` + +## Lifecycle methods + +| Method | When to use | +| --- | --- | +| `ctx.sse.send(data)` | Push one event | +| `ctx.sse.keepAlive()` | Send a `:\n\n` comment to keep the connection warm (called automatically every `pingInterval` ms for all active streams) | +| `ctx.sse.close()` | Emit a final `{ event: closeEvent }` and end the stream | + +The middleware maintains an internal `ssePool` and calls `keepAlive` on +every registered stream every `pingInterval` ms. Manual calls are rarely +needed. + +## Reacting to client disconnect + +The middleware listens to `close` / `error` events on the SSE stream and +calls an internal `close()` that: + +1. Removes the stream from the pool +2. `unpipe()` + `destroy()` the stream +3. Ends the Koa response +4. Destroys the underlying socket + +You can listen yourself too: + +```javascript +context.koa.sse.on('close', () => { + clearInterval(myPushLoop); +}); +``` + +## Long-lived handlers + +The handler function must remain alive while the SSE stream is open. +Don't `return` from it immediately; either keep pushing inline, or spawn a +background task and let it own the lifecycle: + +```javascript +router.any('/logs', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + + // Detach so the handler resolves but the stream stays open + (async () => { + for await (const line of tail('./app.log')) { + context.koa.sse.send({ event: 'log', data: line }); + } + context.koa.sse.close(); + })(); +}); +``` + +The framework's workflow will see the handler resolve, but the response +body (the SSE stream) remains open until you `close()` it or the client +disconnects. + +## Browser client + +```javascript +const source = new EventSource('/sse'); + +source.onmessage = (e) => { + console.log('data:', e.data); +}; + +source.addEventListener('progress', (e) => { + const payload = JSON.parse(e.data); + console.log('progress:', payload.percent); +}); + +source.addEventListener('close', () => { + console.log('server closed the stream'); + source.close(); +}); +``` + +Browsers automatically reconnect on network blips. + +## Pairing with other koapp pieces + +- Validate the upgrade with a router-level validator on `query` + (e.g. auth token) - **koapp-router** +- Authenticate via session cookie - the middleware does not touch auth +- Control backpressure by only pushing when `context.koa.sse.writable` + +## Common pitfalls + +- Calling `success / failed / result / context.koa.body = ...` in the + same handler conflicts with the SSE stream and yields incomplete output. +- Using HTTP/2 requires enabling SSE on the server side - the default + Node HTTP/1.1 works out of the box. +- Firewall / proxy buffering can delay events. Set + `X-Accel-Buffering: no` for nginx: + ```javascript + context.koa.set('X-Accel-Buffering', 'no'); + ``` + Do this **before** calling `sse(context.koa, ...)`. +- The middleware's global `setInterval` heartbeat keeps all open SSE + connections warm; it does not leak across requests because each stream + auto-removes itself from the pool on close. + +## See also + +- Real-time browser apps needing bi-directional traffic → **koapp-apps** (WebSocket) +- Progress indicators for long-running HTTP jobs → this skill + `Workflow` from `@axiosleo/cli-tool` +- Concrete SSE recipes: [examples.md](examples.md) diff --git a/assets/skills/koapp-sse/examples.md b/assets/skills/koapp-sse/examples.md new file mode 100644 index 0000000..7d6f812 --- /dev/null +++ b/assets/skills/koapp-sse/examples.md @@ -0,0 +1,169 @@ +# SSE Examples + +## Hello SSE + +```javascript +const { KoaApplication, Router } = require('@axiosleo/koapp'); +const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; + +const router = new Router('/'); +router.any('/sse', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + context.koa.sse.send({ data: 'hello, world!' }); + context.koa.sse.close(); +}); + +new KoaApplication({ port: 8088, routers: [router] }).start(); +``` + +Browser: + +```javascript +new EventSource('http://localhost:8088/sse').onmessage = (e) => console.log(e.data); +``` + +## Pushing a progress bar + +```javascript +const { _foreach, _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { Router } = require('@axiosleo/koapp'); +const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; + +const router = new Router('/'); +router.any('/progress', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + + process.nextTick(async () => { + await _foreach(['0', '25', '50', '75', '100'], async (percent) => { + context.koa.sse.send({ event: 'progress', data: { percent } }); + await _sleep(500); + }); + context.koa.sse.close(); + }); +}); +``` + +Browser: + +```javascript +const es = new EventSource('/progress'); +es.addEventListener('progress', (e) => { + const { percent } = JSON.parse(e.data); + console.log('progress', percent); +}); +``` + +## Live log tail + +```javascript +const fs = require('fs'); +const { Router } = require('@axiosleo/koapp'); +const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; + +const router = new Router('/'); +router.any('/logs', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + + const stream = fs.createReadStream('./app.log', { encoding: 'utf8' }); + stream.on('data', (chunk) => { + for (const line of chunk.split('\n').filter(Boolean)) { + context.koa.sse.send({ event: 'log', data: line }); + } + }); + stream.on('end', () => context.koa.sse.close()); + context.koa.sse.on('close', () => stream.destroy()); +}); +``` + +## Broadcast to many SSE clients + +The middleware does not group connections itself, but you can keep your +own registry: + +```javascript +const subscribers = new Set(); + +router.any('/notifications', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + subscribers.add(context.koa.sse); + context.koa.sse.on('close', () => subscribers.delete(context.koa.sse)); +}); + +function broadcast(event, data) { + for (const sse of subscribers) { + try { + sse.send({ event, data }); + } catch (err) { + subscribers.delete(sse); + } + } +} + +// Somewhere else in the app +broadcast('message', { text: 'New post published!' }); +``` + +## With validator + auth + +```javascript +router.push('any', '/secure-stream', async (context) => { + const token = context.query.token; + if (token !== process.env.STREAM_TOKEN) { + const { error } = require('@axiosleo/koapp'); + error(401, 'Unauthorized'); + } + + const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; + const sse = KoaSSEMiddleware({ pingInterval: 30000 }); + await sse(context.koa, async () => {}); + + context.koa.sse.send({ event: 'hello', data: { at: Date.now() } }); +}, { + query: { + rules: { token: 'required|string' } + } +}); +``` + +## Proxy-friendly headers + +Place these **before** `sse(context.koa, ...)` so they reach the +response headers: + +```javascript +router.any('/sse', async (context) => { + context.koa.set('X-Accel-Buffering', 'no'); // nginx + context.koa.set('Cache-Control', 'no-cache, no-transform'); + + const { KoaSSEMiddleware } = require('@axiosleo/koapp').middlewares; + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + // ... +}); +``` + +## Sending JSON arrays one item at a time + +```javascript +router.any('/items', async (context) => { + const sse = KoaSSEMiddleware(); + await sse(context.koa, async () => {}); + + const items = await db.table('items').select(); + for (const [idx, item] of items.entries()) { + context.koa.sse.send({ id: idx, event: 'item', data: item }); + } + context.koa.sse.close(); +}); +``` + +Browser: + +```javascript +const es = new EventSource('/items'); +es.addEventListener('item', (e) => console.log(JSON.parse(e.data))); +``` diff --git a/assets/skills/koapp/SKILL.md b/assets/skills/koapp/SKILL.md new file mode 100644 index 0000000..68a18d4 --- /dev/null +++ b/assets/skills/koapp/SKILL.md @@ -0,0 +1,116 @@ +--- +name: koapp +description: Overview of the @axiosleo/koapp framework - a Koa-based web, TCP socket, and WebSocket framework for Node.js. Use when the user mentions @axiosleo/koapp, koapp, needs to pick the right Application class, or wants to understand what the framework exports (KoaApplication, SocketApplication, WebSocketApplication, Router, Controller, Model, response helpers, SSE middleware). +--- + +# @axiosleo/koapp Framework Overview + +`@axiosleo/koapp` is a Node.js framework built on top of [Koa](https://koajs.com/) that provides three runtime modes (HTTP, TCP, WebSocket) behind a unified `Application` base, plus batteries-included helpers for routing, validation, response shaping, and Server-Sent Events. + +Minimum Node.js version: **16.0.0**. + +## Installation + +```bash +npm install @axiosleo/koapp +``` + +## Core Exports + +All symbols below come from `require('@axiosleo/koapp')`: + +| Symbol | Kind | When to use | +| --- | --- | --- | +| `KoaApplication` | Class | Stand up an HTTP server on top of Koa | +| `SocketApplication` | Class | Stand up a TCP socket server (Node `net`) | +| `WebSocketApplication` | Class | Stand up a WebSocket server (the `ws` library) | +| `Application` | Class | Base class, rarely instantiated directly | +| `Router` | Class | Define routes, path params, validators, nested routers | +| `Controller` | Class | Base class to organize request handlers with response helpers | +| `Model` | Class | Structured validation (via `validatorjs`) + object serialization | +| `success` / `failed` / `result` / `response` / `error` | Functions | Throw-style response helpers consumed by the framework's workflow | +| `HttpError` / `HttpResponse` | Classes | Throwable error/response objects | +| `middlewares.KoaSSEMiddleware` | Factory | Attach a Server-Sent Events stream to a Koa route | +| `middlewares.KoaSessionMiddleware` | Re-export | `koa-session` for custom setups | +| `initContext` | Function | Advanced: build a framework context for custom transports | + +Full quick-start example lives in [quick-start.md](quick-start.md). + +## Class Hierarchy + +``` +EventEmitter + └── Application (src/apps/app.js) + ├── KoaApplication (src/apps/koa.js) - HTTP + └── SocketApplication (src/apps/socket.js) - TCP + └── WebSocketApplication (src/apps/websocket.js) - WebSocket +``` + +`WebSocketApplication` inherits all connection-management helpers +(`send`, `close`, `sendByConnectionId`, `closeByConnectionId`, `getConnection`, +`ping`, `broadcast`) from `SocketApplication`. Only the `send`, `close`, and +`broadcast` transport wrappers are overridden. + +## Scenario Routing (pick the right skill) + +When a task falls into one of these areas, prefer the specialized skill for +code-level guidance: + +- Building an HTTP / TCP / WebSocket server → **koapp-apps** +- Defining routes, path params, nested routers, validators → **koapp-router** +- Returning JSON / HTML / custom status responses → **koapp-response** +- 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** + +## Request Lifecycle (Koa path) + +```mermaid +sequenceDiagram + participant Client + participant Koa as Koa Middleware Chain + participant WF as koa.workflow + participant Handler as Router Handler + Client->>Koa: HTTP request + Koa->>Koa: session + body parser + Koa->>WF: workflow.start(context) + WF->>WF: resolve route + validate + WF->>WF: router middlewares + WF->>Handler: handler(context) + Handler-->>WF: throw success/failed/result/error + WF->>WF: router afters + WF->>Koa: handleRes(context) + Koa-->>Client: response +``` + +The `response` functions (`success`, `failed`, `result`, `response`, `error`) +deliberately **throw** a typed object that the workflow catches and turns into +the actual Koa or socket response. Inside handlers, call them and `return` is +implicit - no need to `return` the result of `success(...)`. + +## Conventions enforced by the framework + +1. Code uses `'use strict'`, CommonJS, and async/await - follow suit in user + code unless the target project uses TypeScript (see `assets/tmpl/`). +2. Response payloads (JSON) are always wrapped as + `{ request_id, timestamp, code, message, data }`. `code` is a + `";"` string. +3. Path parameters use `/{:name}` syntax, e.g. `/users/{:id}/posts/{:postId}`. + Legacy `:name` is also accepted but `{:name}` is preferred. +4. A router with `method` unset (or empty string) is effectively disabled - + requests will resolve to the fallback `/***` handler. +5. Validation rules use [validatorjs](https://github.com/mikeerickson/validatorjs) + syntax and live under `validators.params|query|body`. + +## When NOT to use koapp directly + +- Pure static file serving without API - `koa-static` alone is lighter +- Heavy enterprise microservices needing opinionated DI / RPC - use NestJS +- Edge-runtime deployments (Cloudflare Workers / Vercel Edge) - the framework + depends on Node-only APIs (`net`, `ws`, `fs`) + +## Related Resources + +- Quick start walkthrough: [quick-start.md](quick-start.md) +- Published on npm: `@axiosleo/koapp` +- Source: https://github.com/AxiosLeo/node-koapp diff --git a/assets/skills/koapp/quick-start.md b/assets/skills/koapp/quick-start.md new file mode 100644 index 0000000..aa6e66b --- /dev/null +++ b/assets/skills/koapp/quick-start.md @@ -0,0 +1,74 @@ +# @axiosleo/koapp Quick Start + +Minimum viable HTTP app using `@axiosleo/koapp`. + +## 1. Install + +```bash +npm install @axiosleo/koapp +``` + +## 2. Hello World server + +```javascript +'use strict'; + +const { KoaApplication, Router, success } = require('@axiosleo/koapp'); + +const router = new Router('/', { + method: 'any', + handlers: [ + async (context) => { + success({ message: 'Hello, koapp!' }); + } + ] +}); + +const app = new KoaApplication({ + port: 8088, + listen_host: 'localhost', + routers: [router] +}); + +app.start(); +``` + +Run it: + +```bash +node server.js +# open http://localhost:8088/ +``` + +## 3. Scaffolding a full project + +The CLI ships a project generator that wires up TypeScript, a DB layer, Docker, +and module scaffolding: + +```bash +npx @axiosleo/koapp init my-app +cd my-app +npm install +npm run start:services +``` + +## 4. Generating CRUD modules from JSON Schema + +After `init`, place `*.schema.json` files under `./meta/` and run: + +```bash +npx @axiosleo/koapp gen -d ./meta -o ./services/src/modules +``` + +Each schema produces `name.model.ts`, `name.controller.ts`, `name.router.ts`. + +## 5. Next steps + +Once the skeleton is running, consult the specialized skills: + +- Adding multiple routes and path params → **koapp-router** +- Switching to TCP or WebSocket → **koapp-apps** +- Structured responses, error envelopes → **koapp-response** +- Organizing handlers → **koapp-controller** +- Request-body validation → **koapp-router** (router-level) or **koapp-model** +- Streaming events to the browser → **koapp-sse** diff --git a/commands/http.js b/commands/http.js index aa4dcab..b142700 100644 --- a/commands/http.js +++ b/commands/http.js @@ -5,7 +5,6 @@ const { Command, printer } = require('@axiosleo/cli-tool'); const { Router, KoaApplication, result } = require('../'); const path = require('path'); const promisify = require('util').promisify; -const readdir = promisify(fs.readdir); const stat = promisify(fs.stat); const { _exists, @@ -85,26 +84,23 @@ class HttpCommand extends Command { if (!await _is_dir(dir)) { throw new Error('Only support dir path'); } - const tmp = await readdir(dir); - let files = []; - await Promise.all(tmp.map(async (filename) => { - let filepath = path.join(dir, filename); - let is_dir = await _is_dir(filepath); - let stats = await stat(filepath); - let size = is_dir ? 0 : stats.size; - let mtime = stats.mtime; - let ext = is_dir ? '' : _ext(filename); - - files.push({ - filename, + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + const files = await Promise.all(entries.map(async (entry) => { + const filepath = path.join(dir, entry.name); + // Use stat (not entry.isDirectory()) so symlinks to directories are + // resolved as directories. `Dirent.isDirectory()` is false for + // symlinks regardless of their target. + const stats = await stat(filepath); + const is_dir = stats.isDirectory(); + return { + filename: entry.name, is_dir, filepath, - size, - mtime, - ext - }); + size: is_dir ? 0 : stats.size, + mtime: stats.mtime, + ext: is_dir ? '' : _ext(entry.name) + }; })); - // debug.log(files); return files; } @@ -113,6 +109,7 @@ class HttpCommand extends Command { * @param {*} options */ async exec(args, options) { + printer.print('http'.cyan).green(' Starting server...').println(); let dir = path.resolve(args.dir); const router = new Router('/***', { method: 'any', @@ -161,15 +158,36 @@ class HttpCommand extends Command { } if (await _is_file(d)) { const extname = _ext(d); - context.koa.type = extname; - if (mimeTypes[extname]) { - context.koa.headers['Content-Type'] = mimeTypes[extname]; + const mime = mimeTypes[extname]; + if (mime) { + // Route through Koa's type setter so charset is auto-appended + // for text/* types (and others registered with a charset in + // mime-db). Calling `set('Content-Type', mime)` directly would + // strip that charset and break non-ASCII content. + context.koa.type = mime; } else { - let tmp = context.url.split('/'); - context.koa.headers['Content-disposition'] = 'attachment; filename=' + tmp[tmp.length - 1]; + const filename = path.basename(d); + context.koa.set('Content-Type', 'application/octet-stream'); + context.koa.set('Content-Disposition', 'attachment; filename=' + encodeURIComponent(filename)); } - context.koa.headers['Content-Type'] = mimeTypes[extname]; + const fileStat = await stat(d); + context.koa.set('Content-Length', String(fileStat.size)); + const stream = fs.createReadStream(d); + const res = context.koa.res; + stream.on('error', (err) => { + if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED' || err.code === 'ECONNRESET') { + return; + } + printer.red('StreamError').print(': '); + // eslint-disable-next-line no-console + console.error(err); + }); + res.on('close', () => { + if (!stream.destroyed) { + stream.destroy(); + } + }); context.koa.body = stream; return; } @@ -591,10 +609,7 @@ class HttpCommand extends Command { port: parseInt(options.port), listen_host: '0.0.0.0', routers: [router], - static: { - rootDir: path.resolve(args.dir), - index: 'index.html' - } + static: false }); app.start(); } diff --git a/commands/skills.js b/commands/skills.js new file mode 100644 index 0000000..e21e2d0 --- /dev/null +++ b/commands/skills.js @@ -0,0 +1,201 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); +const { Command, printer } = require('@axiosleo/cli-tool'); +const { + _exists, + _is_dir, + _mkdir, + _copy, + _remove +} = require('@axiosleo/cli-tool/src/helper/fs'); +const { _exec } = require('@axiosleo/cli-tool/src/helper/cmd'); + +const PKG_NAME = '@axiosleo/koapp'; +const TARGET_DIRS = { + cursor: '.cursor/skills', + claude: '.claude/skills' +}; + +function readPkgVersion(pkgDir) { + try { + return require(path.join(pkgDir, 'package.json')).version; + } catch (_err) { // eslint-disable-line no-unused-vars + return null; + } +} + +class SkillsCommand extends Command { + constructor() { + super({ + name: 'skills', + desc: 'Install @axiosleo/koapp AI Skills into Cursor or Claude' + }); + this.addOption('install', 'i', 'Target AI tool: cursor | claude', 'required'); + this.addOption('scope', 's', 'Install scope: project (default) | user', 'optional', 'project'); + this.addOption('force', 'f', 'Overwrite existing skills without prompting', 'optional', false); + } + + resolveDestDir(target, scope) { + const sub = TARGET_DIRS[target]; + if (!sub) { + return null; + } + const base = scope === 'user' ? os.homedir() : process.cwd(); + return path.join(base, sub); + } + + async resolveSourceDir() { + const runnerPkgDir = path.resolve(__dirname, '..'); + const localPkgDir = path.join(process.cwd(), 'node_modules', ...PKG_NAME.split('/')); + + const runnerVer = readPkgVersion(runnerPkgDir); + const state = { + runnerPkgDir, + runnerVer, + localPkgDir, + localVer: null, + sourceDir: null, + updateReminder: null, + usingRunner: false + }; + + const localExists = await _exists(localPkgDir); + if (localExists) { + state.localVer = readPkgVersion(localPkgDir); + const localSkills = path.join(localPkgDir, 'assets/skills'); + if (await _exists(localSkills) && await _is_dir(localSkills)) { + state.sourceDir = localSkills; + if (state.localVer && state.runnerVer && state.localVer !== state.runnerVer) { + printer.warning( + `[skills] running ${PKG_NAME}@${state.runnerVer}, local install is ${state.localVer}` + ).println(); + } else if (state.localVer) { + printer.info(`[skills] installing from local ${PKG_NAME}@${state.localVer}`).println(); + } + return state; + } + // local install exists but lacks skills assets + state.sourceDir = path.join(runnerPkgDir, 'assets/skills'); + state.usingRunner = true; + state.updateReminder = + `Local ${PKG_NAME}${state.localVer ? '@' + state.localVer : ''} does not ship skills assets. ` + + `Installed from runner ${PKG_NAME}${state.runnerVer ? '@' + state.runnerVer : ''} instead. ` + + 'Please update your local dependency: npm install ' + PKG_NAME + '@latest'; + printer.warning('[skills] ' + state.updateReminder).println(); + return state; + } + + printer.warning(`[skills] ${PKG_NAME} is not installed in ${process.cwd()}`).println(); + const shouldInstall = await this.confirm( + `Install ${PKG_NAME} now? (required for consistent skill content)`, + true + ); + if (!shouldInstall) { + printer.info(`[skills] aborted. Run \`npm install ${PKG_NAME}\` and retry.`).println(); + return null; + } + await _exec(`npm install ${PKG_NAME}`, process.cwd()); + + if (await _exists(localPkgDir)) { + state.localVer = readPkgVersion(localPkgDir); + const localSkills = path.join(localPkgDir, 'assets/skills'); + if (await _exists(localSkills) && await _is_dir(localSkills)) { + state.sourceDir = localSkills; + return state; + } + state.sourceDir = path.join(runnerPkgDir, 'assets/skills'); + state.usingRunner = true; + state.updateReminder = + `Freshly installed ${PKG_NAME}${state.localVer ? '@' + state.localVer : ''} lacks skills assets. ` + + 'Using runner assets. Please upgrade: npm install ' + PKG_NAME + '@latest'; + printer.warning('[skills] ' + state.updateReminder).println(); + return state; + } + printer.error(`[skills] ${PKG_NAME} install appears to have failed.`).println(); + return null; + } + + async copySkill(src, dst, force) { + if (await _exists(dst)) { + if (!force) { + const overwrite = await this.confirm( + `Skill "${path.basename(dst)}" already exists at ${dst}. Overwrite?`, + false + ); + if (!overwrite) { + return false; + } + } + await _remove(dst, true); + } + await _copy(src, dst, true); + return true; + } + + async installSkills(sourceDir, destDir, force) { + await _mkdir(destDir); + const fs = require('fs'); + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + const skillDirs = entries.filter((e) => e.isDirectory()); + let installed = 0; + let skipped = 0; + for (const entry of skillDirs) { + const src = path.join(sourceDir, entry.name); + const dst = path.join(destDir, entry.name); + const ok = await this.copySkill(src, dst, force); + if (ok) { + printer.success('[skills] installed: ').println(entry.name); + installed++; + } else { + printer.yellow('[skills] skipped : ').println(entry.name); + skipped++; + } + } + return { installed, skipped, total: skillDirs.length }; + } + + /** + * @param {*} args + * @param {*} options + */ + async exec(args, options) { + const target = options.install; + const scope = options.scope === 'user' ? 'user' : 'project'; + const force = options.force === true || options.force === 'true'; + + if (!target || !TARGET_DIRS[target]) { + printer.error(`[skills] --install must be one of: ${Object.keys(TARGET_DIRS).join(', ')}`).println(); + return; + } + + const destDir = this.resolveDestDir(target, scope); + printer.info(`[skills] target : ${target} (${scope} scope)`).println(); + printer.info(`[skills] destDir: ${destDir}`).println(); + + const state = await this.resolveSourceDir(); + if (!state) { + return; + } + printer.info(`[skills] source : ${state.sourceDir}`).println(); + + if (!await _exists(state.sourceDir)) { + printer.error(`[skills] source directory not found: ${state.sourceDir}`).println(); + return; + } + + const { installed, skipped, total } = await this.installSkills(state.sourceDir, destDir, force); + + printer.println(); + printer.success(`[skills] Done. ${installed} installed, ${skipped} skipped, ${total} total.`).println(); + printer.info(`[skills] location: ${destDir}`).println(); + + if (state.updateReminder) { + printer.println(); + printer.warning('[skills] reminder: ' + state.updateReminder).println(); + } + } +} + +module.exports = SkillsCommand;