@strav/http 0.2.4 → 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.
@@ -1,139 +0,0 @@
1
- import { resolve, join } from 'node:path'
2
- import { inject } from '@strav/kernel/core/inject'
3
- import Configuration from '@strav/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 '@strav/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
- }
@@ -1,14 +0,0 @@
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
- }
package/src/view/index.ts DELETED
@@ -1,13 +0,0 @@
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'
@@ -1,338 +0,0 @@
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
- }
@@ -1,136 +0,0 @@
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
- }