@strav/http 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +50 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +87 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/http/context.ts +220 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +18 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/resource.ts +102 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +159 -0
- package/src/index.ts +7 -0
- package/src/middleware/http_cache.ts +106 -0
- package/src/middleware/i18n.ts +84 -0
- package/src/middleware/request_logger.ts +19 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/providers/auth_provider.ts +35 -0
- package/src/providers/http_provider.ts +27 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/session_provider.ts +29 -0
- package/src/providers/view_provider.ts +18 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +83 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +84 -0
- package/src/view/compiler.ts +199 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +338 -0
- package/src/view/islands/vue_plugin.ts +136 -0
- package/src/view/middleware/static.ts +69 -0
- package/src/view/tokenizer.ts +182 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Options for serializing a Set-Cookie header. */
|
|
2
|
+
export interface CookieOptions {
|
|
3
|
+
httpOnly?: boolean
|
|
4
|
+
secure?: boolean
|
|
5
|
+
sameSite?: 'strict' | 'lax' | 'none'
|
|
6
|
+
maxAge?: number // seconds
|
|
7
|
+
path?: string
|
|
8
|
+
domain?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Serialize a cookie name/value pair into a Set-Cookie header string. */
|
|
12
|
+
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
13
|
+
let cookie = `${name}=${encodeURIComponent(value)}`
|
|
14
|
+
|
|
15
|
+
if (options.httpOnly) cookie += '; HttpOnly'
|
|
16
|
+
if (options.secure) cookie += '; Secure'
|
|
17
|
+
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`
|
|
18
|
+
if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`
|
|
19
|
+
cookie += `; Path=${options.path ?? '/'}`
|
|
20
|
+
if (options.domain) cookie += `; Domain=${options.domain}`
|
|
21
|
+
|
|
22
|
+
return cookie
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse a Cookie header string into a map of name → value. */
|
|
26
|
+
export function parseCookies(header: string): Map<string, string> {
|
|
27
|
+
const cookies = new Map<string, string>()
|
|
28
|
+
|
|
29
|
+
for (const pair of header.split(';')) {
|
|
30
|
+
const eq = pair.indexOf('=')
|
|
31
|
+
if (eq === -1) continue
|
|
32
|
+
const key = pair.slice(0, eq).trim()
|
|
33
|
+
const value = decodeURIComponent(pair.slice(eq + 1).trim())
|
|
34
|
+
cookies.set(key, value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return cookies
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Return a new Response with a Set-Cookie header appended. */
|
|
41
|
+
export function withCookie(
|
|
42
|
+
response: Response,
|
|
43
|
+
name: string,
|
|
44
|
+
value: string,
|
|
45
|
+
options?: CookieOptions
|
|
46
|
+
): Response {
|
|
47
|
+
const headers = new Headers(response.headers)
|
|
48
|
+
headers.append('Set-Cookie', serializeCookie(name, value, options))
|
|
49
|
+
return new Response(response.body, {
|
|
50
|
+
status: response.status,
|
|
51
|
+
statusText: response.statusText,
|
|
52
|
+
headers,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Return a new Response with a cookie-clearing Set-Cookie header (Max-Age=0). */
|
|
57
|
+
export function clearCookie(response: Response, name: string, options?: CookieOptions): Response {
|
|
58
|
+
return withCookie(response, name, '', { ...options, maxAge: 0 })
|
|
59
|
+
}
|
package/src/http/cors.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Types
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface CorsOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Allowed origins. Determines Access-Control-Allow-Origin.
|
|
8
|
+
*
|
|
9
|
+
* - `'*'` — allow all origins (incompatible with credentials)
|
|
10
|
+
* - `string` — a single exact origin
|
|
11
|
+
* - `string[]` — an allow-list of exact origins
|
|
12
|
+
* - `RegExp` — pattern tested against the request Origin header
|
|
13
|
+
* - `(origin: string) => boolean` — callback for custom logic
|
|
14
|
+
*
|
|
15
|
+
* @default '*'
|
|
16
|
+
*/
|
|
17
|
+
origin?: string | string[] | RegExp | ((origin: string) => boolean)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Allowed HTTP methods for preflight responses.
|
|
21
|
+
* @default ['GET','HEAD','PUT','PATCH','POST','DELETE']
|
|
22
|
+
*/
|
|
23
|
+
methods?: string[]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Allowed request headers for preflight responses.
|
|
27
|
+
* When unset, mirrors the Access-Control-Request-Headers from the preflight.
|
|
28
|
+
*/
|
|
29
|
+
allowedHeaders?: string[]
|
|
30
|
+
|
|
31
|
+
/** Headers exposed to the browser via Access-Control-Expose-Headers. */
|
|
32
|
+
exposedHeaders?: string[]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Include Access-Control-Allow-Credentials: true.
|
|
36
|
+
* When true, origin cannot be literal `'*'` — the actual request origin is reflected.
|
|
37
|
+
* @default false
|
|
38
|
+
*/
|
|
39
|
+
credentials?: boolean
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Preflight cache duration in seconds (Access-Control-Max-Age).
|
|
43
|
+
* @default 86400
|
|
44
|
+
*/
|
|
45
|
+
maxAge?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolved config with defaults applied. */
|
|
49
|
+
export interface ResolvedCorsConfig {
|
|
50
|
+
origin: string | string[] | RegExp | ((origin: string) => boolean)
|
|
51
|
+
methods: string[]
|
|
52
|
+
allowedHeaders?: string[]
|
|
53
|
+
exposedHeaders?: string[]
|
|
54
|
+
credentials: boolean
|
|
55
|
+
maxAge: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Defaults
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const DEFAULTS: ResolvedCorsConfig = {
|
|
63
|
+
origin: '*',
|
|
64
|
+
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
|
65
|
+
credentials: false,
|
|
66
|
+
maxAge: 86400,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Merge user options with defaults. */
|
|
74
|
+
export function resolveCorsConfig(options?: CorsOptions): ResolvedCorsConfig {
|
|
75
|
+
return { ...DEFAULTS, ...options }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Determine the Access-Control-Allow-Origin value for a request origin.
|
|
80
|
+
* Returns `null` when the origin is not allowed.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveOrigin(
|
|
83
|
+
config: ResolvedCorsConfig,
|
|
84
|
+
requestOrigin: string | null
|
|
85
|
+
): string | null {
|
|
86
|
+
const { origin, credentials } = config
|
|
87
|
+
|
|
88
|
+
if (!requestOrigin) return credentials ? null : '*'
|
|
89
|
+
|
|
90
|
+
if (origin === '*') return credentials ? requestOrigin : '*'
|
|
91
|
+
if (typeof origin === 'string') return origin === requestOrigin ? origin : null
|
|
92
|
+
if (Array.isArray(origin)) return origin.includes(requestOrigin) ? requestOrigin : null
|
|
93
|
+
if (origin instanceof RegExp) return origin.test(requestOrigin) ? requestOrigin : null
|
|
94
|
+
if (typeof origin === 'function') return origin(requestOrigin) ? requestOrigin : null
|
|
95
|
+
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Build a 204 preflight response with CORS headers. */
|
|
100
|
+
export function preflightResponse(
|
|
101
|
+
config: ResolvedCorsConfig,
|
|
102
|
+
requestOrigin: string | null,
|
|
103
|
+
requestHeaders: string | null
|
|
104
|
+
): Response {
|
|
105
|
+
const allowedOrigin = resolveOrigin(config, requestOrigin)
|
|
106
|
+
|
|
107
|
+
if (!allowedOrigin) return new Response(null, { status: 204 })
|
|
108
|
+
|
|
109
|
+
const headers = new Headers()
|
|
110
|
+
headers.set('Access-Control-Allow-Origin', allowedOrigin)
|
|
111
|
+
headers.set('Access-Control-Allow-Methods', config.methods.join(', '))
|
|
112
|
+
headers.set('Access-Control-Max-Age', String(config.maxAge))
|
|
113
|
+
|
|
114
|
+
if (config.credentials) {
|
|
115
|
+
headers.set('Access-Control-Allow-Credentials', 'true')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (config.allowedHeaders) {
|
|
119
|
+
headers.set('Access-Control-Allow-Headers', config.allowedHeaders.join(', '))
|
|
120
|
+
} else if (requestHeaders) {
|
|
121
|
+
headers.set('Access-Control-Allow-Headers', requestHeaders)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (allowedOrigin !== '*') {
|
|
125
|
+
headers.set('Vary', 'Origin')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new Response(null, { status: 204, headers })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Return a new Response with CORS headers appended. */
|
|
132
|
+
export function withCorsHeaders(
|
|
133
|
+
response: Response,
|
|
134
|
+
config: ResolvedCorsConfig,
|
|
135
|
+
requestOrigin: string | null
|
|
136
|
+
): Response {
|
|
137
|
+
const allowedOrigin = resolveOrigin(config, requestOrigin)
|
|
138
|
+
if (!allowedOrigin) return response
|
|
139
|
+
|
|
140
|
+
const headers = new Headers(response.headers)
|
|
141
|
+
headers.set('Access-Control-Allow-Origin', allowedOrigin)
|
|
142
|
+
|
|
143
|
+
if (config.credentials) {
|
|
144
|
+
headers.set('Access-Control-Allow-Credentials', 'true')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (config.exposedHeaders?.length) {
|
|
148
|
+
headers.set('Access-Control-Expose-Headers', config.exposedHeaders.join(', '))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (allowedOrigin !== '*') {
|
|
152
|
+
const existing = headers.get('Vary')
|
|
153
|
+
if (!existing?.includes('Origin')) {
|
|
154
|
+
headers.set('Vary', existing ? `${existing}, Origin` : 'Origin')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new Response(response.body, {
|
|
159
|
+
status: response.status,
|
|
160
|
+
statusText: response.statusText,
|
|
161
|
+
headers,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { app } from '@stravigor/kernel/core/application'
|
|
2
|
+
import Router from './router.ts'
|
|
3
|
+
|
|
4
|
+
export { default as Context } from './context.ts'
|
|
5
|
+
export { default as Router } from './router.ts'
|
|
6
|
+
export { default as Server } from './server.ts'
|
|
7
|
+
export { compose } from './middleware.ts'
|
|
8
|
+
export { serializeCookie, parseCookies, withCookie, clearCookie } from './cookie.ts'
|
|
9
|
+
export { rateLimit, MemoryStore } from './rate_limit.ts'
|
|
10
|
+
export { Resource } from './resource.ts'
|
|
11
|
+
export type { Handler, Middleware, Next } from './middleware.ts'
|
|
12
|
+
export type { GroupOptions, WebSocketHandlers, WebSocketData } from './router.ts'
|
|
13
|
+
export type { CookieOptions } from './cookie.ts'
|
|
14
|
+
export type { CorsOptions } from './cors.ts'
|
|
15
|
+
export type { RateLimitOptions, RateLimitStore, RateLimitInfo } from './rate_limit.ts'
|
|
16
|
+
|
|
17
|
+
if (!app.has(Router)) app.singleton(Router)
|
|
18
|
+
export const router = app.resolve(Router)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type Context from './context.ts'
|
|
2
|
+
|
|
3
|
+
/** A route handler — receives a Context and returns a Response. */
|
|
4
|
+
export type Handler = (ctx: Context) => Response | Promise<Response>
|
|
5
|
+
|
|
6
|
+
/** Invokes the next middleware (or the final handler) in the pipeline. */
|
|
7
|
+
export type Next = () => Promise<Response>
|
|
8
|
+
|
|
9
|
+
/** A middleware function — wraps a handler with before/after logic. */
|
|
10
|
+
export type Middleware = (ctx: Context, next: Next) => Response | Promise<Response>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compose an array of middleware and a final handler into a single handler.
|
|
14
|
+
*
|
|
15
|
+
* Implements the onion model: each middleware wraps the next, and can
|
|
16
|
+
* inspect or modify the response on the way back.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const handler = compose([logger, auth], finalHandler)
|
|
20
|
+
* const response = await handler(ctx)
|
|
21
|
+
*/
|
|
22
|
+
export function compose(middleware: Middleware[], handler: Handler): Handler {
|
|
23
|
+
return (ctx: Context) => {
|
|
24
|
+
let index = -1
|
|
25
|
+
|
|
26
|
+
function dispatch(i: number): Promise<Response> {
|
|
27
|
+
if (i <= index) throw new Error('next() called multiple times')
|
|
28
|
+
index = i
|
|
29
|
+
|
|
30
|
+
if (i === middleware.length) {
|
|
31
|
+
return Promise.resolve(handler(ctx))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return Promise.resolve(middleware[i]!(ctx, () => dispatch(i + 1)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return dispatch(0)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Middleware } from './middleware.ts'
|
|
2
|
+
import type Context from './context.ts'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Store interface
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface RateLimitInfo {
|
|
9
|
+
/** Total allowed requests in the window. */
|
|
10
|
+
limit: number
|
|
11
|
+
/** Remaining requests in the current window. */
|
|
12
|
+
remaining: number
|
|
13
|
+
/** Unix timestamp (ms) when the window resets. */
|
|
14
|
+
resetTime: number
|
|
15
|
+
/** Whether the current request exceeds the limit. */
|
|
16
|
+
exceeded: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pluggable storage backend for rate limit counters.
|
|
21
|
+
* Implement this interface to use Redis, database, or distributed stores.
|
|
22
|
+
*/
|
|
23
|
+
export interface RateLimitStore {
|
|
24
|
+
increment(key: string, window: number, max: number): RateLimitInfo | Promise<RateLimitInfo>
|
|
25
|
+
reset(key: string): void | Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// In-memory store (fixed window)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
interface WindowEntry {
|
|
33
|
+
count: number
|
|
34
|
+
resetTime: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* In-memory rate limit store using fixed time windows.
|
|
39
|
+
* Entries are lazily cleaned up on access. Suitable for single-process deployments.
|
|
40
|
+
*/
|
|
41
|
+
export class MemoryStore implements RateLimitStore {
|
|
42
|
+
private windows = new Map<string, WindowEntry>()
|
|
43
|
+
|
|
44
|
+
increment(key: string, window: number, max: number): RateLimitInfo {
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
const entry = this.windows.get(key)
|
|
47
|
+
|
|
48
|
+
if (entry && now < entry.resetTime) {
|
|
49
|
+
entry.count++
|
|
50
|
+
return {
|
|
51
|
+
limit: max,
|
|
52
|
+
remaining: Math.max(0, max - entry.count),
|
|
53
|
+
resetTime: entry.resetTime,
|
|
54
|
+
exceeded: entry.count > max,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resetTime = now + window
|
|
59
|
+
this.windows.set(key, { count: 1, resetTime })
|
|
60
|
+
|
|
61
|
+
if (this.windows.size > 10_000) this.cleanup(now)
|
|
62
|
+
|
|
63
|
+
return { limit: max, remaining: max - 1, resetTime, exceeded: false }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reset(key: string): void {
|
|
67
|
+
this.windows.delete(key)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private cleanup(now: number): void {
|
|
71
|
+
for (const [key, entry] of this.windows) {
|
|
72
|
+
if (now >= entry.resetTime) this.windows.delete(key)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Options
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export interface RateLimitOptions {
|
|
82
|
+
/** Time window in milliseconds. @default 60_000 */
|
|
83
|
+
window?: number
|
|
84
|
+
|
|
85
|
+
/** Maximum requests allowed in the window. @default 60 */
|
|
86
|
+
max?: number
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract the rate limit key from the request context.
|
|
90
|
+
* Defaults to client IP via X-Forwarded-For / X-Real-IP.
|
|
91
|
+
*/
|
|
92
|
+
keyExtractor?: (ctx: Context) => string
|
|
93
|
+
|
|
94
|
+
/** Return `true` to bypass the rate limit check. */
|
|
95
|
+
skip?: (ctx: Context) => boolean
|
|
96
|
+
|
|
97
|
+
/** Custom storage backend. Defaults to an in-memory fixed-window store. */
|
|
98
|
+
store?: RateLimitStore
|
|
99
|
+
|
|
100
|
+
/** Custom response when rate limit is exceeded. */
|
|
101
|
+
onLimitReached?: (ctx: Context, info: RateLimitInfo) => Response
|
|
102
|
+
|
|
103
|
+
/** Whether to add X-RateLimit-* headers to successful responses. @default true */
|
|
104
|
+
headers?: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Default key extractor
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function defaultKeyExtractor(ctx: Context): string {
|
|
112
|
+
const forwarded = ctx.header('x-forwarded-for')
|
|
113
|
+
if (forwarded) return forwarded.split(',')[0]!.trim()
|
|
114
|
+
|
|
115
|
+
const realIp = ctx.header('x-real-ip')
|
|
116
|
+
if (realIp) return realIp
|
|
117
|
+
|
|
118
|
+
return 'unknown'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Middleware factory
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export function rateLimit(options: RateLimitOptions = {}): Middleware {
|
|
126
|
+
const {
|
|
127
|
+
window = 60_000,
|
|
128
|
+
max = 60,
|
|
129
|
+
keyExtractor = defaultKeyExtractor,
|
|
130
|
+
skip,
|
|
131
|
+
store = new MemoryStore(),
|
|
132
|
+
onLimitReached,
|
|
133
|
+
headers: addHeaders = true,
|
|
134
|
+
} = options
|
|
135
|
+
|
|
136
|
+
return async (ctx, next) => {
|
|
137
|
+
if (skip?.(ctx)) return next()
|
|
138
|
+
|
|
139
|
+
const key = keyExtractor(ctx)
|
|
140
|
+
const info = await store.increment(key, window, max)
|
|
141
|
+
|
|
142
|
+
if (info.exceeded) {
|
|
143
|
+
if (onLimitReached) return onLimitReached(ctx, info)
|
|
144
|
+
|
|
145
|
+
const retryAfter = Math.ceil((info.resetTime - Date.now()) / 1000)
|
|
146
|
+
return new Response(JSON.stringify({ error: 'Too Many Requests' }), {
|
|
147
|
+
status: 429,
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
'X-RateLimit-Limit': String(info.limit),
|
|
151
|
+
'X-RateLimit-Remaining': '0',
|
|
152
|
+
'X-RateLimit-Reset': String(Math.ceil(info.resetTime / 1000)),
|
|
153
|
+
'Retry-After': String(Math.max(0, retryAfter)),
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const response = await next()
|
|
159
|
+
|
|
160
|
+
if (!addHeaders) return response
|
|
161
|
+
|
|
162
|
+
const headers = new Headers(response.headers)
|
|
163
|
+
headers.set('X-RateLimit-Limit', String(info.limit))
|
|
164
|
+
headers.set('X-RateLimit-Remaining', String(info.remaining))
|
|
165
|
+
headers.set('X-RateLimit-Reset', String(Math.ceil(info.resetTime / 1000)))
|
|
166
|
+
|
|
167
|
+
return new Response(response.body, {
|
|
168
|
+
status: response.status,
|
|
169
|
+
statusText: response.statusText,
|
|
170
|
+
headers,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { DateTime } from 'luxon'
|
|
2
|
+
import type { PaginationResult, PaginationMeta } from '@stravigor/database/database/query_builder'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Resource base class
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base class for API resources (serializers).
|
|
10
|
+
*
|
|
11
|
+
* Subclass and implement `define()` to control the shape of JSON output
|
|
12
|
+
* for a model. Use the static helpers to transform model instances into
|
|
13
|
+
* plain objects ready for `ctx.json()`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* class UserResource extends Resource<User> {
|
|
17
|
+
* define(user: User) {
|
|
18
|
+
* return {
|
|
19
|
+
* id: user.id,
|
|
20
|
+
* name: user.name,
|
|
21
|
+
* email: user.email,
|
|
22
|
+
* createdAt: user.createdAt,
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* return ctx.json(UserResource.make(user))
|
|
28
|
+
* return ctx.json(UserResource.collection(users))
|
|
29
|
+
* return ctx.json(UserResource.paginate(paginatedResult))
|
|
30
|
+
*/
|
|
31
|
+
export abstract class Resource<T> {
|
|
32
|
+
/**
|
|
33
|
+
* Define the serialized shape for a single model instance.
|
|
34
|
+
* Return a plain object with the desired keys and values.
|
|
35
|
+
* DateTime values are automatically converted to ISO 8601 strings.
|
|
36
|
+
*/
|
|
37
|
+
abstract define(model: T): Record<string, unknown>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Transform a single model instance into a plain serializable object.
|
|
41
|
+
* Returns `null` if the input is `null` or `undefined`.
|
|
42
|
+
*/
|
|
43
|
+
static make<T>(
|
|
44
|
+
this: new () => Resource<T>,
|
|
45
|
+
model: T | null | undefined
|
|
46
|
+
): Record<string, unknown> | null {
|
|
47
|
+
if (model === null || model === undefined) return null
|
|
48
|
+
const instance = new this()
|
|
49
|
+
return serialize(instance.define(model))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Transform an array of model instances into an array of plain objects.
|
|
54
|
+
*/
|
|
55
|
+
static collection<T>(this: new () => Resource<T>, models: T[]): Record<string, unknown>[] {
|
|
56
|
+
const instance = new this()
|
|
57
|
+
return models.map(model => serialize(instance.define(model)))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Transform a PaginationResult into a serialized pagination envelope.
|
|
62
|
+
* Preserves the `meta` object and serializes each item in `data`.
|
|
63
|
+
*/
|
|
64
|
+
static paginate<T>(
|
|
65
|
+
this: new () => Resource<T>,
|
|
66
|
+
result: PaginationResult<T>
|
|
67
|
+
): { data: Record<string, unknown>[]; meta: PaginationMeta } {
|
|
68
|
+
const instance = new this()
|
|
69
|
+
return {
|
|
70
|
+
data: result.data.map(model => serialize(instance.define(model))),
|
|
71
|
+
meta: result.meta,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Serialization helpers
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** Recursively serialize a plain object, converting DateTime → ISO string. */
|
|
81
|
+
function serialize(obj: Record<string, unknown>): Record<string, unknown> {
|
|
82
|
+
const result: Record<string, unknown> = {}
|
|
83
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
84
|
+
result[key] = serializeValue(value)
|
|
85
|
+
}
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function serializeValue(value: unknown): unknown {
|
|
90
|
+
if (value === null || value === undefined) return value
|
|
91
|
+
if (value instanceof DateTime) return value.toISO()
|
|
92
|
+
if (typeof value === 'bigint') {
|
|
93
|
+
return value <= Number.MAX_SAFE_INTEGER && value >= Number.MIN_SAFE_INTEGER
|
|
94
|
+
? Number(value)
|
|
95
|
+
: String(value)
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(value)) return value.map(serializeValue)
|
|
98
|
+
if (typeof value === 'object' && value !== null && value.constructor === Object) {
|
|
99
|
+
return serialize(value as Record<string, unknown>)
|
|
100
|
+
}
|
|
101
|
+
return value
|
|
102
|
+
}
|