@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.
Files changed (46) hide show
  1. package/package.json +50 -0
  2. package/src/auth/access_token.ts +122 -0
  3. package/src/auth/auth.ts +87 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/middleware/authenticate.ts +64 -0
  6. package/src/auth/middleware/csrf.ts +62 -0
  7. package/src/auth/middleware/guest.ts +46 -0
  8. package/src/http/context.ts +220 -0
  9. package/src/http/cookie.ts +59 -0
  10. package/src/http/cors.ts +163 -0
  11. package/src/http/index.ts +18 -0
  12. package/src/http/middleware.ts +39 -0
  13. package/src/http/rate_limit.ts +173 -0
  14. package/src/http/resource.ts +102 -0
  15. package/src/http/router.ts +556 -0
  16. package/src/http/server.ts +159 -0
  17. package/src/index.ts +7 -0
  18. package/src/middleware/http_cache.ts +106 -0
  19. package/src/middleware/i18n.ts +84 -0
  20. package/src/middleware/request_logger.ts +19 -0
  21. package/src/policy/authorize.ts +44 -0
  22. package/src/policy/index.ts +3 -0
  23. package/src/policy/policy_result.ts +13 -0
  24. package/src/providers/auth_provider.ts +35 -0
  25. package/src/providers/http_provider.ts +27 -0
  26. package/src/providers/index.ts +7 -0
  27. package/src/providers/session_provider.ts +29 -0
  28. package/src/providers/view_provider.ts +18 -0
  29. package/src/session/index.ts +4 -0
  30. package/src/session/middleware.ts +46 -0
  31. package/src/session/session.ts +308 -0
  32. package/src/session/session_manager.ts +83 -0
  33. package/src/validation/index.ts +18 -0
  34. package/src/validation/rules.ts +170 -0
  35. package/src/validation/validate.ts +41 -0
  36. package/src/view/cache.ts +47 -0
  37. package/src/view/client/islands.ts +84 -0
  38. package/src/view/compiler.ts +199 -0
  39. package/src/view/engine.ts +139 -0
  40. package/src/view/escape.ts +14 -0
  41. package/src/view/index.ts +13 -0
  42. package/src/view/islands/island_builder.ts +338 -0
  43. package/src/view/islands/vue_plugin.ts +136 -0
  44. package/src/view/middleware/static.ts +69 -0
  45. package/src/view/tokenizer.ts +182 -0
  46. 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
+ }
@@ -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
+ }