@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,338 @@
1
+ import { resolve, join } from 'node:path'
2
+ import {
3
+ readdirSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ unlinkSync,
7
+ watch as fsWatch,
8
+ type FSWatcher,
9
+ } from 'node:fs'
10
+ import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'
11
+ import { vueSfcPlugin } from './vue_plugin.ts'
12
+ import ViewEngine from '../engine.ts'
13
+ import type { BunPlugin } from 'bun'
14
+
15
+ export interface IslandBuilderOptions {
16
+ /** Directory containing .vue SFC files. Default: './resources/islands' */
17
+ islandsDir?: string
18
+ /** Directory where the bundle is output. Default: './public/builds' */
19
+ outDir?: string
20
+ /** Output filename. Default: 'islands.js' */
21
+ outFile?: string
22
+ /** Enable minification. Default: true in production */
23
+ minify?: boolean
24
+ /** Enable pre-compression (gzip + brotli). Default: true */
25
+ compress?: boolean
26
+ /** Base URL path for the islands script. Default: '/builds/' */
27
+ basePath?: string
28
+ }
29
+
30
+ export interface IslandManifest {
31
+ file: string
32
+ version: string
33
+ src: string
34
+ size: number
35
+ gzip?: number
36
+ brotli?: number
37
+ }
38
+
39
+ export class IslandBuilder {
40
+ private islandsDir: string
41
+ private outDir: string
42
+ private outFile: string
43
+ private minify: boolean
44
+ private compress: boolean
45
+ private basePath: string
46
+ private watcher: FSWatcher | null = null
47
+ private _version: string | null = null
48
+ private _manifest: IslandManifest | null = null
49
+
50
+ constructor(options: IslandBuilderOptions = {}) {
51
+ this.islandsDir = resolve(options.islandsDir ?? './resources/islands')
52
+ this.outDir = resolve(options.outDir ?? './public/builds')
53
+ this.outFile = options.outFile ?? 'islands.js'
54
+ this.minify = options.minify ?? Bun.env.NODE_ENV === 'production'
55
+ this.compress = options.compress ?? true
56
+ this.basePath = options.basePath ?? '/builds/'
57
+ }
58
+
59
+ /** The content hash of the last build, or null if not yet built. */
60
+ get version(): string | null {
61
+ return this._version
62
+ }
63
+
64
+ /** The versioned script src (e.g. '/islands.js?v=abc12345'), or the plain path if not yet built. */
65
+ get src(): string {
66
+ const base = this.basePath + this.outFile
67
+ return this._version ? `${base}?v=${this._version}` : base
68
+ }
69
+
70
+ /** The build manifest with file info and sizes, or null if not yet built. */
71
+ get manifest(): IslandManifest | null {
72
+ return this._manifest
73
+ }
74
+
75
+ /** Discover all .vue files in the islands directory (recursively). */
76
+ private discoverIslands(): { name: string; path: string }[] {
77
+ let entries: string[]
78
+ try {
79
+ entries = readdirSync(this.islandsDir, { recursive: true }) as string[]
80
+ } catch {
81
+ return []
82
+ }
83
+
84
+ return entries
85
+ .filter(f => f.endsWith('.vue'))
86
+ .sort()
87
+ .map(f => ({
88
+ name: f.slice(0, -4).replace(/\\/g, '/'),
89
+ path: join(this.islandsDir, f),
90
+ }))
91
+ }
92
+
93
+ /** Check if a setup file exists in the islands directory. */
94
+ private hasSetupFile(): string | null {
95
+ for (const ext of ['ts', 'js']) {
96
+ const p = join(this.islandsDir, `setup.${ext}`)
97
+ if (existsSync(p)) return p
98
+ }
99
+ return null
100
+ }
101
+
102
+ /** Generate the virtual entry point that imports all islands + mount logic. */
103
+ private generateEntry(islands: { name: string; path: string }[]): string {
104
+ const setupPath = this.hasSetupFile()
105
+ const lines: string[] = []
106
+
107
+ lines.push(`import { createApp, defineComponent, h, Teleport } from 'vue';`)
108
+ lines.push('')
109
+
110
+ if (setupPath) {
111
+ lines.push(`import __setup from '${setupPath}';`)
112
+ lines.push('')
113
+ }
114
+
115
+ // Import each island component
116
+ for (let i = 0; i < islands.length; i++) {
117
+ lines.push(`import __c${i} from '${islands[i]!.path}';`)
118
+ }
119
+
120
+ lines.push('')
121
+ lines.push('var components = {')
122
+ for (let i = 0; i < islands.length; i++) {
123
+ lines.push(` '${islands[i]!.name}': __c${i},`)
124
+ }
125
+ lines.push('};')
126
+
127
+ lines.push('')
128
+ lines.push('function mountIslands() {')
129
+ lines.push(' var islands = [];')
130
+ lines.push(" document.querySelectorAll('[data-vue]').forEach(function(el) {")
131
+ lines.push(' var name = el.dataset.vue;')
132
+ lines.push(' if (!name) return;')
133
+ lines.push(' var Component = components[name];')
134
+ lines.push(' if (!Component) {')
135
+ lines.push(" console.warn('[islands] Unknown component: ' + name);")
136
+ lines.push(' return;')
137
+ lines.push(' }')
138
+ lines.push(" var props = JSON.parse(el.dataset.props || '{}');")
139
+ lines.push(' islands.push({ Component: Component, props: props, el: el });')
140
+ lines.push(' });')
141
+ lines.push('')
142
+ lines.push(' if (islands.length === 0) return;')
143
+ lines.push('')
144
+ lines.push(' var Root = defineComponent({')
145
+ lines.push(' render: function() {')
146
+ lines.push(' return islands.map(function(island) {')
147
+ lines.push(
148
+ ' return h(Teleport, { to: island.el }, [h(island.Component, island.props)]);'
149
+ )
150
+ lines.push(' });')
151
+ lines.push(' }')
152
+ lines.push(' });')
153
+ lines.push('')
154
+ lines.push(' var app = createApp(Root);')
155
+ if (setupPath) {
156
+ lines.push(' if (typeof __setup === "function") __setup(app);')
157
+ }
158
+ lines.push(' var root = document.createElement("div");')
159
+ lines.push(' root.style.display = "contents";')
160
+ lines.push(' document.body.appendChild(root);')
161
+ lines.push(' app.mount(root);')
162
+ lines.push('}')
163
+ lines.push('')
164
+ lines.push("if (document.readyState === 'loading') {")
165
+ lines.push(" document.addEventListener('DOMContentLoaded', mountIslands);")
166
+ lines.push('} else {')
167
+ lines.push(' mountIslands();')
168
+ lines.push('}')
169
+
170
+ return lines.join('\n')
171
+ }
172
+
173
+ /** Compute a short content hash for cache busting. */
174
+ private computeHash(content: Uint8Array): string {
175
+ const hasher = new Bun.CryptoHasher('md5')
176
+ hasher.update(content)
177
+ return hasher.digest('hex').slice(0, 8)
178
+ }
179
+
180
+ /** Generate pre-compressed versions of the bundle. */
181
+ private async generateCompressed(
182
+ outPath: string,
183
+ content: Uint8Array
184
+ ): Promise<{ gzip?: number; brotli?: number }> {
185
+ const sizes: { gzip?: number; brotli?: number } = {}
186
+
187
+ // Gzip
188
+ const gzipped = Bun.gzipSync(content as Uint8Array<ArrayBuffer>)
189
+ await Bun.write(outPath + '.gz', gzipped)
190
+ sizes.gzip = gzipped.length
191
+
192
+ // Brotli
193
+ try {
194
+ const brotli = brotliCompressSync(Buffer.from(content), {
195
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
196
+ })
197
+ await Bun.write(outPath + '.br', brotli)
198
+ sizes.brotli = brotli.length
199
+ } catch {
200
+ // Brotli may not be available in all environments
201
+ }
202
+
203
+ return sizes
204
+ }
205
+
206
+ /** Remove stale compressed files. */
207
+ private cleanCompressed(outPath: string): void {
208
+ for (const ext of ['.gz', '.br']) {
209
+ try {
210
+ unlinkSync(outPath + ext)
211
+ } catch {
212
+ // File may not exist
213
+ }
214
+ }
215
+ }
216
+
217
+ /** Update the ViewEngine global so @islands() picks up the versioned src. */
218
+ private syncViewEngine(): void {
219
+ try {
220
+ ViewEngine.setGlobal('__islandsSrc', this.src)
221
+ } catch {
222
+ // ViewEngine may not be initialized yet
223
+ }
224
+ }
225
+
226
+ /** Build the islands bundle. Returns true if islands were found and built. */
227
+ async build(): Promise<boolean> {
228
+ const islands = this.discoverIslands()
229
+
230
+ if (islands.length === 0) {
231
+ return false
232
+ }
233
+
234
+ // Ensure output directory exists
235
+ mkdirSync(this.outDir, { recursive: true })
236
+
237
+ const entrySource = this.generateEntry(islands)
238
+
239
+ // Virtual entry plugin — resolves the synthetic entry from memory
240
+ const virtualEntryPlugin: BunPlugin = {
241
+ name: 'virtual-entry',
242
+ setup(build) {
243
+ build.onResolve({ filter: /^virtual:islands-entry$/ }, () => ({
244
+ path: 'virtual:islands-entry',
245
+ namespace: 'island-entry',
246
+ }))
247
+
248
+ build.onLoad({ filter: /.*/, namespace: 'island-entry' }, () => ({
249
+ contents: entrySource,
250
+ loader: 'js',
251
+ }))
252
+ },
253
+ }
254
+
255
+ const result = await Bun.build({
256
+ entrypoints: ['virtual:islands-entry'],
257
+ outdir: this.outDir,
258
+ naming: this.outFile,
259
+ minify: this.minify,
260
+ target: 'browser',
261
+ plugins: [virtualEntryPlugin, vueSfcPlugin()],
262
+ define: {
263
+ __VUE_OPTIONS_API__: 'true',
264
+ __VUE_PROD_DEVTOOLS__: 'false',
265
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
266
+ },
267
+ })
268
+
269
+ if (!result.success) {
270
+ const messages = result.logs.map(l => l.message ?? String(l)).join('\n')
271
+ throw new Error(`Island build failed:\n${messages}`)
272
+ }
273
+
274
+ // Read the output, compute version hash, optionally compress
275
+ const outPath = join(this.outDir, this.outFile)
276
+ const content = new Uint8Array(await Bun.file(outPath).arrayBuffer())
277
+
278
+ this._version = this.computeHash(content)
279
+
280
+ let compressedSizes: { gzip?: number; brotli?: number } = {}
281
+ if (this.compress) {
282
+ compressedSizes = await this.generateCompressed(outPath, content)
283
+ } else {
284
+ this.cleanCompressed(outPath)
285
+ }
286
+
287
+ this._manifest = {
288
+ file: this.outFile,
289
+ version: this._version,
290
+ src: this.src,
291
+ size: content.length,
292
+ ...compressedSizes,
293
+ }
294
+
295
+ // Write manifest
296
+ await Bun.write(
297
+ join(this.outDir, this.outFile.replace(/\.js$/, '.manifest.json')),
298
+ JSON.stringify(this._manifest, null, 2)
299
+ )
300
+
301
+ // Sync version with ViewEngine
302
+ this.syncViewEngine()
303
+
304
+ const sizeKB = (content.length / 1024).toFixed(1)
305
+ const gzKB = compressedSizes.gzip
306
+ ? ` | gzip: ${(compressedSizes.gzip / 1024).toFixed(1)}kB`
307
+ : ''
308
+ const brKB = compressedSizes.brotli
309
+ ? ` | br: ${(compressedSizes.brotli / 1024).toFixed(1)}kB`
310
+ : ''
311
+
312
+ console.log(
313
+ `[islands] Built ${islands.length} component(s) → ${this.outFile} (${sizeKB}kB${gzKB}${brKB}) v=${this._version}`
314
+ )
315
+ return true
316
+ }
317
+
318
+ /** Watch the islands directory and rebuild on changes. */
319
+ watch(): void {
320
+ if (this.watcher) return
321
+
322
+ this.build().catch(err => console.error('[islands] Build error:', err))
323
+
324
+ this.watcher = fsWatch(this.islandsDir, { recursive: true }, (_event, filename) => {
325
+ if (filename && !filename.endsWith('.vue') && !filename.startsWith('setup.')) return
326
+ console.log('[islands] Change detected, rebuilding...')
327
+ this.build().catch(err => console.error('[islands] Rebuild error:', err))
328
+ })
329
+
330
+ console.log(`[islands] Watching ${this.islandsDir}`)
331
+ }
332
+
333
+ /** Stop watching. */
334
+ unwatch(): void {
335
+ this.watcher?.close()
336
+ this.watcher = null
337
+ }
338
+ }
@@ -0,0 +1,136 @@
1
+ import { parse, compileScript, compileTemplate, compileStyle } from '@vue/compiler-sfc'
2
+ import type { BunPlugin } from 'bun'
3
+
4
+ function hashId(path: string): string {
5
+ const hasher = new Bun.CryptoHasher('md5')
6
+ hasher.update(path)
7
+ return hasher.digest('hex').slice(0, 8)
8
+ }
9
+
10
+ export function vueSfcPlugin(): BunPlugin {
11
+ return {
12
+ name: 'vue-sfc',
13
+ setup(build) {
14
+ build.onLoad({ filter: /\.vue$/ }, async args => {
15
+ const source = await Bun.file(args.path).text()
16
+ const id = hashId(args.path)
17
+ const scopeId = `data-v-${id}`
18
+ const hasScoped = false // computed below
19
+ const { descriptor, errors } = parse(source, { filename: args.path })
20
+
21
+ if (errors.length > 0) {
22
+ throw new Error(
23
+ `Vue SFC parse error in ${args.path}:\n${errors.map(e => e.message).join('\n')}`
24
+ )
25
+ }
26
+
27
+ const scoped = descriptor.styles.some(s => s.scoped)
28
+
29
+ // ── Script ────────────────────────────────────────────────────────
30
+ let scriptCode = ''
31
+ let bindings: Record<string, any> | undefined
32
+
33
+ if (descriptor.script || descriptor.scriptSetup) {
34
+ const result = compileScript(descriptor, {
35
+ id,
36
+ inlineTemplate: !!descriptor.scriptSetup,
37
+ sourceMap: false,
38
+ templateOptions: scoped
39
+ ? {
40
+ scoped: true,
41
+ id,
42
+ compilerOptions: { scopeId },
43
+ }
44
+ : undefined,
45
+ })
46
+ scriptCode = result.content
47
+ bindings = result.bindings
48
+ }
49
+
50
+ // ── Template (Options API only — script setup uses inlineTemplate) ─
51
+ let templateCode = ''
52
+
53
+ if (descriptor.template && !descriptor.scriptSetup) {
54
+ const result = compileTemplate({
55
+ source: descriptor.template.content,
56
+ filename: args.path,
57
+ id,
58
+ scoped,
59
+ compilerOptions: {
60
+ bindingMetadata: bindings,
61
+ scopeId: scoped ? scopeId : undefined,
62
+ },
63
+ })
64
+
65
+ if (result.errors.length > 0) {
66
+ throw new Error(
67
+ `Vue template error in ${args.path}:\n${result.errors.map(e => (typeof e === 'string' ? e : e.message)).join('\n')}`
68
+ )
69
+ }
70
+
71
+ templateCode = result.code
72
+ }
73
+
74
+ // ── Styles ────────────────────────────────────────────────────────
75
+ const styles: string[] = []
76
+
77
+ for (const styleBlock of descriptor.styles) {
78
+ const result = compileStyle({
79
+ source: styleBlock.content,
80
+ filename: args.path,
81
+ id: scopeId,
82
+ scoped: !!styleBlock.scoped,
83
+ })
84
+
85
+ if (result.errors.length > 0) {
86
+ console.warn(`[vue-sfc] Style warning in ${args.path}:`, result.errors)
87
+ }
88
+
89
+ styles.push(result.code)
90
+ }
91
+
92
+ // ── Assemble ──────────────────────────────────────────────────────
93
+ let output = ''
94
+
95
+ // Inject styles at module load time
96
+ if (styles.length > 0) {
97
+ const css = JSON.stringify(styles.join('\n'))
98
+ output += `(function(){var s=document.createElement('style');s.textContent=${css};document.head.appendChild(s)})();\n`
99
+ }
100
+
101
+ if (descriptor.scriptSetup) {
102
+ // <script setup> with inlineTemplate — scriptCode is a complete module
103
+ // Rewrite the default export to capture the component and set __scopeId
104
+ if (scoped) {
105
+ output += scriptCode.replace(/export\s+default\s+/, 'const __sfc__ = ') + '\n'
106
+ output += `__sfc__.__scopeId = ${JSON.stringify(scopeId)};\n`
107
+ output += 'export default __sfc__;\n'
108
+ } else {
109
+ output += scriptCode + '\n'
110
+ }
111
+ } else {
112
+ // Options API — stitch script + template render function
113
+ if (scriptCode) {
114
+ output += scriptCode.replace(/export\s+default\s*\{/, 'const __component__ = {') + '\n'
115
+ } else {
116
+ output += 'const __component__ = {};\n'
117
+ }
118
+
119
+ if (templateCode) {
120
+ output += templateCode + '\n'
121
+ output += '__component__.render = render;\n'
122
+ }
123
+
124
+ if (scoped) {
125
+ output += `__component__.__scopeId = ${JSON.stringify(scopeId)};\n`
126
+ }
127
+
128
+ output += 'export default __component__;\n'
129
+ }
130
+
131
+ const isTs = descriptor.script?.lang === 'ts' || descriptor.scriptSetup?.lang === 'ts'
132
+ return { contents: output, loader: isTs ? 'ts' : 'js' }
133
+ })
134
+ },
135
+ }
136
+ }
@@ -0,0 +1,69 @@
1
+ import { resolve, normalize } from 'node:path'
2
+ import type { Middleware } from '../../http/middleware.ts'
3
+
4
+ export function staticFiles(root = 'public'): Middleware {
5
+ const resolvedRoot = resolve(root)
6
+
7
+ return async (ctx, next) => {
8
+ // Only serve GET/HEAD requests
9
+ if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
10
+ return next()
11
+ }
12
+
13
+ // Skip hidden files (segments starting with .)
14
+ const segments = ctx.path.split('/')
15
+ if (segments.some(s => s.startsWith('.') && s.length > 1)) {
16
+ return next()
17
+ }
18
+
19
+ // Skip pre-compressed files (served via content negotiation only)
20
+ if (ctx.path.endsWith('.gz') || ctx.path.endsWith('.br')) {
21
+ return next()
22
+ }
23
+
24
+ const filePath = normalize(resolve(resolvedRoot + ctx.path))
25
+
26
+ // Directory traversal protection
27
+ if (!filePath.startsWith(resolvedRoot)) {
28
+ return next()
29
+ }
30
+
31
+ const file = Bun.file(filePath)
32
+ const exists = await file.exists()
33
+
34
+ if (!exists) {
35
+ return next()
36
+ }
37
+
38
+ // Content negotiation for pre-compressed files
39
+ const acceptEncoding = ctx.request.headers.get('accept-encoding') ?? ''
40
+
41
+ if (acceptEncoding.includes('br')) {
42
+ const brFile = Bun.file(filePath + '.br')
43
+ if (await brFile.exists()) {
44
+ return new Response(brFile, {
45
+ headers: {
46
+ 'Content-Encoding': 'br',
47
+ 'Content-Type': file.type,
48
+ Vary: 'Accept-Encoding',
49
+ },
50
+ })
51
+ }
52
+ }
53
+
54
+ if (acceptEncoding.includes('gzip')) {
55
+ const gzFile = Bun.file(filePath + '.gz')
56
+ if (await gzFile.exists()) {
57
+ return new Response(gzFile, {
58
+ headers: {
59
+ 'Content-Encoding': 'gzip',
60
+ 'Content-Type': file.type,
61
+ Vary: 'Accept-Encoding',
62
+ },
63
+ })
64
+ }
65
+ }
66
+
67
+ return new Response(file)
68
+ }
69
+ }