@mantiq/vite 0.0.1
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/README.md +19 -0
- package/package.json +59 -0
- package/src/Vite.ts +486 -0
- package/src/ViteServiceProvider.ts +48 -0
- package/src/contracts/Vite.ts +90 -0
- package/src/errors/ViteError.ts +41 -0
- package/src/helpers/vite.ts +15 -0
- package/src/index.ts +32 -0
- package/src/middleware/ServeStaticFiles.ts +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mantiq/vite
|
|
2
|
+
|
|
3
|
+
Vite dev server integration, SSR support, and static file serving for MantiqJS.
|
|
4
|
+
|
|
5
|
+
Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mantiq/vite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/vite",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Vite dev server & manifest integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/vite",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/abdullahkhan/mantiq.git",
|
|
12
|
+
"directory": "packages/vite"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/abdullahkhan/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript",
|
|
22
|
+
"framework",
|
|
23
|
+
"vite"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"package.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"clean": "rm -rf dist"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mantiq/core": "workspace:*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"bun-types": "latest",
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"vite": "^6.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"vite": ">=5.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/Vite.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import type { ViteConfig, ViteManifest, ManifestChunk, PageOptions, SSRModule, SSRResult, RenderOptions } from './contracts/Vite.ts'
|
|
2
|
+
import { ViteManifestNotFoundError, ViteEntrypointNotFoundError, ViteSSRBundleNotFoundError, ViteSSREntryError } from './errors/ViteError.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Core Vite integration class.
|
|
6
|
+
*
|
|
7
|
+
* Handles dev/prod detection, asset tag generation, manifest reading,
|
|
8
|
+
* and HTML shell rendering. Framework-agnostic — works with React, Vue,
|
|
9
|
+
* Svelte, or vanilla JS.
|
|
10
|
+
*/
|
|
11
|
+
export class Vite {
|
|
12
|
+
private readonly config: ViteConfig
|
|
13
|
+
private manifestCache: ViteManifest | null = null
|
|
14
|
+
/** null = unchecked, false = not found, string = dev server URL */
|
|
15
|
+
private hotFileCache: string | false | null = null
|
|
16
|
+
|
|
17
|
+
// ── SSR state ───────────────────────────────────────────────────────────
|
|
18
|
+
private readonly ssrEntry: string | null
|
|
19
|
+
private readonly ssrBundle: string
|
|
20
|
+
private viteDevServer: any | null = null
|
|
21
|
+
private ssrModuleCache: SSRModule | null = null
|
|
22
|
+
/** Application base path (for resolving SSR bundle). Set via setBasePath(). */
|
|
23
|
+
private basePath: string = ''
|
|
24
|
+
|
|
25
|
+
constructor(config: Partial<ViteConfig> = {}) {
|
|
26
|
+
this.config = {
|
|
27
|
+
devServerUrl: config.devServerUrl ?? 'http://localhost:5173',
|
|
28
|
+
buildDir: config.buildDir ?? 'build',
|
|
29
|
+
publicDir: config.publicDir ?? 'public',
|
|
30
|
+
manifest: config.manifest ?? '.vite/manifest.json',
|
|
31
|
+
reactRefresh: config.reactRefresh ?? false,
|
|
32
|
+
rootElement: config.rootElement ?? 'app',
|
|
33
|
+
hotFile: config.hotFile ?? 'hot',
|
|
34
|
+
...(config.ssr ? { ssr: config.ssr } : {}),
|
|
35
|
+
}
|
|
36
|
+
this.ssrEntry = config.ssr?.entry ?? null
|
|
37
|
+
this.ssrBundle = config.ssr?.bundle ?? 'bootstrap/ssr/ssr.js'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Initialization ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check for the hot file to determine dev/prod mode.
|
|
44
|
+
* Called during ViteServiceProvider.boot().
|
|
45
|
+
*/
|
|
46
|
+
async initialize(): Promise<void> {
|
|
47
|
+
const hotPath = this.hotFilePath()
|
|
48
|
+
const file = Bun.file(hotPath)
|
|
49
|
+
if (await file.exists()) {
|
|
50
|
+
const url = (await file.text()).trim()
|
|
51
|
+
this.hotFileCache = url || this.config.devServerUrl
|
|
52
|
+
} else {
|
|
53
|
+
this.hotFileCache = false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether the Vite dev server is running (hot file exists). */
|
|
58
|
+
isDev(): boolean {
|
|
59
|
+
return typeof this.hotFileCache === 'string'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The dev server URL (from hot file or config fallback). */
|
|
63
|
+
devServerUrl(): string {
|
|
64
|
+
return typeof this.hotFileCache === 'string'
|
|
65
|
+
? this.hotFileCache
|
|
66
|
+
: this.config.devServerUrl
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Asset Tag Generation ─────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate `<script>` and `<link>` tags for the given entrypoint(s).
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* const tags = await vite.assets('src/main.tsx')
|
|
77
|
+
* const tags = await vite.assets(['src/main.tsx', 'src/extra.css'])
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
async assets(entrypoints: string | string[]): Promise<string> {
|
|
81
|
+
const entries = Array.isArray(entrypoints) ? entrypoints : [entrypoints]
|
|
82
|
+
|
|
83
|
+
if (this.isDev()) {
|
|
84
|
+
return this.devAssets(entries)
|
|
85
|
+
}
|
|
86
|
+
return this.prodAssets(entries)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private devAssets(entries: string[]): string {
|
|
90
|
+
const url = this.devServerUrl()
|
|
91
|
+
const tags: string[] = []
|
|
92
|
+
|
|
93
|
+
// React Fast Refresh preamble (must come before any other script)
|
|
94
|
+
if (this.config.reactRefresh) {
|
|
95
|
+
tags.push(this.reactRefreshTag())
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Vite client for HMR
|
|
99
|
+
tags.push(`<script type="module" src="${url}/@vite/client"></script>`)
|
|
100
|
+
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.endsWith('.css')) {
|
|
103
|
+
tags.push(`<link rel="stylesheet" href="${url}/${entry}">`)
|
|
104
|
+
} else {
|
|
105
|
+
tags.push(`<script type="module" src="${url}/${entry}"></script>`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return tags.join('\n ')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async prodAssets(entries: string[]): Promise<string> {
|
|
113
|
+
const manifest = await this.loadManifest()
|
|
114
|
+
const tags: string[] = []
|
|
115
|
+
const cssEmitted = new Set<string>()
|
|
116
|
+
const preloadedPaths = new Set<string>()
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const chunk = manifest[entry]
|
|
120
|
+
if (!chunk) {
|
|
121
|
+
throw new ViteEntrypointNotFoundError(entry, this.manifestPath())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Collect all CSS (from this chunk + transitively imported chunks)
|
|
125
|
+
const allCss = this.collectCss(manifest, entry, new Set<string>())
|
|
126
|
+
for (const cssPath of allCss) {
|
|
127
|
+
if (!cssEmitted.has(cssPath)) {
|
|
128
|
+
cssEmitted.add(cssPath)
|
|
129
|
+
tags.push(`<link rel="stylesheet" href="/${this.config.buildDir}/${cssPath}">`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Module preloads for statically imported chunks
|
|
134
|
+
const preloads = this.collectPreloads(manifest, entry, new Set<string>())
|
|
135
|
+
for (const preloadPath of preloads) {
|
|
136
|
+
if (!preloadedPaths.has(preloadPath) && preloadPath !== chunk.file) {
|
|
137
|
+
preloadedPaths.add(preloadPath)
|
|
138
|
+
tags.push(`<link rel="modulepreload" href="/${this.config.buildDir}/${preloadPath}">`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// CSS-only entries: emit the file itself as a stylesheet
|
|
143
|
+
if (entry.endsWith('.css')) {
|
|
144
|
+
if (!cssEmitted.has(chunk.file)) {
|
|
145
|
+
cssEmitted.add(chunk.file)
|
|
146
|
+
tags.push(`<link rel="stylesheet" href="/${this.config.buildDir}/${chunk.file}">`)
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
tags.push(`<script type="module" src="/${this.config.buildDir}/${chunk.file}"></script>`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return tags.join('\n ')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Recursively collect CSS from a chunk and all its static imports. */
|
|
157
|
+
private collectCss(
|
|
158
|
+
manifest: ViteManifest,
|
|
159
|
+
key: string,
|
|
160
|
+
visited: Set<string>,
|
|
161
|
+
): string[] {
|
|
162
|
+
if (visited.has(key)) return []
|
|
163
|
+
visited.add(key)
|
|
164
|
+
|
|
165
|
+
const chunk = manifest[key]
|
|
166
|
+
if (!chunk) return []
|
|
167
|
+
|
|
168
|
+
const css: string[] = [...(chunk.css ?? [])]
|
|
169
|
+
|
|
170
|
+
for (const imp of chunk.imports ?? []) {
|
|
171
|
+
css.push(...this.collectCss(manifest, imp, visited))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return css
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Recursively collect JS file paths from statically imported chunks for modulepreload. */
|
|
178
|
+
private collectPreloads(
|
|
179
|
+
manifest: ViteManifest,
|
|
180
|
+
key: string,
|
|
181
|
+
visited: Set<string>,
|
|
182
|
+
): string[] {
|
|
183
|
+
if (visited.has(key)) return []
|
|
184
|
+
visited.add(key)
|
|
185
|
+
|
|
186
|
+
const chunk = manifest[key]
|
|
187
|
+
if (!chunk) return []
|
|
188
|
+
|
|
189
|
+
const preloads: string[] = []
|
|
190
|
+
|
|
191
|
+
for (const imp of chunk.imports ?? []) {
|
|
192
|
+
const importedChunk = manifest[imp]
|
|
193
|
+
if (importedChunk) {
|
|
194
|
+
preloads.push(importedChunk.file)
|
|
195
|
+
preloads.push(...this.collectPreloads(manifest, imp, visited))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return preloads
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Manifest ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Load and cache the Vite manifest from disk.
|
|
206
|
+
* @throws ViteManifestNotFoundError if the manifest file does not exist.
|
|
207
|
+
*/
|
|
208
|
+
async loadManifest(): Promise<ViteManifest> {
|
|
209
|
+
if (this.manifestCache) return this.manifestCache
|
|
210
|
+
|
|
211
|
+
const path = this.manifestPath()
|
|
212
|
+
const file = Bun.file(path)
|
|
213
|
+
|
|
214
|
+
if (!(await file.exists())) {
|
|
215
|
+
throw new ViteManifestNotFoundError(path)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.manifestCache = (await file.json()) as ViteManifest
|
|
219
|
+
return this.manifestCache
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Clear the cached manifest (useful for testing or watch-mode rebuilds). */
|
|
223
|
+
flushManifest(): void {
|
|
224
|
+
this.manifestCache = null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private manifestPath(): string {
|
|
228
|
+
return `${this.config.publicDir}/${this.config.buildDir}/${this.config.manifest}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private hotFilePath(): string {
|
|
232
|
+
return `${this.config.publicDir}/${this.config.hotFile}`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── SSR ─────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/** Whether SSR is enabled (ssr.entry is configured). */
|
|
238
|
+
isSSR(): boolean {
|
|
239
|
+
return this.ssrEntry !== null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Set the application base path (used to resolve SSR bundle in production). */
|
|
243
|
+
setBasePath(path: string): void {
|
|
244
|
+
this.basePath = path
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Universal page render — the Inertia-like protocol.
|
|
249
|
+
*
|
|
250
|
+
* - If `X-Mantiq: true` header is present → returns JSON (client navigation).
|
|
251
|
+
* - Otherwise → returns full HTML with SSR content (if enabled) or CSR shell.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* return vite().render(request, {
|
|
256
|
+
* page: 'Dashboard',
|
|
257
|
+
* entry: ['src/style.css', 'src/main.tsx'],
|
|
258
|
+
* data: { users },
|
|
259
|
+
* })
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
async render(
|
|
263
|
+
request: { header(name: string): string | undefined; path(): string },
|
|
264
|
+
options: RenderOptions,
|
|
265
|
+
): Promise<Response> {
|
|
266
|
+
const url = request.path()
|
|
267
|
+
const pageData: Record<string, unknown> = { _page: options.page, _url: url, ...(options.data ?? {}) }
|
|
268
|
+
|
|
269
|
+
// Client-side navigation → JSON only
|
|
270
|
+
if (request.header('X-Mantiq') === 'true') {
|
|
271
|
+
return new Response(JSON.stringify(pageData), {
|
|
272
|
+
headers: { 'Content-Type': 'application/json', 'X-Mantiq': 'true' },
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// First load → full HTML (with SSR if enabled)
|
|
277
|
+
const html = await this.page({
|
|
278
|
+
entry: options.entry,
|
|
279
|
+
title: options.title ?? '',
|
|
280
|
+
head: options.head ?? '',
|
|
281
|
+
data: pageData,
|
|
282
|
+
url,
|
|
283
|
+
page: options.page,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
return new Response(html, {
|
|
287
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get or lazily create the embedded Vite dev server (for SSR module loading).
|
|
293
|
+
* Only used in development mode with SSR enabled.
|
|
294
|
+
*/
|
|
295
|
+
private async getViteDevServer(): Promise<any> {
|
|
296
|
+
if (this.viteDevServer) return this.viteDevServer
|
|
297
|
+
|
|
298
|
+
const { createServer } = await import('vite')
|
|
299
|
+
this.viteDevServer = await createServer({
|
|
300
|
+
server: { middlewareMode: true },
|
|
301
|
+
appType: 'custom',
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
return this.viteDevServer
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Load the SSR module. In dev mode, uses Vite's ssrLoadModule for HMR.
|
|
309
|
+
* In production, imports the pre-built bundle.
|
|
310
|
+
*/
|
|
311
|
+
private async loadSSRModule(): Promise<SSRModule> {
|
|
312
|
+
if (this.ssrModuleCache) return this.ssrModuleCache
|
|
313
|
+
|
|
314
|
+
if (!this.ssrEntry) {
|
|
315
|
+
throw new ViteSSREntryError('(none)', 'SSR is not configured. Set ssr.entry in your vite config.')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let mod: any
|
|
319
|
+
|
|
320
|
+
if (this.isDev()) {
|
|
321
|
+
// Dev: use Vite's ssrLoadModule for HMR + transform
|
|
322
|
+
const server = await this.getViteDevServer()
|
|
323
|
+
mod = await server.ssrLoadModule(this.ssrEntry)
|
|
324
|
+
} else {
|
|
325
|
+
// Prod: import the pre-built SSR bundle
|
|
326
|
+
const bundlePath = this.basePath
|
|
327
|
+
? `${this.basePath}/${this.ssrBundle}`
|
|
328
|
+
: this.ssrBundle
|
|
329
|
+
|
|
330
|
+
const file = Bun.file(bundlePath)
|
|
331
|
+
if (!(await file.exists())) {
|
|
332
|
+
throw new ViteSSRBundleNotFoundError(bundlePath)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
mod = await import(bundlePath)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (typeof mod.render !== 'function') {
|
|
339
|
+
throw new ViteSSREntryError(
|
|
340
|
+
this.ssrEntry,
|
|
341
|
+
'Module does not export a render() function.',
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Only cache in production (dev needs fresh modules for HMR)
|
|
346
|
+
if (!this.isDev()) {
|
|
347
|
+
this.ssrModuleCache = mod as SSRModule
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return mod as SSRModule
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Perform SSR render for a given URL and page data.
|
|
355
|
+
* Returns the rendered HTML string and optional head tags.
|
|
356
|
+
*/
|
|
357
|
+
private async renderSSR(url: string, data?: Record<string, unknown>): Promise<SSRResult> {
|
|
358
|
+
const ssrModule = await this.loadSSRModule()
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
return await ssrModule.render(url, data)
|
|
362
|
+
} catch (err) {
|
|
363
|
+
// In dev, fix the stack trace for better DX
|
|
364
|
+
if (this.isDev() && this.viteDevServer && err instanceof Error) {
|
|
365
|
+
this.viteDevServer.ssrFixStacktrace(err)
|
|
366
|
+
}
|
|
367
|
+
throw err
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Close the embedded Vite dev server (cleanup). */
|
|
372
|
+
async closeDevServer(): Promise<void> {
|
|
373
|
+
if (this.viteDevServer) {
|
|
374
|
+
await this.viteDevServer.close()
|
|
375
|
+
this.viteDevServer = null
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── HTML Shell ───────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Render a full HTML page with Vite assets injected.
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* ```ts
|
|
386
|
+
* const html = await vite.page({
|
|
387
|
+
* entry: 'src/main.tsx',
|
|
388
|
+
* title: 'My App',
|
|
389
|
+
* data: { users: [...] },
|
|
390
|
+
* })
|
|
391
|
+
* return MantiqResponse.html(html)
|
|
392
|
+
* ```
|
|
393
|
+
*/
|
|
394
|
+
async page(options: PageOptions): Promise<string> {
|
|
395
|
+
const {
|
|
396
|
+
entry,
|
|
397
|
+
title = '',
|
|
398
|
+
data,
|
|
399
|
+
rootElement = this.config.rootElement,
|
|
400
|
+
head = '',
|
|
401
|
+
url,
|
|
402
|
+
} = options
|
|
403
|
+
|
|
404
|
+
const assetTags = await this.assets(entry)
|
|
405
|
+
|
|
406
|
+
const dataScript = data
|
|
407
|
+
? `\n <script>window.__MANTIQ_DATA__ = ${JSON.stringify(data)}</script>`
|
|
408
|
+
: ''
|
|
409
|
+
|
|
410
|
+
// SSR: render the page component to HTML on the server
|
|
411
|
+
let ssrHtml = ''
|
|
412
|
+
let ssrHead = ''
|
|
413
|
+
if (this.isSSR() && url) {
|
|
414
|
+
try {
|
|
415
|
+
const result = await this.renderSSR(url, data)
|
|
416
|
+
ssrHtml = result.html ?? ''
|
|
417
|
+
ssrHead = result.head ?? ''
|
|
418
|
+
} catch {
|
|
419
|
+
// SSR failure falls back to CSR shell
|
|
420
|
+
ssrHtml = ''
|
|
421
|
+
ssrHead = ''
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const headContent = [head, ssrHead].filter(Boolean).join('\n ')
|
|
426
|
+
|
|
427
|
+
return `<!DOCTYPE html>
|
|
428
|
+
<html lang="en">
|
|
429
|
+
<head>
|
|
430
|
+
<meta charset="UTF-8">
|
|
431
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
432
|
+
<title>${escapeHtml(title)}</title>
|
|
433
|
+
${headContent}
|
|
434
|
+
${assetTags}
|
|
435
|
+
</head>
|
|
436
|
+
<body>
|
|
437
|
+
<div id="${escapeHtml(rootElement)}">${ssrHtml}</div>${dataScript}
|
|
438
|
+
</body>
|
|
439
|
+
</html>`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── React Refresh ────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
/** Generate the React Fast Refresh preamble for dev mode. */
|
|
445
|
+
reactRefreshTag(): string {
|
|
446
|
+
const url = this.devServerUrl()
|
|
447
|
+
return `<script type="module">
|
|
448
|
+
import RefreshRuntime from '${url}/@react-refresh'
|
|
449
|
+
RefreshRuntime.injectIntoGlobalHook(window)
|
|
450
|
+
window.$RefreshReg$ = () => {}
|
|
451
|
+
window.$RefreshSig$ = () => (type) => type
|
|
452
|
+
window.__vite_plugin_react_preamble_installed__ = true
|
|
453
|
+
</script>`
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Testing Helpers ──────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/** @internal Set the manifest directly (for testing without file I/O). */
|
|
459
|
+
setManifest(manifest: ViteManifest): void {
|
|
460
|
+
this.manifestCache = manifest
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** @internal Set dev mode state directly (for testing without file I/O). */
|
|
464
|
+
setDevMode(url: string | false): void {
|
|
465
|
+
this.hotFileCache = url
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** @internal Set an SSR module directly (for testing without file I/O). */
|
|
469
|
+
setSSRModule(mod: SSRModule | null): void {
|
|
470
|
+
this.ssrModuleCache = mod
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Returns the resolved config (read-only). */
|
|
474
|
+
getConfig(): Readonly<ViteConfig> {
|
|
475
|
+
return this.config
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Escape HTML special characters to prevent XSS. */
|
|
480
|
+
export function escapeHtml(str: string): string {
|
|
481
|
+
return str
|
|
482
|
+
.replace(/&/g, '&')
|
|
483
|
+
.replace(/</g, '<')
|
|
484
|
+
.replace(/>/g, '>')
|
|
485
|
+
.replace(/"/g, '"')
|
|
486
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ServiceProvider, ConfigRepository } from '@mantiq/core'
|
|
2
|
+
import { Vite } from './Vite.ts'
|
|
3
|
+
import { ServeStaticFiles } from './middleware/ServeStaticFiles.ts'
|
|
4
|
+
|
|
5
|
+
export const VITE = Symbol('Vite')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers the Vite integration in the application container.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { ViteServiceProvider } from '@mantiq/vite'
|
|
13
|
+
* await app.registerProviders([CoreServiceProvider, ViteServiceProvider])
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export class ViteServiceProvider extends ServiceProvider {
|
|
17
|
+
override register(): void {
|
|
18
|
+
this.app.singleton(Vite, (c) => {
|
|
19
|
+
let viteConfig = {}
|
|
20
|
+
try {
|
|
21
|
+
viteConfig = c.make(ConfigRepository).get('vite') ?? {}
|
|
22
|
+
} catch {
|
|
23
|
+
// No vite config file — use all defaults
|
|
24
|
+
}
|
|
25
|
+
return new Vite(viteConfig)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
this.app.alias(Vite, VITE)
|
|
29
|
+
|
|
30
|
+
// Register static files middleware with Vite instance injected
|
|
31
|
+
this.app.bind(ServeStaticFiles, (c) => new ServeStaticFiles(c.make(Vite)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override async boot(): Promise<void> {
|
|
35
|
+
const vite = this.app.make(Vite)
|
|
36
|
+
|
|
37
|
+
// Set the base path so SSR can resolve the production bundle
|
|
38
|
+
try {
|
|
39
|
+
const config = this.app.make(ConfigRepository)
|
|
40
|
+
const basePath = config.get('app.basePath')
|
|
41
|
+
if (basePath) vite.setBasePath(basePath)
|
|
42
|
+
} catch {
|
|
43
|
+
// Config may not be available in all contexts
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await vite.initialize()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Configuration for the Vite integration. */
|
|
2
|
+
export interface ViteConfig {
|
|
3
|
+
/** Vite dev server URL. Default: 'http://localhost:5173' */
|
|
4
|
+
devServerUrl: string
|
|
5
|
+
/** Build output directory, relative to publicDir. Default: 'build' */
|
|
6
|
+
buildDir: string
|
|
7
|
+
/** Public directory (absolute path). Default: 'public' */
|
|
8
|
+
publicDir: string
|
|
9
|
+
/** Path to manifest.json inside buildDir. Default: '.vite/manifest.json' */
|
|
10
|
+
manifest: string
|
|
11
|
+
/** Enable React Fast Refresh preamble in dev mode. Default: false */
|
|
12
|
+
reactRefresh: boolean
|
|
13
|
+
/** Root element ID for the app mount point. Default: 'app' */
|
|
14
|
+
rootElement: string
|
|
15
|
+
/** Name of the hot file (relative to publicDir). Default: 'hot' */
|
|
16
|
+
hotFile: string
|
|
17
|
+
/** SSR configuration. When set, enables server-side rendering. */
|
|
18
|
+
ssr?: {
|
|
19
|
+
/** SSR entry module path (e.g. 'src/ssr.tsx') */
|
|
20
|
+
entry: string
|
|
21
|
+
/** Production SSR bundle path. Default: 'bootstrap/ssr/ssr.js' */
|
|
22
|
+
bundle?: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A single chunk entry in the Vite 5+ manifest. */
|
|
27
|
+
export interface ManifestChunk {
|
|
28
|
+
/** The hashed output file path, e.g. 'assets/main-abc123.js' */
|
|
29
|
+
file: string
|
|
30
|
+
/** The original source path (present on entry chunks) */
|
|
31
|
+
src?: string
|
|
32
|
+
/** Whether this chunk is an entry point */
|
|
33
|
+
isEntry?: boolean
|
|
34
|
+
/** Whether this chunk is a dynamic import */
|
|
35
|
+
isDynamicEntry?: boolean
|
|
36
|
+
/** CSS files extracted from this chunk */
|
|
37
|
+
css?: string[]
|
|
38
|
+
/** Asset files referenced by this chunk (images, fonts, etc.) */
|
|
39
|
+
assets?: string[]
|
|
40
|
+
/** Chunk keys for static imports this chunk depends on */
|
|
41
|
+
imports?: string[]
|
|
42
|
+
/** Chunk keys for dynamic imports */
|
|
43
|
+
dynamicImports?: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The full Vite manifest — a map from source path to chunk info. */
|
|
47
|
+
export type ViteManifest = Record<string, ManifestChunk>
|
|
48
|
+
|
|
49
|
+
/** Options passed to `vite.page()` for rendering a full HTML document. */
|
|
50
|
+
export interface PageOptions {
|
|
51
|
+
/** Entrypoint path(s), e.g. 'src/main.tsx' or ['src/main.tsx', 'src/extra.css'] */
|
|
52
|
+
entry: string | string[]
|
|
53
|
+
/** HTML document title */
|
|
54
|
+
title?: string
|
|
55
|
+
/** Data to inject as window.__MANTIQ_DATA__ for the client */
|
|
56
|
+
data?: Record<string, unknown>
|
|
57
|
+
/** Root element ID override (defaults to config value) */
|
|
58
|
+
rootElement?: string
|
|
59
|
+
/** Extra HTML to inject inside <head> (meta tags, fonts, etc.) */
|
|
60
|
+
head?: string
|
|
61
|
+
/** Request URL — passed to SSR render() for route-aware rendering */
|
|
62
|
+
url?: string
|
|
63
|
+
/** Page component identifier (e.g. 'Dashboard') — used for SSR page lookup */
|
|
64
|
+
page?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Result returned by an SSR module's render() function. */
|
|
68
|
+
export interface SSRResult {
|
|
69
|
+
html: string
|
|
70
|
+
head?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The SSR module must export a render() function matching this shape. */
|
|
74
|
+
export interface SSRModule {
|
|
75
|
+
render(url: string, data?: Record<string, unknown>): Promise<SSRResult> | SSRResult
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options passed to `vite.render()` for universal (Inertia-like) page responses. */
|
|
79
|
+
export interface RenderOptions {
|
|
80
|
+
/** Page component name (e.g. 'Dashboard', 'Login') */
|
|
81
|
+
page: string
|
|
82
|
+
/** Client entrypoint(s) for asset tags */
|
|
83
|
+
entry: string | string[]
|
|
84
|
+
/** Page data passed to the component */
|
|
85
|
+
data?: Record<string, unknown>
|
|
86
|
+
/** HTML document title */
|
|
87
|
+
title?: string
|
|
88
|
+
/** Extra HTML to inject inside <head> */
|
|
89
|
+
head?: string
|
|
90
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MantiqError } from '@mantiq/core'
|
|
2
|
+
|
|
3
|
+
/** Thrown when the Vite manifest file cannot be found (forgot to run `vite build`). */
|
|
4
|
+
export class ViteManifestNotFoundError extends MantiqError {
|
|
5
|
+
constructor(manifestPath: string) {
|
|
6
|
+
super(
|
|
7
|
+
`Vite manifest not found at "${manifestPath}". Did you run "vite build"?`,
|
|
8
|
+
{ manifestPath },
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Thrown when a requested entrypoint is not present in the Vite manifest. */
|
|
14
|
+
export class ViteEntrypointNotFoundError extends MantiqError {
|
|
15
|
+
constructor(entrypoint: string, manifestPath: string) {
|
|
16
|
+
super(
|
|
17
|
+
`Entrypoint "${entrypoint}" not found in Vite manifest at "${manifestPath}".`,
|
|
18
|
+
{ entrypoint, manifestPath },
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Thrown when the SSR bundle cannot be found in production. */
|
|
24
|
+
export class ViteSSRBundleNotFoundError extends MantiqError {
|
|
25
|
+
constructor(bundlePath: string) {
|
|
26
|
+
super(
|
|
27
|
+
`SSR bundle not found at "${bundlePath}". Did you run "vite build --ssr"?`,
|
|
28
|
+
{ bundlePath },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Thrown when the SSR module does not export a valid render() function. */
|
|
34
|
+
export class ViteSSREntryError extends MantiqError {
|
|
35
|
+
constructor(entry: string, reason: string) {
|
|
36
|
+
super(
|
|
37
|
+
`SSR entry "${entry}" is invalid: ${reason}`,
|
|
38
|
+
{ entry, reason },
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Application } from '@mantiq/core'
|
|
2
|
+
import { Vite } from '../Vite.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the Vite instance from the application container.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { vite } from '@mantiq/vite'
|
|
10
|
+
* const html = await vite().page({ entry: 'src/main.tsx', title: 'Home' })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export function vite(): Vite {
|
|
14
|
+
return Application.getInstance().make(Vite)
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @mantiq/vite — public API exports
|
|
2
|
+
|
|
3
|
+
// ── Contracts ────────────────────────────────────────────────────────────────
|
|
4
|
+
export type {
|
|
5
|
+
ViteConfig,
|
|
6
|
+
ViteManifest,
|
|
7
|
+
ManifestChunk,
|
|
8
|
+
PageOptions,
|
|
9
|
+
SSRResult,
|
|
10
|
+
SSRModule,
|
|
11
|
+
RenderOptions,
|
|
12
|
+
} from './contracts/Vite.ts'
|
|
13
|
+
|
|
14
|
+
// ── Errors ───────────────────────────────────────────────────────────────────
|
|
15
|
+
export {
|
|
16
|
+
ViteManifestNotFoundError,
|
|
17
|
+
ViteEntrypointNotFoundError,
|
|
18
|
+
ViteSSRBundleNotFoundError,
|
|
19
|
+
ViteSSREntryError,
|
|
20
|
+
} from './errors/ViteError.ts'
|
|
21
|
+
|
|
22
|
+
// ── Main Class ───────────────────────────────────────────────────────────────
|
|
23
|
+
export { Vite, escapeHtml } from './Vite.ts'
|
|
24
|
+
|
|
25
|
+
// ── Service Provider ─────────────────────────────────────────────────────────
|
|
26
|
+
export { ViteServiceProvider, VITE } from './ViteServiceProvider.ts'
|
|
27
|
+
|
|
28
|
+
// ── Middleware ────────────────────────────────────────────────────────────────
|
|
29
|
+
export { ServeStaticFiles } from './middleware/ServeStaticFiles.ts'
|
|
30
|
+
|
|
31
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
32
|
+
export { vite } from './helpers/vite.ts'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { Vite } from '../Vite.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serves static files from the public directory.
|
|
6
|
+
* Resolves the public dir from the Vite config automatically.
|
|
7
|
+
* Useful during development — in production, use a reverse proxy (nginx/CDN).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* kernel.registerMiddleware('static', ServeStaticFiles)
|
|
12
|
+
* kernel.setGlobalMiddleware(['static', 'log', 'cors'])
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export class ServeStaticFiles implements Middleware {
|
|
16
|
+
private publicDir: string | null = null
|
|
17
|
+
|
|
18
|
+
constructor(private vite?: Vite) {}
|
|
19
|
+
|
|
20
|
+
setParameters(params: string[]): void {
|
|
21
|
+
if (params[0]) this.publicDir = params[0]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private getPublicDir(): string {
|
|
25
|
+
if (this.publicDir) return this.publicDir
|
|
26
|
+
if (this.vite) return this.vite.getConfig().publicDir
|
|
27
|
+
return 'public'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
31
|
+
// Only serve static files for GET/HEAD requests
|
|
32
|
+
const method = request.method()
|
|
33
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
34
|
+
return next()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const urlPath = request.path()
|
|
38
|
+
|
|
39
|
+
// Prevent directory traversal
|
|
40
|
+
if (urlPath.includes('..') || urlPath.includes('\0')) {
|
|
41
|
+
return next()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Skip the hot file — it's internal
|
|
45
|
+
if (urlPath === '/hot') {
|
|
46
|
+
return next()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filePath = `${this.getPublicDir()}${urlPath}`
|
|
50
|
+
const file = Bun.file(filePath)
|
|
51
|
+
|
|
52
|
+
if (await file.exists()) {
|
|
53
|
+
// Skip directories (files without extensions that are size 0)
|
|
54
|
+
if (file.size === 0 && !urlPath.includes('.')) {
|
|
55
|
+
return next()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Response(file, {
|
|
59
|
+
headers: {
|
|
60
|
+
'Content-Type': file.type,
|
|
61
|
+
'Content-Length': String(file.size),
|
|
62
|
+
'Cache-Control': 'no-cache',
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return next()
|
|
68
|
+
}
|
|
69
|
+
}
|