forkly
v1.3.1
Published
A declarative Node.js HTTP framework inspired by React Hooks and Flask, built on file-based route conventions.
Readme
Forkly
A declarative Node.js HTTP framework inspired by React Hooks and Flask, built on file-based route conventions.
Quick Start
npm install forklyCreate app.ts:
import { Forkly } from 'forkly'
const app = new Forkly({
routerPath: './routes',
workers: 0, // 0 = single-process
})
await app.listen({ port: 3000, debug: true })Run:
npx tsx app.tsFile-Based Routing
Place route files in the routes/ directory. The framework scans them automatically. Only .ts and .ws.ts extensions are recognized.
Path Derivation
| File | Route |
|------|-------|
| routes/index.ts | GET / |
| routes/user.ts | GET /user |
| routes/api/user.ts | GET /api/user |
| routes/api/index.ts | GET /api |
| routes/user.ws.ts | WebSocket /user |
Files and directories starting with _ or . are ignored.
Multiple Methods
Register handlers for GET / POST / PUT / DELETE in the same file:
import { useRouter } from 'forkly'
const { setGetRouter } = useRouter({ method: 'GET' })
const { setPostRouter } = useRouter({ method: 'POST' })
const { GET } = setGetRouter()
const { POST } = setPostRouter()
GET(async (req) => {
return { code: 200, data: { msg: 'get user' } }
})
POST(async (req) => {
return { code: 201, data: { msg: 'created' } }
})Registering the same method twice in one file throws an error.
useRouter API
useRouter is the core declarative API of Forkly.
Basic Usage
import { useRouter, types } from 'forkly'
const { setGetRouter } = useRouter({ method: 'GET' })
const { GET } = setGetRouter({ query: { id: types.string } })
GET(async (req) => {
// req.query.id is inferred as string
return { code: 200, data: { id: req.query.id } }
})Dynamic Routes
Declare dynamic URL parameters via dynamicRouter:
const { setGetRouter } = useRouter({ method: 'GET', dynamicRouter: 'id' })
const { GET } = setGetRouter()
GET(async (req) => {
// req.params.id is inferred as string
return { code: 200, data: { id: req.params.id } }
})When placed in routes/user.ts, this matches GET /user/:id.
Signature
useRouter<M extends string, D extends string | undefined = undefined>(opts: {
method: M // HTTP method: 'GET' | 'POST' | 'PUT' | 'DELETE' | ...
dynamicRouter?: D // dynamic segment name
})Returns { setGetRouter, setPostRouter, ... } (auto-named by method). The setter returns flat-named handler registrars and lifecycle hooks:
{
GET, // (fn: (req: TypedRequest) => unknown) => void
beforeGetRequest, // (fn: (req: TypedRequest, next: NextFn) => unknown) => void
afterGetRequest, // (fn: (req: TypedRequest, next: NextFn) => unknown) => void
teardownGetRequest, // (fn: (req: TypedRequest, next: NextFn) => unknown) => void
onGetError, // (map: Record<number, (req: TypedRequest, err: Error) => unknown>) => void
}Type Validation
Forkly provides runtime validation powered by Valibot.
Declaring Types
import { useRouter, types } from 'forkly'
const { setGetRouter } = useRouter({ method: 'GET' })
const { GET } = setGetRouter({
query: { id: types.string, page: types.optional(types.number) },
params: { id: types.string },
})
GET(async (req) => {
// req.query.id → string
// req.query.page → number | undefined
// req.params.id → string
})Built-in Types
| Type | Description |
|------|-------------|
| types.string | string |
| types.number | number |
| types.boolean | boolean |
| types.file | File |
| types.blob | Blob |
| types.buffer | Buffer |
| types.stream | ReadableStream |
| types.optional(schema) | optional field |
| types.fileConstraint(opts) | File with constraints |
File Constraints
types.fileConstraint({
type: ['image/png', 'image/jpeg'], // allowed MIME types
maxSize: '5mb', // maximum file size
minSize: '1kb', // minimum file size
})Validation Results
Validation failures do not throw exceptions. Results are written to req.validation:
req.validation.query.valid // boolean
req.validation.query.errors // { path, expected, received }[]Coerced values are written back. E.g., query string "1" is coerced to number 1.
Lifecycle Hooks
Global Hooks (Server-level)
const app = new Forkly()
app.beforeRequest(async (req, next) => {
console.log('before:', req.path)
})
app.afterRequest(async (req, next) => {
console.log('after:', req.path)
})Route-level Hooks
const { GET, beforeGetRequest, afterGetRequest, teardownGetRequest, onGetError } = setGetRouter()
beforeGetRequest(async (req, next) => {
console.log('before route')
})
afterGetRequest(async (req, next) => {
console.log('after route')
})
teardownGetRequest(async (req, next) => {
// runs after response is sent — good for logging/cleanup
console.log('teardown')
})
onGetError({
401: (req, err) => {
return { code: 401, message: 'Unauthorized' }
},
500: (req, err) => {
return { code: 500, message: 'Internal Server Error' }
},
})Execution Order
global.beforeRequest → hook.beforeRequest → handler
→ hook.afterRequest → global.afterRequest → send response → hook.teardownRequestRequest Object
The handler receives a TypedRequest<D, Decl> which extends ForklyRequest.
Core Properties
| Property | Type | Description |
|----------|------|-------------|
| id | string | Unique request ID (UUID) |
| params | DynamicParams<D> | Route parameters |
| query | InferDecl<Q> | Query string parameters |
| body | InferDecl<B> | Request body |
| headers | Record<string, string \| string[] \| undefined> | Request headers |
| validation | see below | Validation result |
| globalData | Record<string, unknown> | Custom global data store |
URL Properties
| Property | Description |
|----------|-------------|
| originalUrl | Full URL including query string |
| path | Pathname only |
| hostname | Host name |
| protocol | http or https |
Utility Methods
| Method | Description |
|--------|-------------|
| get(name) | Get request header (case-insensitive) |
| header(name) | Alias for get |
| is(...types) | Check Content-Type, e.g. req.is('json', 'form') |
| accepts(...types) | Check Accept header, e.g. req.accepts('html', 'json') |
| ip | Client IP (X-Forwarded-For aware) |
| range(size) | Parse Range header, returns RangeResult[] \| -1 \| -2 |
Validation Structure
req.validation = {
params: { valid: boolean, value: any, errors: ValidationError[] },
query: { valid: boolean, value: any, errors: ValidationError[] },
body: { valid: boolean, value: any, errors: ValidationError[] },
}Response Handling
Simply return an object from the handler. The framework serializes it to JSON automatically.
Standard Response
GET(async (req) => {
return {
code: 200,
data: { users: [] },
message: 'ok',
}
})Magic Fields (__ prefix)
Use special keys in the return value to control response behavior:
| Field | Type | Description |
|-------|------|-------------|
| __code__ | number | Custom status code (default: 200) |
| __header__ | Record<string, string> | Custom response headers |
| __cookie__ | Record<string, string> | Set cookies |
| __session__ | Record<string, unknown> | Write to session |
| __statusText__ | string | Custom status text |
GET(async (req) => {
return {
code: 200,
data: { token: 'xxx' },
__code__: 201,
__header__: { 'X-Custom': 'hello' },
__cookie__: { token: 'abc123' },
}
})Sessions use the sid cookie for identification.
File Download & Redirect
File Download
import { download } from 'forkly'
GET(async (req) => {
return download('/path/to/report.pdf', {
filename: 'report.pdf',
maxAge: 3600,
dotfiles: 'deny', // 'allow' | 'deny' | 'ignore'
root: '/data', // root for relative paths
})
})Supports Range requests (partial content / resume).
Redirect
import { redirect } from 'forkly'
GET(async (req) => {
return redirect('https://example.com', 301)
})Defaults to 302.
Static Files
const app = new Forkly({ routerPath: './routes' })
app.setPublicDir('./public')
await app.listen({ port: 3000 })Requests like http://localhost:3000/logo.png serve public/logo.png automatically, skipping route matching.
Cluster Mode
const app = new Forkly({
workers: 0, // 0 = auto (CPU count), >0 = explicit, 0 = single-process
})The primary process forks workers automatically. Crashed workers are restarted.
Compression
const app = new Forkly({
compress: {
gzip: true, // default: true
brotli: false, // default: false
threshold: 1024, // min bytes to compress, default: 1024
},
})Gzip or brotli is selected automatically based on the Accept-Encoding header.
Environment Variables
import { loadEnv, ENV } from 'forkly'
await loadEnv() // reads .env by default
await loadEnv('.env.prod') // custom path
console.log(ENV.DATABASE_URL).env format:
# comments
DATABASE_URL=postgres://localhost:5432/db
PORT=3000ENV is a merged copy of process.env. .env is auto-loaded on listen().
Health Check
Forkly exposes a built-in /health endpoint:
{"status":"ok","uptime":123.456}Graceful Shutdown
On SIGTERM / SIGINT, the server waits for active requests to complete (up to 30 seconds), then shuts down.
CPU-Intensive Tasks
import { intensiveTask } from 'forkly'
GET(async (req) => {
const result = await intensiveTask(
{ data: [1, 2, 3, 4, 5], threshold: 100 },
({ data, threshold }) => {
// runs in a Worker thread — does not block the event loop
return data.filter(n => n < threshold)
}
)
return { code: 200, data: result }
})intensiveTask(data, fn) serializes the function and runs it in a Worker thread. data is passed via structured clone.
API Reference
Exports
export { Forkly, loadEnv, ENV } from 'forkly'
export { scanRoutes } from 'forkly'
export { useRouter } from 'forkly'
export { intensiveTask } from 'forkly'
export { types } from 'forkly'
export { download, redirect } from 'forkly'Forkly Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| host | string | 0.0.0.0 | Listen host |
| routerPath | string | ./routes | Routes directory |
| workers | number | 0 | Number of cluster workers |
| timeout | number | 30000 | Request timeout (ms) |
| https | { key, cert } | — | TLS certificate |
| compress.gzip | boolean | true | Enable gzip |
| compress.brotli | boolean | false | Enable brotli |
| compress.threshold | number | 1024 | Compression threshold (bytes) |
| trustProxy | boolean | false | Trust proxy headers |
| proxyCount | number | 1 | Number of proxy hops |
listen() Options
await app.listen({ port: 3000, debug: false })| Option | Type | Default | Description |
|--------|------|---------|-------------|
| port | number | 3000 | Listen port |
| debug | boolean | false | Debug mode (show error stack) |
(内容由AI生成,仅供参考)
