@strav/http 0.2.4 → 0.2.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/http",
3
- "version": "0.2.4",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "HTTP layer for the Strav framework — router, server, middleware, authentication, sessions, validation, and views",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "exports": {
21
21
  ".": "./src/index.ts",
22
+ "./client": "./src/client/index.ts",
22
23
  "./http": "./src/http/index.ts",
23
24
  "./http/*": "./src/http/*.ts",
24
25
  "./view": "./src/view/index.ts",
@@ -35,8 +36,8 @@
35
36
  "./providers/*": "./src/providers/*.ts"
36
37
  },
37
38
  "peerDependencies": {
38
- "@strav/kernel": "0.1.4",
39
- "@strav/database": "0.1.4"
39
+ "@strav/kernel": "0.2.6",
40
+ "@strav/database": "0.2.6"
40
41
  },
41
42
  "dependencies": {
42
43
  "@vue/compiler-sfc": "^3.5.28",
@@ -27,7 +27,7 @@ export function guest(redirectTo?: string): Middleware {
27
27
 
28
28
  if (guardName === 'session') {
29
29
  const session = ctx.get<Session>('session')
30
- isAuthenticated = session !== null && session.isAuthenticated && !session.isExpired()
30
+ isAuthenticated = session && session.isAuthenticated && !session.isExpired()
31
31
  } else if (guardName === 'token') {
32
32
  const header = ctx.header('authorization')
33
33
  if (header?.startsWith('Bearer ')) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Browser-safe HTTP utilities from @strav/http
3
+ *
4
+ * This module exports only browser-compatible functionality,
5
+ * avoiding Node.js dependencies that cause bundling issues.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { route, routeUrl } from '@strav/http/client'
10
+ *
11
+ * // Use route helpers on the client-side
12
+ * const response = await route('users.show', { params: { id: 123 } })
13
+ * const profileUrl = routeUrl('users.profile', { id: 456 })
14
+ * ```
15
+ */
16
+
17
+ // Re-export browser-safe route helpers (no Node.js dependencies)
18
+ export { route, routeUrl, registerRoutes } from './route_helper.ts'
19
+ export type { RouteOptions } from './route_helper.ts'
20
+
21
+ // Re-export browser-safe types
22
+ export type {
23
+ Handler,
24
+ Middleware,
25
+ Next
26
+ } from '../http/middleware.ts'
27
+
28
+ export type {
29
+ CorsOptions
30
+ } from '../http/cors.ts'
31
+
32
+ export type {
33
+ CookieOptions
34
+ } from '../http/cookie.ts'
35
+
36
+ export type {
37
+ RouteDefinition,
38
+ GroupOptions,
39
+ WebSocketHandlers,
40
+ WebSocketData
41
+ } from '../http/router.ts'
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Browser-safe route helpers that work without the server-side router instance.
3
+ *
4
+ * These functions maintain the same API as the server-side route helpers but
5
+ * require route definitions to be provided at runtime or fallback to URL construction.
6
+ */
7
+
8
+ export interface RouteOptions extends Omit<RequestInit, 'body'> {
9
+ params?: Record<string, any>
10
+ body?: any
11
+ }
12
+
13
+ // Global registry for client-side route definitions
14
+ const clientRoutes = new Map<string, { method: string; pattern: string }>()
15
+
16
+ /**
17
+ * Register route definitions for client-side use.
18
+ * This should be called during app initialization with route data from the server.
19
+ */
20
+ export function registerRoutes(routes: Record<string, { method: string; pattern: string }>) {
21
+ Object.entries(routes).forEach(([name, def]) => {
22
+ clientRoutes.set(name, def)
23
+ })
24
+ }
25
+
26
+ /**
27
+ * Generate a URL for a named route with optional parameters.
28
+ *
29
+ * @example
30
+ * const profileUrl = routeUrl('users.profile', { id: 456 })
31
+ * // Returns '/users/456'
32
+ */
33
+ export function routeUrl(name: string, params?: Record<string, any>): string {
34
+ const routeDef = clientRoutes.get(name)
35
+ if (!routeDef) {
36
+ throw new Error(`Route '${name}' not found. Make sure to call registerRoutes() with route definitions.`)
37
+ }
38
+
39
+ return generateUrl(routeDef.pattern, params)
40
+ }
41
+
42
+ /**
43
+ * Invoke a named route with automatic method detection and smart defaults.
44
+ *
45
+ * @example
46
+ * // Simple POST with JSON body
47
+ * await route('auth.register', {
48
+ * name: 'John',
49
+ * email: 'john@example.com',
50
+ * password: 'secret'
51
+ * })
52
+ *
53
+ * // GET with URL parameters
54
+ * await route('users.show', { params: { id: 123 } })
55
+ */
56
+ export async function route(
57
+ name: string,
58
+ data?: any,
59
+ options?: RouteOptions
60
+ ): Promise<Response> {
61
+ const routeDef = clientRoutes.get(name)
62
+ if (!routeDef) {
63
+ throw new Error(`Route '${name}' not found. Make sure to call registerRoutes() with route definitions.`)
64
+ }
65
+
66
+ // Determine if data is the body or options
67
+ let body: any
68
+ let opts: RouteOptions = {}
69
+
70
+ if (data !== undefined) {
71
+ // If data has params, body, or any RequestInit properties, treat it as options
72
+ if (
73
+ typeof data === 'object' &&
74
+ !Array.isArray(data) &&
75
+ !(data instanceof FormData) &&
76
+ !(data instanceof Blob) &&
77
+ !(data instanceof ArrayBuffer) &&
78
+ !(data instanceof URLSearchParams) &&
79
+ ('params' in data || 'body' in data || 'headers' in data || 'cache' in data ||
80
+ 'credentials' in data || 'mode' in data || 'redirect' in data || 'referrer' in data)
81
+ ) {
82
+ opts = data
83
+ body = opts.body
84
+ } else {
85
+ // Otherwise, treat data as the body
86
+ body = data
87
+ opts = options || {}
88
+ }
89
+ } else {
90
+ opts = options || {}
91
+ }
92
+
93
+ // Generate URL with parameters
94
+ const generatedUrl = generateUrl(routeDef.pattern, opts.params)
95
+
96
+ // Determine method from route definition
97
+ const method = opts.method || routeDef.method
98
+
99
+ // Build headers with smart defaults
100
+ const headers = new Headers(opts.headers)
101
+
102
+ // Set default Accept header if not provided
103
+ if (!headers.has('Accept')) {
104
+ headers.set('Accept', 'application/json')
105
+ }
106
+
107
+ // Handle body and Content-Type
108
+ let requestBody: string | FormData | Blob | ArrayBuffer | URLSearchParams | undefined
109
+ if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
110
+ if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams) {
111
+ // Let fetch set the Content-Type for FormData, or use the existing type for Blob/ArrayBuffer
112
+ requestBody = body
113
+ } else if (typeof body === 'object') {
114
+ // JSON body
115
+ if (!headers.has('Content-Type')) {
116
+ headers.set('Content-Type', 'application/json')
117
+ }
118
+ requestBody = JSON.stringify(body)
119
+ } else {
120
+ // String or other primitive
121
+ requestBody = String(body)
122
+ if (!headers.has('Content-Type')) {
123
+ headers.set('Content-Type', 'text/plain')
124
+ }
125
+ }
126
+ }
127
+
128
+ // Set default credentials if not provided
129
+ const credentials = opts.credentials || 'same-origin'
130
+
131
+ // Build final fetch options
132
+ const fetchOptions: RequestInit = {
133
+ ...opts,
134
+ method,
135
+ headers,
136
+ credentials,
137
+ ...(requestBody !== undefined && { body: requestBody })
138
+ }
139
+
140
+ // Remove our custom properties
141
+ delete (fetchOptions as any).params
142
+
143
+ return fetch(generatedUrl, fetchOptions)
144
+ }
145
+
146
+ /**
147
+ * Generate URL from pattern and parameters (browser-safe version)
148
+ */
149
+ function generateUrl(pattern: string, params?: Record<string, any>): string {
150
+ let url = pattern
151
+ const queryParams: Record<string, string> = {}
152
+
153
+ if (params) {
154
+ Object.entries(params).forEach(([key, value]) => {
155
+ const paramPattern = `:${key}`
156
+ const wildcardPattern = `*${key}`
157
+
158
+ if (url.includes(paramPattern)) {
159
+ // Replace route parameter
160
+ url = url.replace(paramPattern, encodeURIComponent(String(value)))
161
+ } else if (url.includes(wildcardPattern)) {
162
+ // Replace wildcard parameter
163
+ url = url.replace(`/${wildcardPattern}`, `/${encodeURIComponent(String(value))}`)
164
+ } else {
165
+ // Add as query parameter
166
+ queryParams[key] = String(value)
167
+ }
168
+ })
169
+ }
170
+
171
+ // Append query parameters
172
+ const queryString = Object.keys(queryParams)
173
+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] ?? '')}`)
174
+ .join('&')
175
+
176
+ if (queryString) {
177
+ url += (url.includes('?') ? '&' : '?') + queryString
178
+ }
179
+
180
+ return url
181
+ }
@@ -67,6 +67,22 @@ export default class Context {
67
67
  return this._subdomain
68
68
  }
69
69
 
70
+ /**
71
+ * Get the full origin (protocol + host) of the current request.
72
+ * Uses the X-Forwarded-Proto header if present (common behind proxies),
73
+ * otherwise determines from the request URL.
74
+ */
75
+ getOrigin(): string {
76
+ // Check for forwarded protocol (when behind proxy/load balancer)
77
+ const forwardedProto = this.headers.get('x-forwarded-proto')
78
+ const protocol = forwardedProto ? `${forwardedProto}:` : this.url.protocol
79
+
80
+ // Get host from header (includes port if non-standard)
81
+ const host = this.headers.get('host') || this.url.host
82
+
83
+ return `${protocol}//${host}`
84
+ }
85
+
70
86
  /** Shorthand for reading a single request header. */
71
87
  header(name: string): string | null {
72
88
  return this.headers.get(name)
package/src/http/index.ts CHANGED
@@ -8,7 +8,7 @@ export { compose } from './middleware.ts'
8
8
  export { serializeCookie, parseCookies, withCookie, clearCookie } from './cookie.ts'
9
9
  export { rateLimit, MemoryStore } from './rate_limit.ts'
10
10
  export { Resource } from './resource.ts'
11
- export { route, routeUrl } from './route_helper.ts'
11
+ export { route, routeUrl, routeFullUrl } from './route_helper.ts'
12
12
  export type { Handler, Middleware, Next } from './middleware.ts'
13
13
  export type { GroupOptions, WebSocketHandlers, WebSocketData, RouteDefinition } from './router.ts'
14
14
  export type { CookieOptions } from './cookie.ts'
@@ -1,5 +1,7 @@
1
1
  import { router } from './index.ts'
2
2
  import type { RouteDefinition } from './router.ts'
3
+ import { app } from '@strav/kernel/core/application'
4
+ import Configuration from '@strav/kernel/config/configuration'
3
5
 
4
6
  export interface RouteOptions extends Omit<RequestInit, 'body'> {
5
7
  params?: Record<string, any>
@@ -131,4 +133,62 @@ export async function route(
131
133
  */
132
134
  export function routeUrl(name: string, params?: Record<string, any>): string {
133
135
  return router.generateUrl(name, params)
136
+ }
137
+
138
+ /**
139
+ * Generate a full URL (with protocol and domain) for a named route.
140
+ *
141
+ * Uses the APP_URL from configuration if set, otherwise constructs from
142
+ * the current request context (requires passing the context).
143
+ *
144
+ * @example
145
+ * // With APP_URL configured
146
+ * const resetUrl = routeFullUrl('auth.password.reset', { token: 'abc123' })
147
+ * // Returns 'https://example.com/auth/password-reset?token=abc123'
148
+ *
149
+ * // With request context
150
+ * const profileUrl = routeFullUrl('users.profile', { id: 456 }, ctx)
151
+ * // Returns 'https://example.com/users/456'
152
+ *
153
+ * // Override the base URL
154
+ * const apiUrl = routeFullUrl('api.users', {}, null, 'https://api.example.com')
155
+ * // Returns 'https://api.example.com/api/users'
156
+ */
157
+ export function routeFullUrl(
158
+ name: string,
159
+ params?: Record<string, any>,
160
+ context?: { getOrigin(): string } | null,
161
+ baseUrl?: string
162
+ ): string {
163
+ const path = routeUrl(name, params)
164
+
165
+ // Use provided base URL if given
166
+ if (baseUrl) {
167
+ return baseUrl.replace(/\/$/, '') + path
168
+ }
169
+
170
+ // Try to get app_url from config
171
+ const config = app.resolve(Configuration)
172
+ const appUrl = config.get('http.app_url') as string | undefined
173
+
174
+ if (appUrl) {
175
+ return appUrl.replace(/\/$/, '') + path
176
+ }
177
+
178
+ // Fall back to context origin
179
+ if (context) {
180
+ return context.getOrigin() + path
181
+ }
182
+
183
+ // If no context and no config, construct from http config
184
+ const protocol = config.get('http.secure', false) ? 'https' : 'http'
185
+ const domain = config.get('http.domain', 'localhost') as string
186
+ const port = config.get('http.port', 3000) as number
187
+
188
+ // Only include port if non-standard
189
+ const includePort = (protocol === 'http' && port !== 80) ||
190
+ (protocol === 'https' && port !== 443)
191
+ const host = includePort ? `${domain}:${port}` : domain
192
+
193
+ return `${protocol}://${host}${path}`
134
194
  }
@@ -446,6 +446,17 @@ export default class Router {
446
446
  return this.routes.find(route => route.name === name)
447
447
  }
448
448
 
449
+ /**
450
+ * Get all registered routes.
451
+ *
452
+ * @example
453
+ * const routes = router.getAllRoutes()
454
+ * // Returns array of all route definitions
455
+ */
456
+ getAllRoutes(): readonly RouteDefinition[] {
457
+ return this.routes
458
+ }
459
+
449
460
  /**
450
461
  * Generate a URL for a named route with optional parameters.
451
462
  *
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export * from './http/index.ts'
2
- export * from './view/index.ts'
3
2
  export * from './session/index.ts'
4
3
  export * from './validation/index.ts'
5
4
  export * from './policy/index.ts'
@@ -7,4 +6,4 @@ export * from './auth/index.ts'
7
6
  export * from './providers/index.ts'
8
7
 
9
8
  // Route helpers for named route invocation
10
- export { route, routeUrl } from './http/route_helper.ts'
9
+ export { route, routeUrl, routeFullUrl } from './http/route_helper.ts'
package/src/view/cache.ts DELETED
@@ -1,47 +0,0 @@
1
- export interface RenderResult {
2
- output: string
3
- blocks: Record<string, string>
4
- }
5
-
6
- export type RenderFunction = (
7
- data: Record<string, unknown>,
8
- includeFn: IncludeFn
9
- ) => Promise<RenderResult>
10
-
11
- export type IncludeFn = (name: string, data: Record<string, unknown>) => Promise<string>
12
-
13
- export interface CacheEntry {
14
- fn: RenderFunction
15
- layout?: string
16
- mtime: number
17
- filePath: string
18
- }
19
-
20
- export default class TemplateCache {
21
- private entries = new Map<string, CacheEntry>()
22
-
23
- get(name: string): CacheEntry | undefined {
24
- return this.entries.get(name)
25
- }
26
-
27
- set(name: string, entry: CacheEntry): void {
28
- this.entries.set(name, entry)
29
- }
30
-
31
- async isStale(name: string): Promise<boolean> {
32
- const entry = this.entries.get(name)
33
- if (!entry) return true
34
- const file = Bun.file(entry.filePath)
35
- const exists = await file.exists()
36
- if (!exists) return true
37
- return file.lastModified > entry.mtime
38
- }
39
-
40
- delete(name: string): void {
41
- this.entries.delete(name)
42
- }
43
-
44
- clear(): void {
45
- this.entries.clear()
46
- }
47
- }
@@ -1,84 +0,0 @@
1
- // @ts-nocheck — Client-side script; requires DOM types provided by the app's bundler config.
2
- /**
3
- * Vue Islands Bootstrap
4
- *
5
- * Auto-discovers elements with [data-vue] attributes and mounts
6
- * Vue components on them via a single shared Vue app instance.
7
- * All islands share the same app context (plugins, provide/inject,
8
- * global components), connected to their marker elements via Teleport.
9
- *
10
- * Register your components on the window before this script runs:
11
- *
12
- * import Counter from './components/Counter.vue'
13
- * window.__vue_components = { counter: Counter }
14
- *
15
- * Optionally provide a setup function to install plugins:
16
- *
17
- * window.__vue_setup = (app) => {
18
- * app.use(somePlugin)
19
- * app.provide('key', value)
20
- * }
21
- *
22
- * Then in your .strav templates:
23
- * <vue:counter :initial="{{ count }}" label="Click me" />
24
- */
25
-
26
- import { createApp, defineComponent, h, Teleport } from 'vue'
27
-
28
- declare global {
29
- interface Window {
30
- __vue_components?: Record<string, any>
31
- __vue_setup?: (app: any) => void
32
- }
33
- }
34
-
35
- function toPascalCase(str: string): string {
36
- return str.replace(/(^|-)(\w)/g, (_match, _sep, char) => char.toUpperCase())
37
- }
38
-
39
- function mountIslands(): void {
40
- const components = window.__vue_components ?? {}
41
-
42
- const islands: { Component: any; props: Record<string, any>; el: HTMLElement }[] = []
43
-
44
- document.querySelectorAll<HTMLElement>('[data-vue]').forEach(el => {
45
- const name = el.dataset.vue
46
- if (!name) return
47
-
48
- const Component = components[name] ?? components[toPascalCase(name)]
49
- if (!Component) {
50
- console.warn(`[islands] Unknown component: ${name}`)
51
- return
52
- }
53
-
54
- const props = JSON.parse(el.dataset.props ?? '{}')
55
- islands.push({ Component, props, el })
56
- })
57
-
58
- if (islands.length === 0) return
59
-
60
- const Root = defineComponent({
61
- render() {
62
- return islands.map(island =>
63
- h(Teleport, { to: island.el }, [h(island.Component, island.props)])
64
- )
65
- },
66
- })
67
-
68
- const app = createApp(Root)
69
-
70
- if (typeof window.__vue_setup === 'function') {
71
- window.__vue_setup(app)
72
- }
73
-
74
- const root = document.createElement('div')
75
- root.style.display = 'contents'
76
- document.body.appendChild(root)
77
- app.mount(root)
78
- }
79
-
80
- if (document.readyState === 'loading') {
81
- document.addEventListener('DOMContentLoaded', mountIslands)
82
- } else {
83
- mountIslands()
84
- }