@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
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './http/index.ts'
2
+ export * from './view/index.ts'
3
+ export * from './session/index.ts'
4
+ export * from './validation/index.ts'
5
+ export * from './policy/index.ts'
6
+ export * from './auth/index.ts'
7
+ export * from './providers/index.ts'
@@ -0,0 +1,106 @@
1
+ import type Context from '../http/context.ts'
2
+ import type { Middleware } from '../http/middleware.ts'
3
+ // Moved from @stravigor/kernel/cache — depends on http types
4
+
5
+ export interface HttpCacheOptions {
6
+ /** Cache-Control max-age in seconds. @default 0 */
7
+ maxAge?: number
8
+
9
+ /** Cache-Control s-maxage for shared caches (CDN). */
10
+ sMaxAge?: number
11
+
12
+ /** Cache-Control directives. @default ['public'] */
13
+ directives?: CacheDirective[]
14
+
15
+ /** Add weak ETag header based on response body hash. @default false */
16
+ etag?: boolean
17
+
18
+ /** Vary header values. @default ['Accept-Encoding'] */
19
+ vary?: string[]
20
+
21
+ /** Skip cache headers for certain requests. */
22
+ skip?: (ctx: Context) => boolean
23
+ }
24
+
25
+ export type CacheDirective =
26
+ | 'public'
27
+ | 'private'
28
+ | 'no-cache'
29
+ | 'no-store'
30
+ | 'must-revalidate'
31
+ | 'immutable'
32
+
33
+ /**
34
+ * HTTP cache middleware — sets Cache-Control, ETag, and Vary headers.
35
+ *
36
+ * Only applies to GET and HEAD requests. Browser/CDN does the actual caching.
37
+ * When `etag` is enabled and the request includes a matching `If-None-Match`
38
+ * header, responds with 304 Not Modified.
39
+ *
40
+ * @example
41
+ * router.group({ middleware: [httpCache({ maxAge: 300, etag: true })] }, r => {
42
+ * r.get('/api/categories', listCategories)
43
+ * })
44
+ */
45
+ export function httpCache(options: HttpCacheOptions = {}): Middleware {
46
+ const {
47
+ maxAge = 0,
48
+ sMaxAge,
49
+ directives = ['public'],
50
+ etag: enableEtag = false,
51
+ vary = ['Accept-Encoding'],
52
+ skip,
53
+ } = options
54
+
55
+ const cacheControl = buildCacheControl(directives, maxAge, sMaxAge)
56
+
57
+ return async (ctx, next) => {
58
+ // Only cache GET and HEAD responses
59
+ if (ctx.method !== 'GET' && ctx.method !== 'HEAD') return next()
60
+
61
+ if (skip?.(ctx)) return next()
62
+
63
+ const response = await next()
64
+
65
+ const headers = new Headers(response.headers)
66
+ headers.set('Cache-Control', cacheControl)
67
+
68
+ if (vary.length > 0) {
69
+ const existing = headers.get('Vary')
70
+ const merged = existing ? `${existing}, ${vary.join(', ')}` : vary.join(', ')
71
+ headers.set('Vary', merged)
72
+ }
73
+
74
+ if (enableEtag) {
75
+ const body = await response.clone().arrayBuffer()
76
+ const hash = new Bun.CryptoHasher('md5').update(body).digest('hex')
77
+ const tag = `W/"${hash}"`
78
+
79
+ headers.set('ETag', tag)
80
+
81
+ const ifNoneMatch = ctx.header('if-none-match')
82
+ if (ifNoneMatch === tag) {
83
+ return new Response(null, { status: 304, headers })
84
+ }
85
+
86
+ return new Response(body, {
87
+ status: response.status,
88
+ statusText: response.statusText,
89
+ headers,
90
+ })
91
+ }
92
+
93
+ return new Response(response.body, {
94
+ status: response.status,
95
+ statusText: response.statusText,
96
+ headers,
97
+ })
98
+ }
99
+ }
100
+
101
+ function buildCacheControl(directives: CacheDirective[], maxAge: number, sMaxAge?: number): string {
102
+ const parts = [...directives]
103
+ if (maxAge > 0) parts.push(`max-age=${maxAge}` as CacheDirective)
104
+ if (sMaxAge != null) parts.push(`s-maxage=${sMaxAge}` as CacheDirective)
105
+ return parts.join(', ')
106
+ }
@@ -0,0 +1,84 @@
1
+ import type Context from '../http/context.ts'
2
+ import type { Next } from '../http/middleware.ts'
3
+ import type { Middleware } from '../http/middleware.ts'
4
+ import I18nManager from '@stravigor/kernel/i18n/i18n_manager'
5
+ import { localeStorage } from '@stravigor/kernel/i18n/helpers'
6
+
7
+ /**
8
+ * i18n middleware — detects the request locale and sets it for the
9
+ * duration of the request via `AsyncLocalStorage`.
10
+ *
11
+ * Detection strategies are tried in the order configured in `config/i18n.ts`.
12
+ *
13
+ * @example
14
+ * import { i18n } from '@stravigor/http/middleware/i18n'
15
+ * router.use(i18n())
16
+ */
17
+ export function i18n(): Middleware {
18
+ return (ctx: Context, next: Next) => {
19
+ const detected = detectLocale(ctx)
20
+ return localeStorage.run(detected, () => next())
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Detect locale from the request using configured strategies.
26
+ * Falls back to the default locale if no strategy matches.
27
+ */
28
+ function detectLocale(ctx: Context): string {
29
+ const config = I18nManager.config
30
+ const supported = config.supported
31
+
32
+ for (const strategy of config.detect) {
33
+ switch (strategy) {
34
+ case 'query': {
35
+ const lang = ctx.query.get('lang') ?? ctx.query.get('locale')
36
+ if (lang && supported.includes(lang)) return lang
37
+ break
38
+ }
39
+ case 'cookie': {
40
+ const cookie = ctx.cookie('locale')
41
+ if (cookie && supported.includes(cookie)) return cookie
42
+ break
43
+ }
44
+ case 'header': {
45
+ const match = parseAcceptLanguage(ctx.headers.get('accept-language'), supported)
46
+ if (match) return match
47
+ break
48
+ }
49
+ }
50
+ }
51
+
52
+ return config.default
53
+ }
54
+
55
+ /**
56
+ * Parse the Accept-Language header and return the best match
57
+ * against supported locales.
58
+ *
59
+ * @example
60
+ * parseAcceptLanguage('fr-FR,fr;q=0.9,en;q=0.8', ['en', 'fr']) // 'fr'
61
+ */
62
+ export function parseAcceptLanguage(header: string | null, supported: string[]): string | null {
63
+ if (!header) return null
64
+
65
+ // Parse entries like "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7"
66
+ const entries = header
67
+ .split(',')
68
+ .map(part => {
69
+ const [tag = '', ...rest] = part.trim().split(';')
70
+ const qPart = rest.find(r => r.trim().startsWith('q='))
71
+ const q = qPart ? parseFloat(qPart.trim().slice(2)) : 1.0
72
+ return { tag: tag.trim().toLowerCase(), q: Number.isNaN(q) ? 0 : q }
73
+ })
74
+ .sort((a, b) => b.q - a.q)
75
+
76
+ // Try exact match first, then base language (e.g. 'fr-FR' → 'fr')
77
+ for (const { tag } of entries) {
78
+ if (supported.includes(tag)) return tag
79
+ const base = tag.split('-')[0]!
80
+ if (supported.includes(base)) return base
81
+ }
82
+
83
+ return null
84
+ }
@@ -0,0 +1,19 @@
1
+ import type { Middleware } from '../http/middleware.ts'
2
+ import type Logger from '@stravigor/kernel/logger/logger'
3
+
4
+ export function requestLogger(logger: Logger): Middleware {
5
+ return async (ctx, next) => {
6
+ const start = performance.now()
7
+ const response = await next()
8
+ const duration = Math.round(performance.now() - start)
9
+
10
+ logger.info(`${ctx.method} ${ctx.path} ${response.status} ${duration}ms`, {
11
+ method: ctx.method,
12
+ path: ctx.path,
13
+ status: response.status,
14
+ duration,
15
+ })
16
+
17
+ return response
18
+ }
19
+ }
@@ -0,0 +1,44 @@
1
+ import type { Middleware } from '../http/middleware.ts'
2
+ import type { PolicyResult } from './policy_result.ts'
3
+
4
+ type PolicyReturn = PolicyResult | Promise<PolicyResult>
5
+
6
+ export function authorize(
7
+ policy: Record<string, (...args: any[]) => PolicyReturn>,
8
+ method: string,
9
+ loadResource?: (ctx: import('../http/context.ts').default) => Promise<unknown>
10
+ ): Middleware {
11
+ return async (ctx, next) => {
12
+ const user = ctx.get('user')
13
+ if (!user) {
14
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
15
+ status: 401,
16
+ headers: { 'Content-Type': 'application/json' },
17
+ })
18
+ }
19
+
20
+ let resource: unknown
21
+ if (loadResource) {
22
+ resource = await loadResource(ctx)
23
+ ctx.set('resource', resource)
24
+ }
25
+
26
+ const policyMethod = policy[method]
27
+ if (!policyMethod) {
28
+ return new Response(JSON.stringify({ error: 'Forbidden' }), {
29
+ status: 403,
30
+ headers: { 'Content-Type': 'application/json' },
31
+ })
32
+ }
33
+
34
+ const access = await policyMethod(user, resource)
35
+ if (!access.allowed) {
36
+ return new Response(JSON.stringify({ error: access.reason }), {
37
+ status: access.status,
38
+ headers: { 'Content-Type': 'application/json' },
39
+ })
40
+ }
41
+
42
+ return next()
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ export { authorize } from './authorize.ts'
2
+ export { allow, deny } from './policy_result.ts'
3
+ export type { PolicyResult } from './policy_result.ts'
@@ -0,0 +1,13 @@
1
+ export type PolicyResult = {
2
+ allowed: boolean
3
+ status: number
4
+ reason: string
5
+ }
6
+
7
+ export function allow(): PolicyResult {
8
+ return { allowed: true, status: 200, reason: '' }
9
+ }
10
+
11
+ export function deny(status = 403, reason = 'Action forbidden'): PolicyResult {
12
+ return { allowed: false, status, reason }
13
+ }
@@ -0,0 +1,35 @@
1
+ import ServiceProvider from '@stravigor/kernel/core/service_provider'
2
+ import type Application from '@stravigor/kernel/core/application'
3
+ import Auth from '../auth/auth.ts'
4
+
5
+ export interface AuthProviderOptions {
6
+ /** Function to load a user by ID. Required for auth middleware. */
7
+ resolver?: (id: string | number) => Promise<unknown>
8
+ /** Whether to auto-create the access_tokens table. Default: `true` */
9
+ ensureTables?: boolean
10
+ }
11
+
12
+ export default class AuthProvider extends ServiceProvider {
13
+ readonly name = 'auth'
14
+ override readonly dependencies = ['database']
15
+
16
+ constructor(private options?: AuthProviderOptions) {
17
+ super()
18
+ }
19
+
20
+ override register(app: Application): void {
21
+ app.singleton(Auth)
22
+ }
23
+
24
+ override async boot(app: Application): Promise<void> {
25
+ app.resolve(Auth)
26
+
27
+ if (this.options?.resolver) {
28
+ Auth.useResolver(this.options.resolver)
29
+ }
30
+
31
+ if (this.options?.ensureTables !== false) {
32
+ await Auth.ensureTables()
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,27 @@
1
+ import ServiceProvider from '@stravigor/kernel/core/service_provider'
2
+ import type Application from '@stravigor/kernel/core/application'
3
+ import Server from '../http/server.ts'
4
+ import Router from '../http/router.ts'
5
+
6
+ export default class HttpProvider extends ServiceProvider {
7
+ readonly name = 'http'
8
+ override readonly dependencies = ['config']
9
+
10
+ private server: Server | null = null
11
+
12
+ override register(app: Application): void {
13
+ if (!app.has(Router)) app.singleton(Router)
14
+ app.singleton(Server)
15
+ }
16
+
17
+ override boot(app: Application): void {
18
+ const router = app.resolve(Router)
19
+ this.server = app.resolve(Server)
20
+ this.server.start(router)
21
+ }
22
+
23
+ override shutdown(): void {
24
+ this.server?.stop()
25
+ this.server = null
26
+ }
27
+ }
@@ -0,0 +1,7 @@
1
+ export { default as HttpProvider } from './http_provider.ts'
2
+ export { default as AuthProvider } from './auth_provider.ts'
3
+ export { default as SessionProvider } from './session_provider.ts'
4
+ export { default as ViewProvider } from './view_provider.ts'
5
+
6
+ export type { AuthProviderOptions } from './auth_provider.ts'
7
+ export type { SessionProviderOptions } from './session_provider.ts'
@@ -0,0 +1,29 @@
1
+ import ServiceProvider from '@stravigor/kernel/core/service_provider'
2
+ import type Application from '@stravigor/kernel/core/application'
3
+ import SessionManager from '../session/session_manager.ts'
4
+
5
+ export interface SessionProviderOptions {
6
+ /** Whether to auto-create the sessions table. Default: `true` */
7
+ ensureTable?: boolean
8
+ }
9
+
10
+ export default class SessionProvider extends ServiceProvider {
11
+ readonly name = 'session'
12
+ override readonly dependencies = ['database']
13
+
14
+ constructor(private options?: SessionProviderOptions) {
15
+ super()
16
+ }
17
+
18
+ override register(app: Application): void {
19
+ app.singleton(SessionManager)
20
+ }
21
+
22
+ override async boot(app: Application): Promise<void> {
23
+ app.resolve(SessionManager)
24
+
25
+ if (this.options?.ensureTable !== false) {
26
+ await SessionManager.ensureTable()
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,18 @@
1
+ import ServiceProvider from '@stravigor/kernel/core/service_provider'
2
+ import type Application from '@stravigor/kernel/core/application'
3
+ import { ViewEngine } from '@stravigor/view'
4
+ import Context from '../http/context.ts'
5
+
6
+ export default class ViewProvider extends ServiceProvider {
7
+ readonly name = 'view'
8
+ override readonly dependencies = ['config']
9
+
10
+ override register(app: Application): void {
11
+ app.singleton(ViewEngine)
12
+ }
13
+
14
+ override boot(app: Application): void {
15
+ const engine = app.resolve(ViewEngine)
16
+ Context.setViewEngine(engine)
17
+ }
18
+ }
@@ -0,0 +1,4 @@
1
+ export { default as Session } from './session.ts'
2
+ export { default as SessionManager } from './session_manager.ts'
3
+ export { session } from './middleware.ts'
4
+ export type { SessionConfig } from './session_manager.ts'
@@ -0,0 +1,46 @@
1
+ import type { Middleware } from '../http/middleware.ts'
2
+ import { withCookie } from '../http/cookie.ts'
3
+ import Session from './session.ts'
4
+ import SessionManager from './session_manager.ts'
5
+
6
+ /**
7
+ * Session middleware — attaches a Session to every request.
8
+ *
9
+ * 1. Reads the session cookie and loads the session from DB
10
+ * 2. Creates a new anonymous session if absent or expired
11
+ * 3. Ages flash data so previous-request flash is readable
12
+ * 4. Sets `ctx.get('session')` and `ctx.get('csrfToken')`
13
+ * 5. After the handler: saves dirty data and refreshes the cookie
14
+ *
15
+ * @example
16
+ * import { session } from '@stravigor/http/session'
17
+ * router.use(session())
18
+ */
19
+ export function session(): Middleware {
20
+ return async (ctx, next) => {
21
+ let sess = await Session.fromRequest(ctx)
22
+
23
+ if (!sess || sess.isExpired()) {
24
+ sess = Session.create(ctx)
25
+ }
26
+
27
+ sess.ageFlash()
28
+
29
+ ctx.set('session', sess)
30
+ ctx.set('csrfToken', sess.csrfToken)
31
+
32
+ const response = await next()
33
+
34
+ await sess.save()
35
+
36
+ // Refresh cookie (sliding expiration)
37
+ const cfg = SessionManager.config
38
+ return withCookie(response, cfg.cookie, sess.id, {
39
+ httpOnly: cfg.httpOnly,
40
+ secure: cfg.secure,
41
+ sameSite: cfg.sameSite,
42
+ maxAge: cfg.lifetime * 60,
43
+ path: '/',
44
+ })
45
+ }
46
+ }