@stravigor/core 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/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -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/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -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/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -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 +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -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 +50 -0
- package/src/view/compiler.ts +185 -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 +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an environment variable with an optional default.
|
|
3
|
+
*
|
|
4
|
+
* Bun auto-loads `.env`, so all variables are available via `process.env`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* env('DB_HOST', '127.0.0.1')
|
|
8
|
+
* env('APP_KEY') // throws if not set and no default
|
|
9
|
+
*/
|
|
10
|
+
function env(key: string, defaultValue: string): string
|
|
11
|
+
function env(key: string, defaultValue: null): string | null
|
|
12
|
+
function env(key: string): string
|
|
13
|
+
function env(key: string, defaultValue?: string | null): string | null {
|
|
14
|
+
const value = process.env[key]
|
|
15
|
+
if (value !== undefined) return value
|
|
16
|
+
if (defaultValue !== undefined) return defaultValue
|
|
17
|
+
throw new Error(`Environment variable "${key}" is not set`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read an environment variable as an integer. */
|
|
21
|
+
env.int = (key: string, defaultValue?: number): number => {
|
|
22
|
+
const raw = process.env[key]
|
|
23
|
+
if (raw !== undefined) {
|
|
24
|
+
const parsed = parseInt(raw, 10)
|
|
25
|
+
if (!isNaN(parsed)) return parsed
|
|
26
|
+
}
|
|
27
|
+
if (defaultValue !== undefined) return defaultValue
|
|
28
|
+
throw new Error(`Environment variable "${key}" is not set or not a valid integer`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read an environment variable as a float. */
|
|
32
|
+
env.float = (key: string, defaultValue?: number): number => {
|
|
33
|
+
const raw = process.env[key]
|
|
34
|
+
if (raw !== undefined) {
|
|
35
|
+
const parsed = parseFloat(raw)
|
|
36
|
+
if (!isNaN(parsed)) return parsed
|
|
37
|
+
}
|
|
38
|
+
if (defaultValue !== undefined) return defaultValue
|
|
39
|
+
throw new Error(`Environment variable "${key}" is not set or not a valid number`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read an environment variable as a boolean. Truthy: `'true'`, `'1'`, `'yes'`. */
|
|
43
|
+
env.bool = (key: string, defaultValue?: boolean): boolean => {
|
|
44
|
+
const raw = process.env[key]
|
|
45
|
+
if (raw !== undefined) return ['true', '1', 'yes'].includes(raw.toLowerCase())
|
|
46
|
+
if (defaultValue !== undefined) return defaultValue
|
|
47
|
+
throw new Error(`Environment variable "${key}" is not set`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { env }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import BaseModel from '../orm/base_model.ts'
|
|
2
|
+
|
|
3
|
+
/** Extract a user ID from a BaseModel instance or a raw string/number. */
|
|
4
|
+
export function extractUserId(user: unknown): string {
|
|
5
|
+
if (typeof user === 'string') return user
|
|
6
|
+
if (typeof user === 'number') return String(user)
|
|
7
|
+
if (user instanceof BaseModel) {
|
|
8
|
+
const ctor = user.constructor as typeof BaseModel
|
|
9
|
+
return String((user as unknown as Record<string, unknown>)[ctor.primaryKeyProperty])
|
|
10
|
+
}
|
|
11
|
+
throw new Error('Pass a BaseModel instance or a string/number user ID.')
|
|
12
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a camelCase or PascalCase string to snake_case.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* toSnakeCase('firstName') // 'first_name'
|
|
6
|
+
* toSnakeCase('HTMLParser') // 'html_parser'
|
|
7
|
+
* toSnakeCase('already_snake') // 'already_snake'
|
|
8
|
+
*/
|
|
9
|
+
export function toSnakeCase(str: string): string {
|
|
10
|
+
return str
|
|
11
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
12
|
+
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a snake_case string to camelCase.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* toCamelCase('first_name') // 'firstName'
|
|
21
|
+
* toCamelCase('created_at') // 'createdAt'
|
|
22
|
+
* toCamelCase('reviewed_by_id') // 'reviewedById'
|
|
23
|
+
* toCamelCase('id') // 'id'
|
|
24
|
+
*/
|
|
25
|
+
export function toCamelCase(str: string): string {
|
|
26
|
+
return str.replace(/_([a-z\d])/g, (_, c) => c.toUpperCase())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert a snake_case or plain string to PascalCase.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* toPascalCase('user_role') // 'UserRole'
|
|
34
|
+
* toPascalCase('user') // 'User'
|
|
35
|
+
* toPascalCase('order_item') // 'OrderItem'
|
|
36
|
+
*/
|
|
37
|
+
export function toPascalCase(str: string): string {
|
|
38
|
+
const camel = toCamelCase(str)
|
|
39
|
+
return camel.charAt(0).toUpperCase() + camel.slice(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Naively pluralize an English word.
|
|
44
|
+
*
|
|
45
|
+
* Handles common suffixes (s/x/z/ch/sh → es, consonant+y → ies).
|
|
46
|
+
* Not a full inflection library — adequate for code-gen route paths.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* pluralize('user') // 'users'
|
|
50
|
+
* pluralize('category') // 'categories'
|
|
51
|
+
* pluralize('status') // 'statuses'
|
|
52
|
+
*/
|
|
53
|
+
export function pluralize(word: string): string {
|
|
54
|
+
if (
|
|
55
|
+
word.endsWith('s') ||
|
|
56
|
+
word.endsWith('x') ||
|
|
57
|
+
word.endsWith('z') ||
|
|
58
|
+
word.endsWith('ch') ||
|
|
59
|
+
word.endsWith('sh')
|
|
60
|
+
) {
|
|
61
|
+
return word + 'es'
|
|
62
|
+
}
|
|
63
|
+
if (word.endsWith('y') && !/[aeiou]y$/.test(word)) {
|
|
64
|
+
return word.slice(0, -1) + 'ies'
|
|
65
|
+
}
|
|
66
|
+
return word + 's'
|
|
67
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { parseCookies } from './cookie.ts'
|
|
2
|
+
import type ViewEngine from '../view/engine.ts'
|
|
3
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTTP request context — the primary object handlers interact with.
|
|
7
|
+
*
|
|
8
|
+
* Wraps Bun's native Request and adds route params, body parsing,
|
|
9
|
+
* response helpers, and a type-safe state bag for middleware.
|
|
10
|
+
*/
|
|
11
|
+
export default class Context {
|
|
12
|
+
private static _viewEngine: ViewEngine | null = null
|
|
13
|
+
|
|
14
|
+
static setViewEngine(engine: ViewEngine): void {
|
|
15
|
+
Context._viewEngine = engine
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
readonly url: URL
|
|
19
|
+
readonly method: string
|
|
20
|
+
readonly path: string
|
|
21
|
+
readonly headers: Headers
|
|
22
|
+
|
|
23
|
+
private _state = new Map<string, unknown>()
|
|
24
|
+
private _subdomain?: string
|
|
25
|
+
private _query?: URLSearchParams
|
|
26
|
+
private _cookies?: Map<string, string>
|
|
27
|
+
private _body?: unknown
|
|
28
|
+
private _bodyParsed = false
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
readonly request: Request,
|
|
32
|
+
readonly params: Record<string, string> = {},
|
|
33
|
+
private domain: string = 'localhost'
|
|
34
|
+
) {
|
|
35
|
+
this.url = new URL(request.url)
|
|
36
|
+
this.method = request.method
|
|
37
|
+
this.path = this.url.pathname
|
|
38
|
+
this.headers = request.headers
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Request helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Parsed query string parameters. */
|
|
46
|
+
get query(): URLSearchParams {
|
|
47
|
+
if (!this._query) this._query = this.url.searchParams
|
|
48
|
+
return this._query
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subdomain extracted from the Host header relative to the configured domain. */
|
|
52
|
+
get subdomain(): string {
|
|
53
|
+
if (this._subdomain !== undefined) return this._subdomain
|
|
54
|
+
|
|
55
|
+
const host = this.headers.get('host') ?? ''
|
|
56
|
+
const hostname = host.split(':')[0] ?? ''
|
|
57
|
+
|
|
58
|
+
if (hostname.endsWith(this.domain) && hostname.length > this.domain.length) {
|
|
59
|
+
this._subdomain = hostname.slice(0, -(this.domain.length + 1))
|
|
60
|
+
} else {
|
|
61
|
+
this._subdomain = ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this._subdomain
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shorthand for reading a single request header. */
|
|
68
|
+
header(name: string): string | null {
|
|
69
|
+
return this.headers.get(name)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read a query string parameter, with optional typed default. */
|
|
73
|
+
qs(name: string): string | null
|
|
74
|
+
qs(name: string, defaultValue: number): number
|
|
75
|
+
qs(name: string, defaultValue: string): string
|
|
76
|
+
qs(name: string, defaultValue?: string | number): string | number | null {
|
|
77
|
+
const value = this.query.get(name)
|
|
78
|
+
if (value === null || value === '') return defaultValue ?? null
|
|
79
|
+
if (typeof defaultValue === 'number') {
|
|
80
|
+
const parsed = Number(value)
|
|
81
|
+
return Number.isNaN(parsed) ? defaultValue : parsed
|
|
82
|
+
}
|
|
83
|
+
return value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read a cookie value by name from the Cookie header. */
|
|
87
|
+
cookie(name: string): string | null {
|
|
88
|
+
if (!this._cookies) {
|
|
89
|
+
this._cookies = parseCookies(this.headers.get('cookie') ?? '')
|
|
90
|
+
}
|
|
91
|
+
return this._cookies.get(name) ?? null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract named string fields from a form body. With no args, returns all non-file fields. */
|
|
95
|
+
async inputs<K extends string>(...keys: K[]): Promise<Record<K, string>> {
|
|
96
|
+
const form = await this.body<FormData>()
|
|
97
|
+
const result = {} as Record<K, string>
|
|
98
|
+
if (keys.length === 0) {
|
|
99
|
+
form.forEach((value, key) => {
|
|
100
|
+
if (typeof value === 'string') (result as Record<string, string>)[key] = value
|
|
101
|
+
})
|
|
102
|
+
} else {
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const value = form.get(key)
|
|
105
|
+
result[key] = typeof value === 'string' ? value : ''
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extract named file fields from a form body. With no args, returns all file fields. */
|
|
112
|
+
async files<K extends string>(...keys: K[]): Promise<Record<K, File | null>> {
|
|
113
|
+
const form = await this.body<FormData>()
|
|
114
|
+
const result = {} as Record<K, File | null>
|
|
115
|
+
if (keys.length === 0) {
|
|
116
|
+
form.forEach((value, key) => {
|
|
117
|
+
if (value instanceof File) (result as Record<string, File>)[key] = value
|
|
118
|
+
})
|
|
119
|
+
} else {
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
const value = form.get(key)
|
|
122
|
+
result[key] = value instanceof File ? value : null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse the request body. Automatically detects JSON, form-data, and text.
|
|
130
|
+
* The result is cached — safe to call multiple times.
|
|
131
|
+
*/
|
|
132
|
+
async body<T = unknown>(): Promise<T> {
|
|
133
|
+
if (!this._bodyParsed) {
|
|
134
|
+
const contentType = this.header('content-type') ?? ''
|
|
135
|
+
|
|
136
|
+
if (contentType.includes('application/json')) {
|
|
137
|
+
this._body = await this.request.json()
|
|
138
|
+
} else if (
|
|
139
|
+
contentType.includes('multipart/form-data') ||
|
|
140
|
+
contentType.includes('application/x-www-form-urlencoded')
|
|
141
|
+
) {
|
|
142
|
+
this._body = await this.request.formData()
|
|
143
|
+
} else {
|
|
144
|
+
this._body = await this.request.text()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this._bodyParsed = true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return this._body as T
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Response helpers
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
json(data: unknown, status = 200): Response {
|
|
158
|
+
return new Response(JSON.stringify(data), {
|
|
159
|
+
status,
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
text(content: string, status = 200): Response {
|
|
165
|
+
return new Response(content, {
|
|
166
|
+
status,
|
|
167
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
html(content: string, status = 200): Response {
|
|
172
|
+
return new Response(content, {
|
|
173
|
+
status,
|
|
174
|
+
headers: { 'Content-Type': 'text/html' },
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
redirect(url: string, status = 302): Response {
|
|
179
|
+
return new Response(null, {
|
|
180
|
+
status,
|
|
181
|
+
headers: { Location: url },
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
empty(status = 204): Response {
|
|
186
|
+
return new Response(null, { status })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async view(template: string, data?: Record<string, unknown>, status = 200): Promise<Response> {
|
|
190
|
+
if (!Context._viewEngine) {
|
|
191
|
+
throw new ConfigurationError('ViewEngine not configured. Register it in the container.')
|
|
192
|
+
}
|
|
193
|
+
const html = await Context._viewEngine.render(template, data)
|
|
194
|
+
return this.html(html, status)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Middleware state
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/** Store a value for downstream middleware / handlers. */
|
|
202
|
+
set<T>(key: string, value: T): void {
|
|
203
|
+
this._state.set(key, value)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Retrieve a value set by upstream middleware. */
|
|
207
|
+
get<T>(key: string): T
|
|
208
|
+
get<T1, T2>(k1: string, k2: string): [T1, T2]
|
|
209
|
+
get<T1, T2, T3>(k1: string, k2: string, k3: string): [T1, T2, T3]
|
|
210
|
+
get<T1, T2, T3, T4>(k1: string, k2: string, k3: string, k4: string): [T1, T2, T3, T4]
|
|
211
|
+
get(...keys: string[]): unknown {
|
|
212
|
+
if (keys.length === 1) return this._state.get(keys[0]!)
|
|
213
|
+
return keys.map(k => this._state.get(k))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -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,16 @@
|
|
|
1
|
+
import { app } from '../core/application.ts'
|
|
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 type { Handler, Middleware, Next } from './middleware.ts'
|
|
11
|
+
export type { GroupOptions, WebSocketHandlers, WebSocketData } from './router.ts'
|
|
12
|
+
export type { CookieOptions } from './cookie.ts'
|
|
13
|
+
export type { CorsOptions } from './cors.ts'
|
|
14
|
+
export type { RateLimitOptions, RateLimitStore, RateLimitInfo } from './rate_limit.ts'
|
|
15
|
+
|
|
16
|
+
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
|
+
}
|