@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,84 @@
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
+ }
@@ -0,0 +1,199 @@
1
+ import { TemplateError } from '@stravigor/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
+ }
@@ -0,0 +1,139 @@
1
+ import { resolve, join } from 'node:path'
2
+ import { inject } from '@stravigor/kernel/core/inject'
3
+ import Configuration from '@stravigor/kernel/config/configuration'
4
+ import { escapeHtml } from './escape.ts'
5
+ import { tokenize } from './tokenizer.ts'
6
+ import { compile } from './compiler.ts'
7
+ import TemplateCache from './cache.ts'
8
+ import type { CacheEntry, RenderFunction, IncludeFn } from './cache.ts'
9
+ import { ConfigurationError, TemplateError } from '@stravigor/kernel/exceptions/errors'
10
+
11
+ const MAX_INCLUDE_DEPTH = 50
12
+
13
+ @inject
14
+ export default class ViewEngine {
15
+ private static _instance: ViewEngine | null = null
16
+ private static _globals: Record<string, unknown> = {}
17
+
18
+ private directory: string
19
+ private cacheEnabled: boolean
20
+ private cache: TemplateCache
21
+
22
+ constructor(config: Configuration) {
23
+ this.directory = resolve(config.get('view.directory', 'resources/views') as string)
24
+ this.cacheEnabled = config.get('view.cache', true) as boolean
25
+ this.cache = new TemplateCache()
26
+ ViewEngine._instance = this
27
+ }
28
+
29
+ static get instance(): ViewEngine {
30
+ if (!ViewEngine._instance) {
31
+ throw new ConfigurationError('ViewEngine not configured. Register it in the container.')
32
+ }
33
+ return ViewEngine._instance
34
+ }
35
+
36
+ /** Register a global variable available in all templates. */
37
+ static setGlobal(key: string, value: unknown): void {
38
+ ViewEngine._globals[key] = value
39
+ }
40
+
41
+ async render(name: string, data: Record<string, unknown> = {}): Promise<string> {
42
+ const merged = { ...ViewEngine._globals, ...data }
43
+ return this.renderWithDepth(name, merged, 0)
44
+ }
45
+
46
+ private async renderWithDepth(
47
+ name: string,
48
+ data: Record<string, unknown>,
49
+ depth: number
50
+ ): Promise<string> {
51
+ if (depth > MAX_INCLUDE_DEPTH) {
52
+ throw new TemplateError(
53
+ `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded — possible circular include`
54
+ )
55
+ }
56
+
57
+ const entry = await this.resolve(name)
58
+
59
+ const includeFn: IncludeFn = (includeName, includeData) => {
60
+ return this.renderWithDepth(includeName, { ...data, ...includeData }, depth + 1)
61
+ }
62
+
63
+ const result = await entry.fn(data, includeFn)
64
+
65
+ // Layout inheritance: render child first, then render layout with blocks merged
66
+ if (entry.layout) {
67
+ const layoutData = { ...data, ...result.blocks }
68
+ return this.renderWithDepth(entry.layout, layoutData, depth + 1)
69
+ }
70
+
71
+ return result.output
72
+ }
73
+
74
+ private async resolve(name: string): Promise<CacheEntry> {
75
+ const cached = this.cache.get(name)
76
+
77
+ if (cached) {
78
+ if (this.cacheEnabled) return cached
79
+ const stale = await this.cache.isStale(name)
80
+ if (!stale) return cached
81
+ }
82
+
83
+ return this.compileTemplate(name)
84
+ }
85
+
86
+ private async compileTemplate(name: string): Promise<CacheEntry> {
87
+ const filePath = this.resolvePath(name)
88
+ const file = Bun.file(filePath)
89
+
90
+ const exists = await file.exists()
91
+ if (!exists) {
92
+ throw new TemplateError(`Template not found: ${name} (looked at ${filePath})`)
93
+ }
94
+
95
+ const source = await file.text()
96
+ const tokens = tokenize(source)
97
+ const result = compile(tokens)
98
+ const fn = this.createRenderFunction(result.code)
99
+
100
+ const entry: CacheEntry = {
101
+ fn,
102
+ layout: result.layout,
103
+ mtime: file.lastModified,
104
+ filePath,
105
+ }
106
+
107
+ this.cache.set(name, entry)
108
+ return entry
109
+ }
110
+
111
+ private resolvePath(name: string): string {
112
+ const relativePath = name.replace(/\./g, '/') + '.strav'
113
+ return join(this.directory, relativePath)
114
+ }
115
+
116
+ private createRenderFunction(code: string): RenderFunction {
117
+ // Use async Function with `with` statement for scope injection.
118
+ // `new Function()` does not inherit strict mode, so `with` is available.
119
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
120
+
121
+ const fn = new AsyncFunction('__data', '__escape', '__include', `with (__data) {\n${code}\n}`)
122
+
123
+ return (data: Record<string, unknown>, includeFn: IncludeFn) => {
124
+ return fn(data, escapeHtml, includeFn)
125
+ }
126
+ }
127
+ }
128
+
129
+ export async function view(
130
+ name: string,
131
+ data: Record<string, unknown> = {},
132
+ status = 200
133
+ ): Promise<Response> {
134
+ const html = await ViewEngine.instance.render(name, data)
135
+ return new Response(html, {
136
+ status,
137
+ headers: { 'Content-Type': 'text/html' },
138
+ })
139
+ }
@@ -0,0 +1,14 @@
1
+ const replacements: Record<string, string> = {
2
+ '&': '&amp;',
3
+ '<': '&lt;',
4
+ '>': '&gt;',
5
+ '"': '&quot;',
6
+ "'": '&#39;',
7
+ }
8
+
9
+ const pattern = /[&<>"']/g
10
+
11
+ export function escapeHtml(value: unknown): string {
12
+ const str = String(value ?? '')
13
+ return str.replace(pattern, ch => replacements[ch]!)
14
+ }
@@ -0,0 +1,13 @@
1
+ export { default as ViewEngine, view } from './engine.ts'
2
+ export { tokenize } from './tokenizer.ts'
3
+ export { compile } from './compiler.ts'
4
+ export { default as TemplateCache } from './cache.ts'
5
+ export { escapeHtml } from './escape.ts'
6
+ export { staticFiles } from './middleware/static.ts'
7
+ export { IslandBuilder } from './islands/island_builder.ts'
8
+ export { vueSfcPlugin } from './islands/vue_plugin.ts'
9
+
10
+ export type { Token, TokenType, VueAttr } from './tokenizer.ts'
11
+ export type { CompilationResult } from './compiler.ts'
12
+ export type { CacheEntry, RenderFunction, IncludeFn, RenderResult } from './cache.ts'
13
+ export type { IslandBuilderOptions, IslandManifest } from './islands/island_builder.ts'