@strav/http 0.2.3 → 0.2.7

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.3",
3
+ "version": "0.2.7",
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
+ }
@@ -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,7 +1,9 @@
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'
6
5
  export * from './auth/index.ts'
7
6
  export * from './providers/index.ts'
7
+
8
+ // Route helpers for named route invocation
9
+ export { route, routeUrl } 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
- }
@@ -1,199 +0,0 @@
1
- import { TemplateError } from '@strav/kernel/exceptions/errors'
2
- import type { Token } from './tokenizer.ts'
3
-
4
- export interface CompilationResult {
5
- code: string
6
- layout?: string
7
- }
8
-
9
- interface StackEntry {
10
- type: 'if' | 'each' | 'block'
11
- line: number
12
- blockName?: string
13
- }
14
-
15
- function escapeJs(str: string): string {
16
- return str
17
- .replace(/\\/g, '\\\\')
18
- .replace(/"/g, '\\"')
19
- .replace(/\n/g, '\\n')
20
- .replace(/\r/g, '\\r')
21
- .replace(/\t/g, '\\t')
22
- }
23
-
24
- export function compile(tokens: Token[]): CompilationResult {
25
- const lines: string[] = []
26
- const stack: StackEntry[] = []
27
- let layout: string | undefined
28
-
29
- lines.push('let __out = "";')
30
- lines.push('const __blocks = {};')
31
-
32
- for (const token of tokens) {
33
- switch (token.type) {
34
- case 'text':
35
- lines.push(`__out += "${escapeJs(token.value)}";`)
36
- break
37
-
38
- case 'escaped':
39
- lines.push(`__out += __escape(${token.value});`)
40
- break
41
-
42
- case 'raw':
43
- lines.push(`__out += (${token.value});`)
44
- break
45
-
46
- case 'comment':
47
- // Stripped from output
48
- break
49
-
50
- case 'vue_island': {
51
- const attrs = token.attrs ?? {}
52
- const propParts: string[] = []
53
- for (const [name, attr] of Object.entries(attrs)) {
54
- if (attr.bound) {
55
- propParts.push(`${JSON.stringify(name)}: (${attr.value})`)
56
- } else {
57
- propParts.push(`${JSON.stringify(name)}: ${JSON.stringify(attr.value)}`)
58
- }
59
- }
60
- const propsExpr = `{${propParts.join(', ')}}`
61
- const tag = escapeJs(token.tag!)
62
- lines.push('__out += \'<div data-vue="' + tag + '"\'')
63
- lines.push(
64
- ' + " data-props=\'" + JSON.stringify(' + propsExpr + ").replace(/'/g, '&#39;') + \"'\""
65
- )
66
- lines.push(" + '></div>';")
67
-
68
- break
69
- }
70
-
71
- case 'directive':
72
- compileDirective(token, lines, stack, l => {
73
- layout = l
74
- })
75
- break
76
- }
77
- }
78
-
79
- if (stack.length > 0) {
80
- const unclosed = stack[stack.length - 1]!
81
- throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
82
- }
83
-
84
- lines.push('return { output: __out, blocks: __blocks };')
85
-
86
- return { code: lines.join('\n'), layout }
87
- }
88
-
89
- function compileDirective(
90
- token: Token,
91
- lines: string[],
92
- stack: StackEntry[],
93
- setLayout: (name: string) => void
94
- ): void {
95
- switch (token.directive) {
96
- case 'if':
97
- if (!token.args) throw new TemplateError(`@if requires a condition at line ${token.line}`)
98
- lines.push(`if (${token.args}) {`)
99
- stack.push({ type: 'if', line: token.line })
100
- break
101
-
102
- case 'elseif':
103
- if (!token.args) throw new TemplateError(`@elseif requires a condition at line ${token.line}`)
104
- if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
105
- throw new TemplateError(`@elseif without matching @if at line ${token.line}`)
106
- }
107
- lines.push(`} else if (${token.args}) {`)
108
- break
109
-
110
- case 'else':
111
- if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
112
- throw new TemplateError(`@else without matching @if at line ${token.line}`)
113
- }
114
- lines.push(`} else {`)
115
- break
116
-
117
- case 'each': {
118
- if (!token.args) throw new TemplateError(`@each requires arguments at line ${token.line}`)
119
- const match = token.args.match(/^\s*(\w+)\s+in\s+(.+)$/)
120
- if (!match) {
121
- throw new TemplateError(`@each syntax error at line ${token.line}: expected "item in list"`)
122
- }
123
- const itemName = match[1]!
124
- const listExpr = match[2]!.trim()
125
- lines.push(`{`)
126
- lines.push(` const __list = (${listExpr});`)
127
- lines.push(` for (let $index = 0; $index < __list.length; $index++) {`)
128
- lines.push(` const ${itemName} = __list[$index];`)
129
- lines.push(` const $first = $index === 0;`)
130
- lines.push(` const $last = $index === __list.length - 1;`)
131
- stack.push({ type: 'each', line: token.line })
132
- break
133
- }
134
-
135
- case 'layout': {
136
- if (!token.args) throw new TemplateError(`@layout requires a name at line ${token.line}`)
137
- const name = token.args.replace(/^['"]|['"]$/g, '').trim()
138
- setLayout(name)
139
- break
140
- }
141
-
142
- case 'block': {
143
- if (!token.args) throw new TemplateError(`@block requires a name at line ${token.line}`)
144
- const name = token.args.replace(/^['"]|['"]$/g, '').trim()
145
- const nameStr = JSON.stringify(name)
146
- // If a child template already provided this block as data, yield it.
147
- // Otherwise, render the default content between @block and @end.
148
- lines.push(`if (typeof ${name} !== 'undefined' && ${name} !== null) {`)
149
- lines.push(` __out += ${name};`)
150
- lines.push(` __blocks[${nameStr}] = ${name};`)
151
- lines.push(`} else {`)
152
- lines.push(` __blocks[${nameStr}] = (function() { let __out = "";`)
153
- stack.push({ type: 'block', line: token.line, blockName: name })
154
- break
155
- }
156
-
157
- case 'include': {
158
- if (!token.args) throw new TemplateError(`@include requires arguments at line ${token.line}`)
159
- const match = token.args.match(/^\s*['"]([^'"]+)['"]\s*(?:,\s*(.+))?\s*$/)
160
- if (!match) {
161
- throw new TemplateError(
162
- `@include syntax error at line ${token.line}: expected "'name'" or "'name', data"`
163
- )
164
- }
165
- const name = match[1]!
166
- const dataExpr = match[2] ? match[2].trim() : '{}'
167
- lines.push(`__out += await __include(${JSON.stringify(name)}, ${dataExpr});`)
168
- break
169
- }
170
-
171
- case 'islands': {
172
- const src = token.args ? token.args.replace(/^['"]|['"]$/g, '').trim() : '/islands.js'
173
- // Use __islandsSrc (set by IslandBuilder via ViewEngine.setGlobal) for versioned URL, fallback to static src
174
- lines.push(
175
- `__out += '<script src="' + (typeof __islandsSrc !== 'undefined' ? __islandsSrc : '${escapeJs(src)}') + '"><\\/script>';`
176
- )
177
- break
178
- }
179
-
180
- case 'end': {
181
- if (!stack.length) {
182
- throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
183
- }
184
- const top = stack.pop()!
185
- if (top.type === 'block') {
186
- const nameStr = JSON.stringify(top.blockName!)
187
- lines.push(` return __out; })();`)
188
- lines.push(` __out += __blocks[${nameStr}];`)
189
- lines.push(`}`)
190
- } else if (top.type === 'each') {
191
- lines.push(` }`) // close for loop
192
- lines.push(`}`) // close block scope
193
- } else {
194
- lines.push(`}`)
195
- }
196
- break
197
- }
198
- }
199
- }