@davaux/multisite 0.8.0 → 0.8.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.
Files changed (73) hide show
  1. package/dist/build.d.ts +44 -0
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/build.js +136 -0
  4. package/dist/build.js.map +1 -0
  5. package/dist/index.d.ts +202 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +944 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/test/fixtures/base/routes/_layout.d.ts +3 -0
  10. package/dist/test/fixtures/base/routes/_layout.d.ts.map +1 -0
  11. package/dist/test/fixtures/base/routes/_layout.js +6 -0
  12. package/dist/test/fixtures/base/routes/_layout.js.map +1 -0
  13. package/dist/test/fixtures/base/routes/about.page.d.ts +3 -0
  14. package/dist/test/fixtures/base/routes/about.page.d.ts.map +1 -0
  15. package/dist/test/fixtures/base/routes/about.page.js +3 -0
  16. package/dist/test/fixtures/base/routes/about.page.js.map +1 -0
  17. package/dist/test/fixtures/base/routes/index.page.d.ts +3 -0
  18. package/dist/test/fixtures/base/routes/index.page.d.ts.map +1 -0
  19. package/dist/test/fixtures/base/routes/index.page.js +3 -0
  20. package/dist/test/fixtures/base/routes/index.page.js.map +1 -0
  21. package/dist/test/fixtures/site-a/routes/_layout.d.ts +3 -0
  22. package/dist/test/fixtures/site-a/routes/_layout.d.ts.map +1 -0
  23. package/dist/test/fixtures/site-a/routes/_layout.js +6 -0
  24. package/dist/test/fixtures/site-a/routes/_layout.js.map +1 -0
  25. package/dist/test/fixtures/site-a/routes/_middleware.d.ts +3 -0
  26. package/dist/test/fixtures/site-a/routes/_middleware.d.ts.map +1 -0
  27. package/dist/test/fixtures/site-a/routes/_middleware.js +6 -0
  28. package/dist/test/fixtures/site-a/routes/_middleware.js.map +1 -0
  29. package/dist/test/fixtures/site-a/routes/config.page.d.ts +3 -0
  30. package/dist/test/fixtures/site-a/routes/config.page.d.ts.map +1 -0
  31. package/dist/test/fixtures/site-a/routes/config.page.js +7 -0
  32. package/dist/test/fixtures/site-a/routes/config.page.js.map +1 -0
  33. package/dist/test/fixtures/site-a/routes/index.page.d.ts +3 -0
  34. package/dist/test/fixtures/site-a/routes/index.page.d.ts.map +1 -0
  35. package/dist/test/fixtures/site-a/routes/index.page.js +3 -0
  36. package/dist/test/fixtures/site-a/routes/index.page.js.map +1 -0
  37. package/dist/test/fixtures/site-a/routes/shop.page.d.ts +3 -0
  38. package/dist/test/fixtures/site-a/routes/shop.page.d.ts.map +1 -0
  39. package/dist/test/fixtures/site-a/routes/shop.page.js +3 -0
  40. package/dist/test/fixtures/site-a/routes/shop.page.js.map +1 -0
  41. package/dist/test/fixtures/site-a/routes/state.page.d.ts +3 -0
  42. package/dist/test/fixtures/site-a/routes/state.page.d.ts.map +1 -0
  43. package/dist/test/fixtures/site-a/routes/state.page.js +3 -0
  44. package/dist/test/fixtures/site-a/routes/state.page.js.map +1 -0
  45. package/dist/test/fixtures/site-b/routes/_error.d.ts +3 -0
  46. package/dist/test/fixtures/site-b/routes/_error.d.ts.map +1 -0
  47. package/dist/test/fixtures/site-b/routes/_error.js +3 -0
  48. package/dist/test/fixtures/site-b/routes/_error.js.map +1 -0
  49. package/dist/test/fixtures/site-b/routes/about.page.d.ts +3 -0
  50. package/dist/test/fixtures/site-b/routes/about.page.d.ts.map +1 -0
  51. package/dist/test/fixtures/site-b/routes/about.page.js +3 -0
  52. package/dist/test/fixtures/site-b/routes/about.page.js.map +1 -0
  53. package/dist/test/multisite.test.d.ts +2 -0
  54. package/dist/test/multisite.test.d.ts.map +1 -0
  55. package/dist/test/multisite.test.js +492 -0
  56. package/dist/test/multisite.test.js.map +1 -0
  57. package/package.json +6 -3
  58. package/CLAUDE.md +0 -133
  59. package/src/build.ts +0 -183
  60. package/src/index.ts +0 -1219
  61. package/src/test/fixtures/base/routes/_layout.ts +0 -6
  62. package/src/test/fixtures/base/routes/about.page.ts +0 -3
  63. package/src/test/fixtures/base/routes/index.page.ts +0 -3
  64. package/src/test/fixtures/site-a/routes/_layout.ts +0 -6
  65. package/src/test/fixtures/site-a/routes/_middleware.ts +0 -6
  66. package/src/test/fixtures/site-a/routes/config.page.ts +0 -7
  67. package/src/test/fixtures/site-a/routes/index.page.ts +0 -3
  68. package/src/test/fixtures/site-a/routes/shop.page.ts +0 -3
  69. package/src/test/fixtures/site-a/routes/state.page.ts +0 -3
  70. package/src/test/fixtures/site-b/routes/_error.ts +0 -3
  71. package/src/test/fixtures/site-b/routes/about.page.ts +0 -3
  72. package/src/test/multisite.test.ts +0 -650
  73. package/tsconfig.json +0 -17
package/src/index.ts DELETED
@@ -1,1219 +0,0 @@
1
- import { spawn } from 'node:child_process'
2
- import { createReadStream, existsSync, watch as fsWatch, statSync } from 'node:fs'
3
- import type { IncomingMessage, ServerResponse } from 'node:http'
4
- import { createServer } from 'node:http'
5
- import { join, relative } from 'node:path'
6
- import type {
7
- IslandFile,
8
- LayoutFile,
9
- MiddlewareFile,
10
- RequestContext,
11
- RouteFile,
12
- RouteType,
13
- ScanResult,
14
- } from 'davaux'
15
- import {
16
- collectCss,
17
- cssCollectorPlugin,
18
- generateIslandsEntry,
19
- islandServerPlugin,
20
- } from 'davaux/build'
21
- import {
22
- collectEsbuildPlugins,
23
- collectScannerSuffixes,
24
- type DavauxPlugin,
25
- pathsToAlias,
26
- } from 'davaux/config'
27
- import type { CompiledApp } from 'davaux/handler'
28
- import { buildApp, dispatch } from 'davaux/handler'
29
- import { scanIslands, scanRoutes } from 'davaux/scanner'
30
- import { context, type Plugin } from 'esbuild'
31
-
32
- // Keyed on the Node IncomingMessage object — auto-cleaned when the request is GC'd.
33
- const siteConfigMap = new WeakMap<IncomingMessage, unknown>()
34
-
35
- // ─── Public types ─────────────────────────────────────────────────────────────
36
-
37
- export interface SiteDefinition<T = Record<string, unknown>> {
38
- /** Unique name for this site. Used in logging. */
39
- name: string
40
- /**
41
- * Hostname(s) that route to this site. Supply `'*'` as a catch-all fallback.
42
- * Port is stripped before matching, so `'example.com'` matches both
43
- * `example.com` and `example.com:3000`.
44
- */
45
- hostname: string | string[]
46
- /**
47
- * Absolute path to the site-specific routes directory. Routes here are
48
- * overlaid on top of `baseDir`: same URL pattern + type in the overlay wins.
49
- * Omit to run the base routes without any site-specific overrides.
50
- */
51
- routesDir?: string
52
- /**
53
- * Absolute path to the site-specific islands directory. Islands here are
54
- * merged with those in `MultisiteConfig.islandsDir` for this site's client bundle.
55
- */
56
- islandsDir?: string
57
- /**
58
- * Absolute path to the site-specific public directory. Static files here are
59
- * served at their URL path before route dispatch, taking priority over the
60
- * shared `publicDir` when both contain a file at the same path.
61
- */
62
- publicDir?: string
63
- /**
64
- * Absolute path to a site-specific client-side entry file. Overrides
65
- * `MultisiteConfig.clientEntry` for this site when set.
66
- */
67
- clientEntry?: string
68
- /** Arbitrary per-site config — accessible via `getSite<T>(ctx)` in any handler, layout, or middleware. */
69
- config?: T
70
- }
71
-
72
- export interface MultisiteConfig<T = Record<string, unknown>> {
73
- /** Absolute path to the shared base routes directory. */
74
- baseDir?: string
75
- /** Absolute path to the shared islands directory. Islands here are included in every site's client bundle. */
76
- islandsDir?: string
77
- /** Absolute path to the shared public directory. Static files are served at their URL path before route dispatch. */
78
- publicDir?: string
79
- /**
80
- * Absolute path to a shared client-side entry file compiled to `/_davaux/client.js` and
81
- * injected into every page. Per-site `clientEntry` overrides this when set.
82
- */
83
- clientEntry?: string
84
- /** Site definitions — one entry per hostname (or hostname group). */
85
- sites: SiteDefinition<T>[]
86
- /** Extra route suffixes forwarded from plugins (e.g. `[['.page.md', 'page']]`). */
87
- extraSuffixes?: [string, RouteType][]
88
- }
89
-
90
- export interface BuildOptions {
91
- isDev?: boolean
92
- clientScripts?: string[]
93
- clientStylesheets?: string[]
94
- basePath?: string
95
- /** Absolute path to a compiled app-level middleware file (runs before route matching on every request). */
96
- appMiddlewarePath?: string
97
- /**
98
- * Absolute path to the `src/middleware.ts` source file. In dev mode it is
99
- * compiled alongside route files and used as `appMiddlewarePath`.
100
- * `startMultisite` auto-detects `<cwd>/src/middleware.ts` when `cwd` is set.
101
- */
102
- middlewareSrc?: string
103
- /**
104
- * Absolute path to the project root — used in dev mode to locate the esbuild
105
- * temp directory (`.davaux-multisite/`). Defaults to `process.cwd()`.
106
- * Pass `import.meta.dirname` from your `server.ts` for reliable resolution.
107
- */
108
- cwd?: string
109
- /**
110
- * Davaux plugins to apply — contributes esbuild transforms and scanner suffix
111
- * extensions. Use the same list here as in `buildMultisite`.
112
- *
113
- * @example
114
- * import { mdx } from '@davaux/mdx'
115
- * startMultisite(sites, { plugins: [mdx()] })
116
- */
117
- plugins?: DavauxPlugin[]
118
- /**
119
- * tsconfig-style path aliases forwarded to esbuild `alias` in every build context.
120
- * Same format as `compilerOptions.paths` — use `{ '~/*': ['./src/*'] }`.
121
- */
122
- paths?: Record<string, string>
123
- /**
124
- * Extra packages to mark as external beyond the defaults (`node:*`, `davaux`,
125
- * `@davaux/multisite`). Useful for native or CJS-only packages like `canvas` or
126
- * `better-sqlite3`.
127
- */
128
- external?: string[]
129
- }
130
-
131
- export interface ServerOptions {
132
- port?: number
133
- hostname?: string
134
- }
135
-
136
- export interface StartMultisiteOptions extends BuildOptions, ServerOptions {}
137
-
138
- // ─── Internal types ───────────────────────────────────────────────────────────
139
-
140
- interface SiteEntry<T> {
141
- app: CompiledApp
142
- config: T | undefined
143
- /** Absolute path to the compiled client islands bundle, if any. */
144
- islandsPath?: string
145
- /** Absolute path to the compiled CSS stylesheet, if any. */
146
- stylesPath?: string
147
- /** Absolute path to the compiled client entry bundle, if any. */
148
- clientPath?: string
149
- /** Ordered list of public directories to serve static files from (per-site first, then shared). */
150
- publicDirs: string[]
151
- }
152
-
153
- function contentTypeFor(filePath: string): string {
154
- const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase()
155
- const types: Record<string, string> = {
156
- css: 'text/css',
157
- gif: 'image/gif',
158
- html: 'text/html',
159
- ico: 'image/x-icon',
160
- jpeg: 'image/jpeg',
161
- jpg: 'image/jpeg',
162
- js: 'application/javascript',
163
- json: 'application/json',
164
- mjs: 'application/javascript',
165
- pdf: 'application/pdf',
166
- png: 'image/png',
167
- svg: 'image/svg+xml',
168
- txt: 'text/plain',
169
- webmanifest: 'application/manifest+json',
170
- webp: 'image/webp',
171
- woff: 'font/woff',
172
- woff2: 'font/woff2',
173
- xml: 'text/xml',
174
- }
175
- return types[ext] ?? 'application/octet-stream'
176
- }
177
-
178
- // ─── Route sort helpers (mirrors scanRoutes internals) ────────────────────────
179
-
180
- const TYPE_PRIORITY: Record<string, number> = { page: 1 }
181
-
182
- function dynamicWeight(pattern: string): number {
183
- const segs = pattern.split('/')
184
- if (segs.some((s) => s.startsWith('*'))) return 1000
185
- return segs.filter((s) => s.startsWith(':')).length
186
- }
187
-
188
- function sortRoutes(routes: RouteFile[]): RouteFile[] {
189
- return [...routes].sort((a, b) => {
190
- const aw = dynamicWeight(a.urlPattern)
191
- const bw = dynamicWeight(b.urlPattern)
192
- if (aw !== bw) return aw - bw
193
- if (a.urlPattern.length !== b.urlPattern.length)
194
- return a.urlPattern.length - b.urlPattern.length
195
- return (TYPE_PRIORITY[a.type] ?? 0) - (TYPE_PRIORITY[b.type] ?? 0)
196
- })
197
- }
198
-
199
- // ─── Merge logic ──────────────────────────────────────────────────────────────
200
-
201
- /**
202
- * Merge a site-specific `ScanResult` on top of a base `ScanResult`.
203
- *
204
- * - **Routes**: overlay wins at the same `urlPattern + type`. The merged list
205
- * is re-sorted so static routes still precede dynamic ones.
206
- * - **Layouts**: overlay wins at the same `dirPath` (directory-level override).
207
- * Layouts in directories unique to either tree are kept as-is.
208
- * - **Middlewares**: overlay wins at the same `dirPath`. Middlewares unique to
209
- * the base still run; overlapping ones are replaced by the site version.
210
- * - **Error page**: overlay wins if present, otherwise falls back to base.
211
- */
212
- export function mergeScanResults(base: ScanResult, overlay: ScanResult): ScanResult {
213
- // Routes — overlay wins by URL pattern + type
214
- const overlayRouteKeys = new Set(overlay.routes.map((r) => `${r.type}:${r.urlPattern}`))
215
- const routes: RouteFile[] = sortRoutes([
216
- ...overlay.routes,
217
- ...base.routes.filter((r) => !overlayRouteKeys.has(`${r.type}:${r.urlPattern}`)),
218
- ])
219
-
220
- // Layouts — overlay wins by dirPath
221
- const overlayLayoutDirs = new Set(overlay.layouts.map((l) => l.dirPath))
222
- const layouts: LayoutFile[] = [
223
- ...overlay.layouts,
224
- ...base.layouts.filter((l) => !overlayLayoutDirs.has(l.dirPath)),
225
- ]
226
-
227
- // Middlewares — overlay wins by dirPath; base middlewares for non-overlapping
228
- // dirs still run (they're naturally ordered outermost-first by the handler)
229
- const overlayMiddlewareDirs = new Set(overlay.middlewares.map((m) => m.dirPath))
230
- const middlewares: MiddlewareFile[] = [
231
- ...base.middlewares.filter((m) => !overlayMiddlewareDirs.has(m.dirPath)),
232
- ...overlay.middlewares,
233
- ]
234
-
235
- return {
236
- routes,
237
- layouts,
238
- middlewares,
239
- errorPage: overlay.errorPage ?? base.errorPage,
240
- }
241
- }
242
-
243
- // ─── Config helper ────────────────────────────────────────────────────────────
244
-
245
- /** Identity function — returns config unchanged. Provides TypeScript inference for the site config type `T`. */
246
- export function defineSites<T = Record<string, unknown>>(
247
- config: MultisiteConfig<T>,
248
- ): MultisiteConfig<T> {
249
- return config
250
- }
251
-
252
- // ─── Build ────────────────────────────────────────────────────────────────────
253
-
254
- /**
255
- * Scan all site route directories, merge each with the shared base, and return
256
- * a per-hostname map of compiled apps ready for dispatch.
257
- *
258
- * In dev mode (`isDev: true`), all route files are compiled with esbuild
259
- * (applying plugin transforms and wrapping island imports server-side) and
260
- * per-site client island bundles are built before the server starts.
261
- *
262
- * @example
263
- * const apps = await buildMultisiteApps(sites, { isDev: true, cwd: import.meta.dirname })
264
- * startMultisiteServer(apps, { port: 3000 })
265
- */
266
- export async function buildMultisiteApps<T = Record<string, unknown>>(
267
- config: MultisiteConfig<T>,
268
- options: BuildOptions = {},
269
- ): Promise<Map<string, SiteEntry<T>>> {
270
- const { baseDir, islandsDir: baseIslandsDir, sites, extraSuffixes: configSuffixes = [] } = config
271
- const {
272
- isDev = false,
273
- clientScripts = [],
274
- clientStylesheets = [],
275
- basePath = '',
276
- appMiddlewarePath,
277
- middlewareSrc,
278
- cwd: cwdOpt,
279
- plugins: davauxPlugins = [],
280
- paths,
281
- external: userExternal = [],
282
- } = options
283
-
284
- const cwd = cwdOpt ?? process.cwd()
285
- const allSuffixes = [...configSuffixes, ...collectScannerSuffixes(davauxPlugins)]
286
- const userAlias = pathsToAlias(paths ?? {})
287
- const devExternal = ['node:*', 'davaux', '@davaux/multisite', ...userExternal]
288
-
289
- // All island directories across base + all sites (for the server-side island plugin)
290
- const allIslandDirs: string[] = [
291
- ...(baseIslandsDir ? [baseIslandsDir] : []),
292
- ...sites.flatMap((s) => (s.islandsDir ? [s.islandsDir] : [])),
293
- ]
294
-
295
- // Scan all route directories upfront
296
- const baseScanRaw: ScanResult = baseDir
297
- ? await scanRoutes(baseDir, allSuffixes)
298
- : { routes: [], layouts: [], middlewares: [] }
299
-
300
- const siteScanMap = new Map<string, ScanResult>()
301
- for (const site of sites) {
302
- siteScanMap.set(
303
- site.name,
304
- site.routesDir
305
- ? await scanRoutes(site.routesDir, allSuffixes)
306
- : { routes: [], layouts: [], middlewares: [] },
307
- )
308
- }
309
-
310
- // Populated during the blocks below; consumed when building per-site SiteEntry
311
- const siteIslandsPaths = new Map<string, string>()
312
- const siteClientPaths = new Map<string, string>()
313
- let sharedStylesPath: string | undefined
314
-
315
- // Path-mapping functions — identity in production, remapped in dev
316
- let toCompiledPath = (p: string) => p
317
- let toCompiledDir = (d: string) => d
318
-
319
- if (isDev) {
320
- const { build } = await import('esbuild')
321
- const dauxDir = join(cwd, '.davaux-multisite')
322
- const routesOutDir = join(dauxDir, 'routes')
323
- sharedStylesPath = join(dauxDir, 'styles.css')
324
-
325
- // Compile all route files — applies plugin transforms and server-side island wrapping
326
- function collectRouteFiles(scan: ScanResult): string[] {
327
- return [
328
- ...scan.routes.map((r) => r.filePath),
329
- ...scan.layouts.map((l) => l.filePath),
330
- ...scan.middlewares.map((m) => m.filePath),
331
- ...(scan.errorPage ? [scan.errorPage] : []),
332
- ]
333
- }
334
-
335
- const allRouteFiles = [
336
- ...collectRouteFiles(baseScanRaw),
337
- ...[...siteScanMap.values()].flatMap(collectRouteFiles),
338
- ...(middlewareSrc && existsSync(middlewareSrc) ? [middlewareSrc] : []),
339
- ]
340
-
341
- if (allRouteFiles.length > 0) {
342
- await build({
343
- entryPoints: allRouteFiles,
344
- outdir: routesOutDir,
345
- outbase: cwd,
346
- format: 'esm',
347
- platform: 'node',
348
- target: 'node22',
349
- bundle: true,
350
- external: devExternal,
351
- jsx: 'automatic',
352
- jsxImportSource: 'davaux',
353
- sourcemap: 'inline',
354
- alias: { ...userAlias, 'davaux/client': 'davaux/signal' },
355
- plugins: [
356
- ...(allIslandDirs.length > 0 ? [islandServerPlugin(allIslandDirs)] : []),
357
- ...collectEsbuildPlugins(davauxPlugins),
358
- ],
359
- })
360
- }
361
-
362
- // Compile per-site client island bundles
363
- const baseIslands: IslandFile[] = baseIslandsDir ? await scanIslands(baseIslandsDir) : []
364
-
365
- for (const site of sites) {
366
- const siteIslands: IslandFile[] = site.islandsDir ? await scanIslands(site.islandsDir) : []
367
- const allIslands = [...baseIslands, ...siteIslands]
368
- if (allIslands.length === 0) continue
369
-
370
- const islandsPath = join(dauxDir, site.name, 'islands.js')
371
- await build({
372
- stdin: {
373
- contents: generateIslandsEntry(allIslands),
374
- loader: 'ts',
375
- resolveDir: cwd,
376
- },
377
- outfile: islandsPath,
378
- format: 'esm',
379
- platform: 'browser',
380
- target: 'es2022',
381
- bundle: true,
382
- jsx: 'automatic',
383
- jsxImportSource: 'davaux/client',
384
- sourcemap: 'inline',
385
- plugins: collectEsbuildPlugins(davauxPlugins),
386
- })
387
- siteIslandsPaths.set(site.name, islandsPath)
388
- }
389
-
390
- // Compile per-site client bundles
391
- for (const site of sites) {
392
- const clientEntry = site.clientEntry ?? config.clientEntry
393
- if (!clientEntry || !existsSync(clientEntry)) continue
394
- const clientPath = join(dauxDir, site.name, 'client.js')
395
- await build({
396
- entryPoints: [clientEntry],
397
- outfile: clientPath,
398
- format: 'esm',
399
- platform: 'browser',
400
- target: 'es2022',
401
- bundle: true,
402
- jsx: 'automatic',
403
- jsxImportSource: 'davaux/client',
404
- sourcemap: 'inline',
405
- plugins: collectEsbuildPlugins(davauxPlugins),
406
- })
407
- siteClientPaths.set(site.name, clientPath)
408
- }
409
-
410
- // Collect all CSS emitted by the builds into one shared stylesheet
411
- await collectCss(dauxDir, sharedStylesPath)
412
-
413
- toCompiledPath = (src: string) =>
414
- join(routesOutDir, relative(cwd, src).replace(/\.(tsx?|jsx?|mdx?)$/, '.js'))
415
- toCompiledDir = (srcDir: string) => join(routesOutDir, relative(cwd, srcDir))
416
- } else {
417
- // Production: pick up pre-compiled bundles written by buildMultisite
418
- for (const site of sites) {
419
- const islands = join(cwd, '_davaux', site.name, 'islands.js')
420
- if (existsSync(islands)) siteIslandsPaths.set(site.name, islands)
421
- const client = join(cwd, '_davaux', site.name, 'client.js')
422
- if (existsSync(client)) siteClientPaths.set(site.name, client)
423
- }
424
- const prodStyles = join(cwd, '_davaux', 'styles.css')
425
- if (existsSync(prodStyles)) sharedStylesPath = prodStyles
426
- }
427
-
428
- function remapScan(scan: ScanResult): ScanResult {
429
- return {
430
- routes: scan.routes.map((r) => ({ ...r, filePath: toCompiledPath(r.filePath) })),
431
- layouts: scan.layouts.map((l) => ({
432
- filePath: toCompiledPath(l.filePath),
433
- dirPath: toCompiledDir(l.dirPath),
434
- })),
435
- middlewares: scan.middlewares.map((m) => ({
436
- filePath: toCompiledPath(m.filePath),
437
- dirPath: toCompiledDir(m.dirPath),
438
- })),
439
- errorPage: scan.errorPage ? toCompiledPath(scan.errorPage) : undefined,
440
- }
441
- }
442
-
443
- const effectiveMiddlewarePath =
444
- isDev && middlewareSrc && existsSync(middlewareSrc)
445
- ? toCompiledPath(middlewareSrc)
446
- : appMiddlewarePath
447
-
448
- const baseScan = isDev ? remapScan(baseScanRaw) : baseScanRaw
449
- const result = new Map<string, SiteEntry<T>>()
450
-
451
- for (const site of sites) {
452
- const rawSiteScan = siteScanMap.get(site.name) ?? { routes: [], layouts: [], middlewares: [] }
453
- const siteScan = isDev ? remapScan(rawSiteScan) : rawSiteScan
454
- const merged = mergeScanResults(baseScan, siteScan)
455
-
456
- const islandsPath = siteIslandsPaths.get(site.name)
457
- const clientPath = siteClientPaths.get(site.name)
458
- const siteClientScripts = [
459
- ...(islandsPath ? ['/_davaux/islands.js'] : []),
460
- ...(clientPath ? ['/_davaux/client.js'] : []),
461
- ...clientScripts,
462
- ]
463
- const siteClientStylesheets = [
464
- ...(sharedStylesPath ? ['/_davaux/styles.css'] : []),
465
- ...clientStylesheets,
466
- ]
467
-
468
- const publicDirs: string[] = [
469
- ...(site.publicDir ? [site.publicDir] : []),
470
- ...(config.publicDir ? [config.publicDir] : []),
471
- ]
472
-
473
- const app = buildApp(
474
- merged,
475
- isDev,
476
- siteClientScripts,
477
- siteClientStylesheets,
478
- effectiveMiddlewarePath,
479
- basePath,
480
- )
481
-
482
- const entry: SiteEntry<T> = {
483
- app,
484
- config: site.config,
485
- islandsPath,
486
- stylesPath: sharedStylesPath,
487
- clientPath,
488
- publicDirs,
489
- }
490
- const hostnames = Array.isArray(site.hostname) ? site.hostname : [site.hostname]
491
- for (const h of hostnames) result.set(h, entry)
492
- }
493
-
494
- return result
495
- }
496
-
497
- // ─── Server ───────────────────────────────────────────────────────────────────
498
-
499
- /**
500
- * Start an HTTP server that dispatches each request to the matching site by
501
- * hostname. Falls back to the `'*'` entry if the hostname is not explicitly
502
- * registered. Responds 404 if neither matches.
503
- *
504
- * `sites` may be a `() => Map` factory so the caller can hot-swap compiled apps
505
- * in dev mode without restarting the server.
506
- */
507
- export function startMultisiteServer<T = Record<string, unknown>>(
508
- sites: Map<string, SiteEntry<T>> | (() => Map<string, SiteEntry<T>>),
509
- options: ServerOptions = {},
510
- ): ReturnType<typeof createServer> {
511
- const { port = 3000, hostname = 'localhost' } = options
512
- const getSites = typeof sites === 'function' ? sites : () => sites
513
-
514
- const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
515
- const host = (req.headers.host ?? '').split(':')[0]
516
- const currentSites = getSites()
517
- const entry = currentSites.get(host) ?? currentSites.get('*')
518
-
519
- if (!entry) {
520
- res.writeHead(404, { 'Content-Type': 'text/plain' })
521
- res.end(`[davaux/multisite] No site registered for host: ${host}`)
522
- return
523
- }
524
-
525
- if (entry.config !== undefined) siteConfigMap.set(req, entry.config)
526
-
527
- // Serve davaux static assets — per-site for islands, shared for CSS
528
- const urlPath = req.url?.split('?')[0]
529
-
530
- if (urlPath === '/_davaux/islands.js') {
531
- if (entry.islandsPath && existsSync(entry.islandsPath)) {
532
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
533
- createReadStream(entry.islandsPath).pipe(res)
534
- } else {
535
- res.writeHead(404, { 'Content-Type': 'text/plain' })
536
- res.end('Not Found')
537
- }
538
- return
539
- }
540
-
541
- if (urlPath === '/_davaux/client.js') {
542
- if (entry.clientPath && existsSync(entry.clientPath)) {
543
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
544
- createReadStream(entry.clientPath).pipe(res)
545
- } else {
546
- res.writeHead(404, { 'Content-Type': 'text/plain' })
547
- res.end('Not Found')
548
- }
549
- return
550
- }
551
-
552
- if (urlPath === '/_davaux/styles.css') {
553
- res.writeHead(200, { 'Content-Type': 'text/css' })
554
- if (entry.stylesPath && existsSync(entry.stylesPath)) {
555
- createReadStream(entry.stylesPath).pipe(res)
556
- } else {
557
- res.end('')
558
- }
559
- return
560
- }
561
-
562
- // Stub livereload endpoints — live reload is not yet supported in multisite
563
- if (urlPath === '/_davaux/livereload.js' || urlPath === '/_davaux/livereload-worker.js') {
564
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
565
- res.end('')
566
- return
567
- }
568
-
569
- // Serve static files from public directories (per-site dir takes priority over shared)
570
- if (urlPath) {
571
- const relPath = urlPath.replace(/^\/+/, '')
572
- if (relPath) {
573
- for (const dir of entry.publicDirs) {
574
- const filePath = join(dir, relPath)
575
- const normalizedDir = dir.endsWith('/') ? dir : `${dir}/`
576
- if (!filePath.startsWith(normalizedDir)) continue // path traversal guard
577
- if (existsSync(filePath) && statSync(filePath).isFile()) {
578
- res.writeHead(200, { 'Content-Type': contentTypeFor(filePath) })
579
- createReadStream(filePath).pipe(res)
580
- return
581
- }
582
- }
583
- }
584
- }
585
-
586
- dispatch(req, res, entry.app).catch((err) => {
587
- console.error('[davaux/multisite] Unhandled dispatch error:', err)
588
- if (!res.headersSent) {
589
- res.writeHead(500, { 'Content-Type': 'text/plain' })
590
- res.end('Internal Server Error')
591
- }
592
- })
593
- })
594
-
595
- server.listen(port, hostname, () => {
596
- console.log(`\n davaux/multisite http://${hostname}:${port}\n`)
597
- })
598
-
599
- return server
600
- }
601
-
602
- // ─── Low-level dispatch ───────────────────────────────────────────────────────
603
-
604
- /**
605
- * Look up the correct site by the request's `host` header, inject the site
606
- * config into the WeakMap, and dispatch the request. Returns `false` if no
607
- * site is registered for the host (including the `'*'` fallback).
608
- *
609
- * Useful for embedding multisite dispatch into a custom server or for testing
610
- * without spinning up a real HTTP server.
611
- *
612
- * @example
613
- * const server = createServer(async (req, res) => {
614
- * const handled = await dispatchToSite(sites, req, res)
615
- * if (!handled) { res.writeHead(404); res.end('No site') }
616
- * })
617
- */
618
- export async function dispatchToSite<T = Record<string, unknown>>(
619
- sites: Map<string, SiteEntry<T>>,
620
- req: IncomingMessage,
621
- res: ServerResponse,
622
- ): Promise<boolean> {
623
- const host = (req.headers.host ?? '').split(':')[0]
624
- const entry = sites.get(host) ?? sites.get('*')
625
- if (!entry) return false
626
- if (entry.config !== undefined) siteConfigMap.set(req, entry.config)
627
- await dispatch(req, res, entry.app)
628
- return true
629
- }
630
-
631
- // ─── Dev mode ────────────────────────────────────────────────────────────────
632
-
633
- // SharedWorker fans one SSE connection per origin to all tabs; falls back to per-tab EventSource.
634
- const SHARED_WORKER_SCRIPT = `var ports=[];var source=null;
635
- self.onconnect=function(ev){
636
- var port=ev.ports[0];port.start();ports.push(port);
637
- if(!source){
638
- source=new EventSource('/_davaux/livereload');
639
- source.onmessage=function(e){
640
- ports=ports.filter(function(p){try{p.postMessage(e.data);return true}catch(_){return false}});
641
- };
642
- }
643
- };`
644
-
645
- const LIVERELOAD_SCRIPT = `;(function(){
646
- var overlay=null
647
- function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
648
- function show(errors){
649
- if(!overlay){
650
- overlay=document.createElement('div')
651
- overlay.style.cssText='position:fixed;inset:0;z-index:99999;overflow:auto;background:#0d0d0d;color:#e8e8e8;font:13px/1.6 monospace;padding:2rem;box-sizing:border-box'
652
- document.body.appendChild(overlay)
653
- }
654
- overlay.innerHTML='<p style="color:#ff5555;font-size:1.1em;margin:0 0 1.5rem 0"><b>[davaux] Build Error</b></p>'+
655
- errors.map(function(e){
656
- var loc=e.file?(e.file+(e.line?':'+e.line+':'+e.column:'')):'unknown'
657
- return '<div style="margin-bottom:1.25rem;border:1px solid #ff3333;border-radius:6px;padding:1rem">'+
658
- '<div style="color:#888;font-size:0.9em;margin-bottom:0.4rem">'+esc(loc)+'</div>'+
659
- '<div style="color:#ff6b6b">'+esc(e.text)+'</div>'+
660
- (e.lineText?'<pre style="margin:0.75rem 0 0;padding:0.5rem;background:#1a1a1a;border-radius:4px;overflow:auto;color:#aaa">'+esc(e.lineText)+'</pre>':'')+
661
- '</div>'
662
- }).join('')
663
- }
664
- function handle(data){
665
- if(data==='reload'){location.reload()}
666
- else if(data.slice(0,6)==='error:'){show(JSON.parse(data.slice(6)))}
667
- }
668
- if(typeof SharedWorker!=='undefined'){
669
- var w=new SharedWorker('/_davaux/livereload-worker.js')
670
- w.port.onmessage=function(ev){handle(ev.data)}
671
- w.port.start()
672
- } else {
673
- new EventSource('/_davaux/livereload').onmessage=function(ev){handle(ev.data)}
674
- }
675
- })()`
676
-
677
- function startTypeChecker(cwd: string): void {
678
- const tscBin = join(cwd, 'node_modules', '.bin', 'tsc')
679
- if (!existsSync(tscBin) || !existsSync(join(cwd, 'tsconfig.json'))) return
680
- const proc = spawn(tscBin, ['--noEmit', '--watch', '--preserveWatchOutput'], {
681
- cwd,
682
- stdio: ['ignore', 'inherit', 'inherit'],
683
- })
684
- proc.on('error', () => {})
685
- process.on('exit', () => proc.kill())
686
- }
687
-
688
- /**
689
- * Start a multisite dev server with file watching, live reload, and TypeScript type checking.
690
- * Called automatically by `startMultisite` when `NODE_ENV !== 'production'`.
691
- */
692
- export async function startMultisiteDev<T = Record<string, unknown>>(
693
- config: MultisiteConfig<T>,
694
- options: StartMultisiteOptions = {},
695
- ): Promise<ReturnType<typeof createServer>> {
696
- const {
697
- port = 3000,
698
- hostname = 'localhost',
699
- clientScripts: extraClientScripts = [],
700
- clientStylesheets: extraClientStylesheets = [],
701
- basePath = '',
702
- appMiddlewarePath: explicitMiddlewarePath,
703
- middlewareSrc,
704
- cwd: cwdOpt,
705
- plugins: davauxPlugins = [],
706
- paths,
707
- external: userExternal = [],
708
- } = options
709
-
710
- const cwd = cwdOpt ?? process.cwd()
711
- const { baseDir, islandsDir: baseIslandsDir, sites } = config
712
- const allSuffixes = [...(config.extraSuffixes ?? []), ...collectScannerSuffixes(davauxPlugins)]
713
- const extraPlugins = collectEsbuildPlugins(davauxPlugins)
714
- const userAlias = pathsToAlias(paths ?? {})
715
- const serverExternal = ['node:*', 'davaux', '@davaux/multisite', ...userExternal]
716
-
717
- const dauxDir = join(cwd, '.davaux-multisite')
718
- const routesOutDir = join(dauxDir, 'routes')
719
- const sharedStylesPath = join(dauxDir, 'styles.css')
720
-
721
- const allIslandDirs: string[] = [
722
- ...(baseIslandsDir ? [baseIslandsDir] : []),
723
- ...sites.flatMap((s) => (s.islandsDir ? [s.islandsDir] : [])),
724
- ]
725
-
726
- function toCompiledPath(src: string): string {
727
- return join(routesOutDir, relative(cwd, src).replace(/\.(tsx?|jsx?|mdx?)$/, '.js'))
728
- }
729
- function toCompiledDir(srcDir: string): string {
730
- return join(routesOutDir, relative(cwd, srcDir))
731
- }
732
- function remapScan(scan: ScanResult): ScanResult {
733
- return {
734
- routes: scan.routes.map((r) => ({ ...r, filePath: toCompiledPath(r.filePath) })),
735
- layouts: scan.layouts.map((l) => ({
736
- filePath: toCompiledPath(l.filePath),
737
- dirPath: toCompiledDir(l.dirPath),
738
- })),
739
- middlewares: scan.middlewares.map((m) => ({
740
- filePath: toCompiledPath(m.filePath),
741
- dirPath: toCompiledDir(m.dirPath),
742
- })),
743
- errorPage: scan.errorPage ? toCompiledPath(scan.errorPage) : undefined,
744
- }
745
- }
746
- function collectRouteFiles(scan: ScanResult): string[] {
747
- return [
748
- ...scan.routes.map((r) => r.filePath),
749
- ...scan.layouts.map((l) => l.filePath),
750
- ...scan.middlewares.map((m) => m.filePath),
751
- ...(scan.errorPage ? [scan.errorPage] : []),
752
- ]
753
- }
754
-
755
- // ─── SSE live reload ─────────────────────────────────────────────────────────
756
- const sseClients = new Set<ServerResponse>()
757
- let serverReady = false
758
- let reloadTimer: ReturnType<typeof setTimeout> | undefined
759
-
760
- function sendToClients(data: string): void {
761
- for (const client of sseClients) {
762
- try {
763
- client.write(`data: ${data}\n\n`)
764
- } catch {
765
- sseClients.delete(client)
766
- }
767
- }
768
- }
769
-
770
- function scheduleReload(): void {
771
- if (!serverReady) return
772
- if (reloadTimer) clearTimeout(reloadTimer)
773
- reloadTimer = setTimeout(() => {
774
- reloadTimer = undefined
775
- sendToClients('reload')
776
- }, 50)
777
- }
778
-
779
- const reloadPlugin: Plugin = {
780
- name: 'davaux-multisite-reload',
781
- setup(build) {
782
- build.onEnd((result) => {
783
- if (result.errors.length > 0) {
784
- console.error('\n[davaux/multisite] Build error:')
785
- for (const e of result.errors) {
786
- const loc = e.location
787
- console.error(loc ? ` ${loc.file}:${loc.line}: ${e.text}` : ` ${e.text}`)
788
- }
789
- if (serverReady) {
790
- const payload = result.errors.map((e) => ({
791
- text: e.text,
792
- file: e.location?.file,
793
- line: e.location?.line,
794
- column: e.location?.column,
795
- lineText: e.location?.lineText?.trim(),
796
- }))
797
- sendToClients(`error:${JSON.stringify(payload)}`)
798
- }
799
- return
800
- }
801
- scheduleReload()
802
- })
803
- },
804
- }
805
-
806
- // ─── Initial scan ────────────────────────────────────────────────────────────
807
- let baseScanRaw: ScanResult = baseDir
808
- ? await scanRoutes(baseDir, allSuffixes)
809
- : { routes: [], layouts: [], middlewares: [] }
810
-
811
- const siteScanMap = new Map<string, ScanResult>()
812
- for (const site of sites) {
813
- siteScanMap.set(
814
- site.name,
815
- site.routesDir
816
- ? await scanRoutes(site.routesDir, allSuffixes)
817
- : { routes: [], layouts: [], middlewares: [] },
818
- )
819
- }
820
-
821
- const siteIslandsMap = new Map<string, IslandFile[]>()
822
- {
823
- const baseIslands: IslandFile[] = baseIslandsDir ? await scanIslands(baseIslandsDir) : []
824
- for (const site of sites) {
825
- const siteIslands = site.islandsDir ? await scanIslands(site.islandsDir) : []
826
- const combined = [...baseIslands, ...siteIslands]
827
- if (combined.length > 0) siteIslandsMap.set(site.name, combined)
828
- }
829
- }
830
-
831
- function currentRouteFiles(): string[] {
832
- return [
833
- ...collectRouteFiles(baseScanRaw),
834
- ...[...siteScanMap.values()].flatMap(collectRouteFiles),
835
- ...(middlewareSrc && existsSync(middlewareSrc) ? [middlewareSrc] : []),
836
- ]
837
- }
838
-
839
- // ─── Esbuild contexts ────────────────────────────────────────────────────────
840
- type BuildCtx = Awaited<ReturnType<typeof context>>
841
-
842
- function routesCtxOptions() {
843
- return {
844
- entryPoints: currentRouteFiles(),
845
- outdir: routesOutDir,
846
- outbase: cwd,
847
- format: 'esm' as const,
848
- platform: 'node' as const,
849
- target: 'node22',
850
- bundle: true,
851
- external: serverExternal,
852
- jsx: 'automatic' as const,
853
- jsxImportSource: 'davaux',
854
- sourcemap: 'inline' as const,
855
- alias: { ...userAlias, 'davaux/client': 'davaux/signal' },
856
- plugins: [
857
- reloadPlugin,
858
- ...(allIslandDirs.length > 0 ? [islandServerPlugin(allIslandDirs)] : []),
859
- cssCollectorPlugin(dauxDir, sharedStylesPath),
860
- ...extraPlugins,
861
- ],
862
- }
863
- }
864
-
865
- function islandsCtxOptions(allIslands: IslandFile[], outfile: string) {
866
- return {
867
- stdin: { contents: generateIslandsEntry(allIslands), loader: 'ts' as const, resolveDir: cwd },
868
- outfile,
869
- format: 'esm' as const,
870
- platform: 'browser' as const,
871
- target: 'es2022',
872
- bundle: true,
873
- jsx: 'automatic' as const,
874
- jsxImportSource: 'davaux/client',
875
- sourcemap: 'inline' as const,
876
- alias: userAlias,
877
- plugins: [reloadPlugin, ...extraPlugins],
878
- }
879
- }
880
-
881
- let routesCtx: BuildCtx = await context(routesCtxOptions())
882
- await routesCtx.rebuild()
883
-
884
- const islandCtxMap = new Map<string, BuildCtx>()
885
- for (const [siteName, allIslands] of siteIslandsMap) {
886
- const islandsCtx = await context(
887
- islandsCtxOptions(allIslands, join(dauxDir, siteName, 'islands.js')),
888
- )
889
- await islandsCtx.rebuild()
890
- islandCtxMap.set(siteName, islandsCtx)
891
- }
892
-
893
- const clientCtxMap = new Map<string, BuildCtx>()
894
- const siteClientPathsMap = new Map<string, string>()
895
- for (const site of sites) {
896
- const clientEntry = site.clientEntry ?? config.clientEntry
897
- if (!clientEntry || !existsSync(clientEntry)) continue
898
- const clientPath = join(dauxDir, site.name, 'client.js')
899
- const clientCtx = await context({
900
- entryPoints: [clientEntry],
901
- outfile: clientPath,
902
- format: 'esm',
903
- platform: 'browser',
904
- target: 'es2022',
905
- bundle: true,
906
- jsx: 'automatic',
907
- jsxImportSource: 'davaux/client',
908
- sourcemap: 'inline',
909
- alias: userAlias,
910
- plugins: [reloadPlugin, ...extraPlugins],
911
- })
912
- await clientCtx.rebuild()
913
- clientCtxMap.set(site.name, clientCtx)
914
- siteClientPathsMap.set(site.name, clientPath)
915
- }
916
-
917
- // ─── App building ────────────────────────────────────────────────────────────
918
- const effectiveMiddlewarePath =
919
- middlewareSrc && existsSync(middlewareSrc)
920
- ? toCompiledPath(middlewareSrc)
921
- : explicitMiddlewarePath
922
-
923
- function buildCurrentApps(): Map<string, SiteEntry<T>> {
924
- const baseScan = remapScan(baseScanRaw)
925
- const result = new Map<string, SiteEntry<T>>()
926
- for (const site of sites) {
927
- const rawSiteScan = siteScanMap.get(site.name) ?? { routes: [], layouts: [], middlewares: [] }
928
- const merged = mergeScanResults(baseScan, remapScan(rawSiteScan))
929
- const islandsPath = siteIslandsMap.has(site.name)
930
- ? join(dauxDir, site.name, 'islands.js')
931
- : undefined
932
- const clientPath = siteClientPathsMap.get(site.name)
933
- const app = buildApp(
934
- merged,
935
- true,
936
- [
937
- ...(islandsPath ? ['/_davaux/islands.js'] : []),
938
- ...(clientPath ? ['/_davaux/client.js'] : []),
939
- ...extraClientScripts,
940
- ],
941
- ['/_davaux/styles.css', ...extraClientStylesheets],
942
- effectiveMiddlewarePath,
943
- basePath,
944
- )
945
- const entry: SiteEntry<T> = {
946
- app,
947
- config: site.config,
948
- islandsPath,
949
- stylesPath: sharedStylesPath,
950
- clientPath,
951
- publicDirs: [
952
- ...(site.publicDir ? [site.publicDir] : []),
953
- ...(config.publicDir ? [config.publicDir] : []),
954
- ],
955
- }
956
- const hostnames = Array.isArray(site.hostname) ? site.hostname : [site.hostname]
957
- for (const h of hostnames) result.set(h, entry)
958
- }
959
- return result
960
- }
961
-
962
- let currentApps = buildCurrentApps()
963
-
964
- // ─── HTTP server ─────────────────────────────────────────────────────────────
965
- const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
966
- const host = (req.headers.host ?? '').split(':')[0]
967
- const entry = currentApps.get(host) ?? currentApps.get('*')
968
-
969
- if (!entry) {
970
- res.writeHead(404, { 'Content-Type': 'text/plain' })
971
- res.end(`[davaux/multisite] No site registered for host: ${host}`)
972
- return
973
- }
974
-
975
- if (entry.config !== undefined) siteConfigMap.set(req, entry.config)
976
- const urlPath = req.url?.split('?')[0]
977
-
978
- if (urlPath === '/_davaux/livereload') {
979
- res.writeHead(200, {
980
- 'Content-Type': 'text/event-stream',
981
- 'Cache-Control': 'no-cache',
982
- Connection: 'keep-alive',
983
- })
984
- res.write('\n')
985
- sseClients.add(res)
986
- req.on('close', () => sseClients.delete(res))
987
- return
988
- }
989
- if (urlPath === '/_davaux/livereload.js') {
990
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
991
- res.end(LIVERELOAD_SCRIPT)
992
- return
993
- }
994
- if (urlPath === '/_davaux/livereload-worker.js') {
995
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
996
- res.end(SHARED_WORKER_SCRIPT)
997
- return
998
- }
999
- if (urlPath === '/_davaux/islands.js') {
1000
- if (entry.islandsPath && existsSync(entry.islandsPath)) {
1001
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
1002
- createReadStream(entry.islandsPath).pipe(res)
1003
- } else {
1004
- res.writeHead(404, { 'Content-Type': 'text/plain' })
1005
- res.end('Not Found')
1006
- }
1007
- return
1008
- }
1009
- if (urlPath === '/_davaux/client.js') {
1010
- if (entry.clientPath && existsSync(entry.clientPath)) {
1011
- res.writeHead(200, { 'Content-Type': 'application/javascript' })
1012
- createReadStream(entry.clientPath).pipe(res)
1013
- } else {
1014
- res.writeHead(404, { 'Content-Type': 'text/plain' })
1015
- res.end('Not Found')
1016
- }
1017
- return
1018
- }
1019
- if (urlPath === '/_davaux/styles.css') {
1020
- res.writeHead(200, { 'Content-Type': 'text/css' })
1021
- if (existsSync(sharedStylesPath)) {
1022
- createReadStream(sharedStylesPath).pipe(res)
1023
- } else {
1024
- res.end('')
1025
- }
1026
- return
1027
- }
1028
-
1029
- if (urlPath) {
1030
- const relPath = urlPath.replace(/^\/+/, '')
1031
- if (relPath) {
1032
- for (const dir of entry.publicDirs) {
1033
- const filePath = join(dir, relPath)
1034
- const normalizedDir = dir.endsWith('/') ? dir : `${dir}/`
1035
- if (!filePath.startsWith(normalizedDir)) continue
1036
- if (existsSync(filePath) && statSync(filePath).isFile()) {
1037
- res.writeHead(200, { 'Content-Type': contentTypeFor(filePath) })
1038
- createReadStream(filePath).pipe(res)
1039
- return
1040
- }
1041
- }
1042
- }
1043
- }
1044
-
1045
- dispatch(req, res, entry.app).catch((err) => {
1046
- console.error('[davaux/multisite] Unhandled dispatch error:', err)
1047
- if (!res.headersSent) {
1048
- res.writeHead(500, { 'Content-Type': 'text/plain' })
1049
- res.end('Internal Server Error')
1050
- }
1051
- })
1052
- })
1053
-
1054
- server.listen(port, hostname, () => {
1055
- console.log(`\n davaux/multisite http://${hostname}:${port}\n`)
1056
- })
1057
- serverReady = true
1058
-
1059
- // ─── Start watching ──────────────────────────────────────────────────────────
1060
- await routesCtx.watch()
1061
- for (const islandsCtx of islandCtxMap.values()) await islandsCtx.watch()
1062
- for (const clientCtx of clientCtxMap.values()) await clientCtx.watch()
1063
- console.log(' Watching for changes...')
1064
-
1065
- // ─── Structural rebuild ───────────────────────────────────────────────────────
1066
- // Triggered when route or island files are added or removed. esbuild's built-in
1067
- // watch handles content changes; fsWatch fires only 'rename' events (create/delete).
1068
-
1069
- let isRebuilding = false
1070
- let rebuildTimer: ReturnType<typeof setTimeout> | undefined
1071
-
1072
- async function handleStructuralChange(): Promise<void> {
1073
- // Re-scan all directories with current state
1074
- baseScanRaw = baseDir
1075
- ? await scanRoutes(baseDir, allSuffixes)
1076
- : { routes: [], layouts: [], middlewares: [] }
1077
- for (const site of sites) {
1078
- siteScanMap.set(
1079
- site.name,
1080
- site.routesDir
1081
- ? await scanRoutes(site.routesDir, allSuffixes)
1082
- : { routes: [], layouts: [], middlewares: [] },
1083
- )
1084
- }
1085
- const newBaseIslands: IslandFile[] = baseIslandsDir ? await scanIslands(baseIslandsDir) : []
1086
- siteIslandsMap.clear()
1087
- for (const site of sites) {
1088
- const siteIslands = site.islandsDir ? await scanIslands(site.islandsDir) : []
1089
- const combined = [...newBaseIslands, ...siteIslands]
1090
- if (combined.length > 0) siteIslandsMap.set(site.name, combined)
1091
- }
1092
-
1093
- // Recreate routes context (all were disposed in onStructuralChange)
1094
- const routeFiles = currentRouteFiles()
1095
- if (routeFiles.length > 0) {
1096
- routesCtx = await context(routesCtxOptions())
1097
- await routesCtx.rebuild()
1098
- await routesCtx.watch()
1099
- }
1100
-
1101
- // Recreate all island contexts
1102
- islandCtxMap.clear()
1103
- for (const [siteName, allIslands] of siteIslandsMap) {
1104
- const islandsCtx = await context(
1105
- islandsCtxOptions(allIslands, join(dauxDir, siteName, 'islands.js')),
1106
- )
1107
- await islandsCtx.rebuild()
1108
- await islandsCtx.watch()
1109
- islandCtxMap.set(siteName, islandsCtx)
1110
- }
1111
-
1112
- currentApps = buildCurrentApps()
1113
- scheduleReload()
1114
- console.log('[davaux/multisite] Routes updated')
1115
- }
1116
-
1117
- function onStructuralChange(): void {
1118
- // Dispose immediately so esbuild doesn't try to rebuild a stale entry list
1119
- // (e.g. an entry point file that was just deleted).
1120
- routesCtx.dispose().catch(() => {})
1121
- for (const ctx of islandCtxMap.values()) ctx.dispose().catch(() => {})
1122
-
1123
- if (rebuildTimer) clearTimeout(rebuildTimer)
1124
- rebuildTimer = setTimeout(() => {
1125
- if (isRebuilding) return
1126
- isRebuilding = true
1127
- handleStructuralChange()
1128
- .catch((err) => console.error('[davaux/multisite] Route rebuild failed:', err))
1129
- .finally(() => {
1130
- isRebuilding = false
1131
- })
1132
- }, 200)
1133
- }
1134
-
1135
- const routeWatchDirs = [
1136
- ...(baseDir ? [baseDir] : []),
1137
- ...sites.flatMap((s) => (s.routesDir ? [s.routesDir] : [])),
1138
- ]
1139
- for (const dir of routeWatchDirs) {
1140
- if (!existsSync(dir)) continue
1141
- fsWatch(dir, { recursive: true }, (event, filename) => {
1142
- if (event !== 'rename' || !filename) return
1143
- if (!/\.(tsx?|jsx?|mdx?)$/.test(filename)) return
1144
- onStructuralChange()
1145
- })
1146
- }
1147
- for (const dir of allIslandDirs) {
1148
- if (!existsSync(dir)) continue
1149
- fsWatch(dir, { recursive: true }, (event, filename) => {
1150
- if (event !== 'rename' || !filename) return
1151
- if (!/\.(tsx?|jsx?|mdx?)$/.test(filename)) return
1152
- onStructuralChange()
1153
- })
1154
- }
1155
-
1156
- startTypeChecker(cwd)
1157
-
1158
- return server
1159
- }
1160
-
1161
- // ─── Convenience wrapper ──────────────────────────────────────────────────────
1162
-
1163
- /**
1164
- * Build and start a multisite server in one call.
1165
- *
1166
- * Combines `buildMultisiteApps` and `startMultisiteServer`. Automatically sets
1167
- * `isDev` from `NODE_ENV` unless explicitly provided — `true` in development
1168
- * (cache-busting for TypeScript routes), `false` in production.
1169
- *
1170
- * @example
1171
- * // server.ts
1172
- * import { startMultisite } from '@davaux/multisite'
1173
- * import { sites } from './multisite.config.js'
1174
- *
1175
- * startMultisite(sites, { port: 3000, hostname: 'localhost' })
1176
- */
1177
- export async function startMultisite<T = Record<string, unknown>>(
1178
- config: MultisiteConfig<T>,
1179
- options: StartMultisiteOptions = {},
1180
- ): Promise<ReturnType<typeof createServer>> {
1181
- const { port, hostname, isDev = process.env.NODE_ENV !== 'production', ...buildOpts } = options
1182
-
1183
- // Auto-detect src/middleware.ts when cwd is set and no middleware path is explicitly provided
1184
- const cwd = buildOpts.cwd
1185
- if (cwd && !buildOpts.appMiddlewarePath && !buildOpts.middlewareSrc) {
1186
- if (isDev) {
1187
- const mwSrc = join(cwd, 'src', 'middleware.ts')
1188
- if (existsSync(mwSrc)) buildOpts.middlewareSrc = mwSrc
1189
- } else {
1190
- const mwCompiled = join(cwd, 'src', 'middleware.js')
1191
- if (existsSync(mwCompiled)) buildOpts.appMiddlewarePath = mwCompiled
1192
- }
1193
- }
1194
-
1195
- if (isDev) return startMultisiteDev(config, { ...buildOpts, port, hostname })
1196
-
1197
- const apps = await buildMultisiteApps(config, { ...buildOpts, isDev: false })
1198
- return startMultisiteServer(apps, { port, hostname })
1199
- }
1200
-
1201
- // ─── Runtime helpers ──────────────────────────────────────────────────────────
1202
-
1203
- /**
1204
- * Retrieve the current site's config from a request context.
1205
- *
1206
- * Returns `undefined` when called outside of a multisite server (e.g. in tests
1207
- * using a single-site `startServer`).
1208
- *
1209
- * @example
1210
- * import { getSite } from '@davaux/multisite'
1211
- *
1212
- * export default definePage((ctx) => {
1213
- * const site = getSite<MySiteConfig>(ctx)
1214
- * return <Layout theme={site?.theme}>...</Layout>
1215
- * })
1216
- */
1217
- export function getSite<T = Record<string, unknown>>(ctx: RequestContext): T | undefined {
1218
- return siteConfigMap.get(ctx.req) as T | undefined
1219
- }