@davaux/multisite 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,650 @@
1
+ import assert from 'node:assert/strict'
2
+ import { once } from 'node:events'
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import type { IncomingMessage, ServerResponse } from 'node:http'
5
+ import http from 'node:http'
6
+ import { tmpdir } from 'node:os'
7
+ import { dirname, join } from 'node:path'
8
+ import { after, before, describe, test } from 'node:test'
9
+ import { fileURLToPath } from 'node:url'
10
+ import type { RouteFile, RouteType, ScanResult } from 'davaux'
11
+ import {
12
+ buildMultisiteApps,
13
+ defineSites,
14
+ dispatchToSite,
15
+ mergeScanResults,
16
+ startMultisiteServer,
17
+ } from '../index.js'
18
+
19
+ const FIXTURES = fileURLToPath(new URL('./fixtures', import.meta.url))
20
+ const baseDir = join(FIXTURES, 'base/routes')
21
+ const siteADir = join(FIXTURES, 'site-a/routes')
22
+ const siteBDir = join(FIXTURES, 'site-b/routes')
23
+
24
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
25
+
26
+ function fix(base: string, ...parts: string[]): string {
27
+ return join(base, ...parts)
28
+ }
29
+
30
+ function makeRoute(
31
+ dir: string,
32
+ file: string,
33
+ urlPattern: string,
34
+ type: RouteType = 'page',
35
+ ): RouteFile {
36
+ const params = [
37
+ ...[...urlPattern.matchAll(/:(\w+)/g)].map((m) => m[1]),
38
+ ...[...urlPattern.matchAll(/\*(\w+)/g)].map((m) => m[1]),
39
+ ]
40
+ return { filePath: fix(dir, file), urlPattern, type, params }
41
+ }
42
+
43
+ function makeLayout(dir: string, file: string) {
44
+ const filePath = fix(dir, file)
45
+ return { filePath, dirPath: dirname(filePath) }
46
+ }
47
+
48
+ function makeMiddleware(dir: string, file: string) {
49
+ const filePath = fix(dir, file)
50
+ return { filePath, dirPath: dirname(filePath) }
51
+ }
52
+
53
+ function emptyScan(): ScanResult {
54
+ return { routes: [], layouts: [], middlewares: [] }
55
+ }
56
+
57
+ function mockReq(options: { url: string; host: string; method?: string }): IncomingMessage {
58
+ const { url, host, method = 'GET' } = options
59
+ return {
60
+ method,
61
+ url,
62
+ headers: { host },
63
+ on(event: string, listener: (...args: unknown[]) => void) {
64
+ if (event === 'end') setImmediate(() => listener())
65
+ return this
66
+ },
67
+ removeListener() {
68
+ return this
69
+ },
70
+ destroy() {
71
+ return this
72
+ },
73
+ } as unknown as IncomingMessage
74
+ }
75
+
76
+ function mockRes() {
77
+ let status = 200
78
+ let body = ''
79
+ let sent = false
80
+ let ended = false
81
+
82
+ const res = {
83
+ get statusCode() {
84
+ return status
85
+ },
86
+ set statusCode(v: number) {
87
+ status = v
88
+ },
89
+ get headersSent() {
90
+ return sent
91
+ },
92
+ get writableEnded() {
93
+ return ended
94
+ },
95
+ writeHead(code: number, _h?: Record<string, string>) {
96
+ status = code
97
+ sent = true
98
+ },
99
+ end(data?: string | Buffer) {
100
+ if (data != null) body = typeof data === 'string' ? data : data.toString('utf-8')
101
+ ended = true
102
+ },
103
+ getHeader: () => undefined,
104
+ setHeader() {},
105
+ } as unknown as ServerResponse
106
+
107
+ return { res, result: () => ({ status, body }) }
108
+ }
109
+
110
+ async function siteRequest(
111
+ sites: Awaited<ReturnType<typeof buildMultisiteApps>>,
112
+ options: { url: string; host: string; method?: string },
113
+ ) {
114
+ const req = mockReq(options)
115
+ const { res, result } = mockRes()
116
+ await dispatchToSite(sites, req, res)
117
+ return result()
118
+ }
119
+
120
+ function httpGet(
121
+ port: number,
122
+ path: string,
123
+ host: string,
124
+ ): Promise<{ status: number; body: string; type: string }> {
125
+ return new Promise((resolve, reject) => {
126
+ http
127
+ .get({ hostname: '127.0.0.1', port, path, headers: { host } }, (res) => {
128
+ const chunks: Buffer[] = []
129
+ res.on('data', (c: Buffer) => chunks.push(c))
130
+ res.on('end', () =>
131
+ resolve({
132
+ status: res.statusCode ?? 0,
133
+ body: Buffer.concat(chunks).toString(),
134
+ type: String(res.headers['content-type'] ?? ''),
135
+ }),
136
+ )
137
+ })
138
+ .on('error', reject)
139
+ })
140
+ }
141
+
142
+ // ─── defineSites ──────────────────────────────────────────────────────────────
143
+
144
+ describe('defineSites', () => {
145
+ test('returns the config object unchanged (identity)', () => {
146
+ const config = {
147
+ baseDir: '/some/path',
148
+ sites: [{ name: 'a', hostname: 'a.com' }],
149
+ }
150
+ assert.equal(defineSites(config), config)
151
+ })
152
+ })
153
+
154
+ // ─── mergeScanResults — unit tests ────────────────────────────────────────────
155
+
156
+ describe('mergeScanResults', () => {
157
+ test('overlay route overrides base route at same URL pattern and type', () => {
158
+ const baseIndex = makeRoute(baseDir, 'index.page.ts', '/')
159
+ const overlayIndex = makeRoute(siteADir, 'index.page.ts', '/')
160
+ const merged = mergeScanResults(
161
+ { ...emptyScan(), routes: [baseIndex] },
162
+ { ...emptyScan(), routes: [overlayIndex] },
163
+ )
164
+ assert.equal(merged.routes.length, 1)
165
+ assert.equal(merged.routes[0].filePath, overlayIndex.filePath)
166
+ })
167
+
168
+ test('base routes not in overlay are preserved', () => {
169
+ const baseAbout = makeRoute(baseDir, 'about.page.ts', '/about')
170
+ const overlayIndex = makeRoute(siteADir, 'index.page.ts', '/')
171
+ const merged = mergeScanResults(
172
+ { ...emptyScan(), routes: [baseAbout] },
173
+ { ...emptyScan(), routes: [overlayIndex] },
174
+ )
175
+ assert.equal(merged.routes.length, 2)
176
+ const patterns = merged.routes.map((r) => r.urlPattern)
177
+ assert.ok(patterns.includes('/about'))
178
+ assert.ok(patterns.includes('/'))
179
+ })
180
+
181
+ test('overlay-only routes are included alongside base routes', () => {
182
+ const baseIndex = makeRoute(baseDir, 'index.page.ts', '/')
183
+ const overlayShop = makeRoute(siteADir, 'shop.page.ts', '/shop')
184
+ const merged = mergeScanResults(
185
+ { ...emptyScan(), routes: [baseIndex] },
186
+ { ...emptyScan(), routes: [overlayShop] },
187
+ )
188
+ assert.equal(merged.routes.length, 2)
189
+ })
190
+
191
+ test('merged routes are sorted static before dynamic', () => {
192
+ const dynamicBase = makeRoute(baseDir, 'about.page.ts', '/:slug')
193
+ const staticOverlay = makeRoute(siteADir, 'index.page.ts', '/about')
194
+ const merged = mergeScanResults(
195
+ { ...emptyScan(), routes: [dynamicBase] },
196
+ { ...emptyScan(), routes: [staticOverlay] },
197
+ )
198
+ assert.equal(merged.routes[0].urlPattern, '/about')
199
+ assert.equal(merged.routes[1].urlPattern, '/:slug')
200
+ })
201
+
202
+ test('wildcard routes sort after dynamic routes', () => {
203
+ const dynamic = makeRoute(baseDir, 'slug.page.ts', '/:slug')
204
+ const wildcard = makeRoute(baseDir, 'catch.page.ts', '/*all')
205
+ const merged = mergeScanResults({ ...emptyScan(), routes: [wildcard, dynamic] }, emptyScan())
206
+ assert.equal(merged.routes[0].urlPattern, '/:slug')
207
+ assert.equal(merged.routes[1].urlPattern, '/*all')
208
+ })
209
+
210
+ test('routes with different types at the same URL pattern are both kept', () => {
211
+ const baseGet = makeRoute(baseDir, 'api.get.ts', '/api', 'get')
212
+ const overlayPost = makeRoute(siteADir, 'api.post.ts', '/api', 'post')
213
+ const merged = mergeScanResults(
214
+ { ...emptyScan(), routes: [baseGet] },
215
+ { ...emptyScan(), routes: [overlayPost] },
216
+ )
217
+ assert.equal(merged.routes.length, 2)
218
+ const types = merged.routes.map((r) => r.type)
219
+ assert.ok(types.includes('get'))
220
+ assert.ok(types.includes('post'))
221
+ })
222
+
223
+ test('overlay layout at same dirPath replaces base layout', () => {
224
+ const baseLayout = makeLayout(baseDir, '_layout.ts')
225
+ const overlayLayout = makeLayout(siteADir, '_layout.ts')
226
+ // Give them the same dirPath to simulate same-depth override
227
+ const syntheticOverlay = { filePath: overlayLayout.filePath, dirPath: baseLayout.dirPath }
228
+ const merged = mergeScanResults(
229
+ { ...emptyScan(), layouts: [baseLayout] },
230
+ { ...emptyScan(), layouts: [syntheticOverlay] },
231
+ )
232
+ assert.equal(merged.layouts.length, 1)
233
+ assert.equal(merged.layouts[0].filePath, overlayLayout.filePath)
234
+ })
235
+
236
+ test('layouts at different dirPaths are both kept', () => {
237
+ const baseLayout = makeLayout(baseDir, '_layout.ts')
238
+ const overlayLayout = makeLayout(siteADir, '_layout.ts')
239
+ // Different dirPaths — both survive
240
+ const merged = mergeScanResults(
241
+ { ...emptyScan(), layouts: [baseLayout] },
242
+ { ...emptyScan(), layouts: [overlayLayout] },
243
+ )
244
+ assert.equal(merged.layouts.length, 2)
245
+ })
246
+
247
+ test('overlay middleware at same dirPath replaces base middleware', () => {
248
+ const baseMw = makeMiddleware(baseDir, '_middleware.ts')
249
+ const overlayMw = makeMiddleware(siteADir, '_middleware.ts')
250
+ const syntheticOverlay = { filePath: overlayMw.filePath, dirPath: baseMw.dirPath }
251
+ const merged = mergeScanResults(
252
+ { ...emptyScan(), middlewares: [baseMw] },
253
+ { ...emptyScan(), middlewares: [syntheticOverlay] },
254
+ )
255
+ assert.equal(merged.middlewares.length, 1)
256
+ assert.equal(merged.middlewares[0].filePath, overlayMw.filePath)
257
+ })
258
+
259
+ test('base middlewares at non-overlapping dirs are all preserved', () => {
260
+ const baseMw = makeMiddleware(baseDir, '_middleware.ts')
261
+ const baseSubMw = makeMiddleware(join(baseDir, 'admin'), '_middleware.ts')
262
+ const overlayMw = makeMiddleware(siteADir, '_middleware.ts')
263
+ // overlayMw.dirPath !== baseMw.dirPath && != baseSubMw.dirPath — all three survive
264
+ const merged = mergeScanResults(
265
+ { ...emptyScan(), middlewares: [baseMw, baseSubMw] },
266
+ { ...emptyScan(), middlewares: [overlayMw] },
267
+ )
268
+ assert.equal(merged.middlewares.length, 3)
269
+ })
270
+
271
+ test('overlay error page wins over base error page', () => {
272
+ const merged = mergeScanResults(
273
+ { ...emptyScan(), errorPage: fix(baseDir, '_error.ts') },
274
+ { ...emptyScan(), errorPage: fix(siteBDir, '_error.ts') },
275
+ )
276
+ assert.equal(merged.errorPage, fix(siteBDir, '_error.ts'))
277
+ })
278
+
279
+ test('base error page used when overlay has none', () => {
280
+ const merged = mergeScanResults(
281
+ { ...emptyScan(), errorPage: fix(baseDir, '_error.ts') },
282
+ emptyScan(),
283
+ )
284
+ assert.equal(merged.errorPage, fix(baseDir, '_error.ts'))
285
+ })
286
+ })
287
+
288
+ // ─── buildMultisiteApps + dispatchToSite — integration tests ─────────────────
289
+
290
+ describe('buildMultisiteApps', () => {
291
+ test('base route is served when site has no override', async () => {
292
+ const sites = await buildMultisiteApps(
293
+ defineSites({
294
+ baseDir,
295
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
296
+ }),
297
+ )
298
+ // /about exists only in base; site-a does not override it
299
+ const { status, body } = await siteRequest(sites, { url: '/about', host: 'site-a.com' })
300
+ assert.equal(status, 200)
301
+ assert.ok(body.includes('About (base)'))
302
+ })
303
+
304
+ test('site route overrides base route at same URL', async () => {
305
+ const sites = await buildMultisiteApps(
306
+ defineSites({
307
+ baseDir,
308
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
309
+ }),
310
+ )
311
+ const { status, body } = await siteRequest(sites, { url: '/', host: 'site-a.com' })
312
+ assert.equal(status, 200)
313
+ assert.ok(body.includes('Home (site-a)'))
314
+ assert.ok(!body.includes('Home (base)'))
315
+ })
316
+
317
+ test('site-unique route is served', async () => {
318
+ const sites = await buildMultisiteApps(
319
+ defineSites({
320
+ baseDir,
321
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
322
+ }),
323
+ )
324
+ const { status, body } = await siteRequest(sites, { url: '/shop', host: 'site-a.com' })
325
+ assert.equal(status, 200)
326
+ assert.ok(body.includes('Shop (site-a)'))
327
+ })
328
+
329
+ test('site-b gets its own override of /about', async () => {
330
+ const sites = await buildMultisiteApps(
331
+ defineSites({
332
+ baseDir,
333
+ sites: [
334
+ { name: 'site-a', hostname: 'site-a.com', routesDir: siteADir },
335
+ { name: 'site-b', hostname: 'site-b.com', routesDir: siteBDir },
336
+ ],
337
+ }),
338
+ )
339
+ const a = await siteRequest(sites, { url: '/about', host: 'site-a.com' })
340
+ const b = await siteRequest(sites, { url: '/about', host: 'site-b.com' })
341
+ assert.ok(a.body.includes('About (base)'))
342
+ assert.ok(b.body.includes('About (site-b)'))
343
+ })
344
+
345
+ test('hostname dispatch routes to the correct site', async () => {
346
+ const sites = await buildMultisiteApps(
347
+ defineSites({
348
+ baseDir,
349
+ sites: [
350
+ { name: 'site-a', hostname: 'site-a.com', routesDir: siteADir },
351
+ { name: 'site-b', hostname: 'site-b.com', routesDir: siteBDir },
352
+ ],
353
+ }),
354
+ )
355
+ const a = await siteRequest(sites, { url: '/', host: 'site-a.com' })
356
+ const b = await siteRequest(sites, { url: '/', host: 'site-b.com' })
357
+ assert.ok(a.body.includes('Home (site-a)'))
358
+ assert.ok(b.body.includes('Home (base)')) // site-b has no index override
359
+ })
360
+
361
+ test('wildcard fallback site handles unknown hosts', async () => {
362
+ const sites = await buildMultisiteApps(
363
+ defineSites({
364
+ baseDir,
365
+ sites: [{ name: 'default', hostname: '*', routesDir: siteADir }],
366
+ }),
367
+ )
368
+ const { status, body } = await siteRequest(sites, { url: '/', host: 'unknown.com' })
369
+ assert.equal(status, 200)
370
+ assert.ok(body.includes('Home (site-a)'))
371
+ })
372
+
373
+ test('dispatchToSite returns false for unregistered host with no wildcard', async () => {
374
+ const sites = await buildMultisiteApps(
375
+ defineSites({
376
+ baseDir,
377
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
378
+ }),
379
+ )
380
+ const req = mockReq({ url: '/', host: 'other.com' })
381
+ const { res } = mockRes()
382
+ const handled = await dispatchToSite(sites, req, res)
383
+ assert.equal(handled, false)
384
+ })
385
+
386
+ test('dispatchToSite returns true when a site handles the request', async () => {
387
+ const sites = await buildMultisiteApps(
388
+ defineSites({
389
+ baseDir,
390
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
391
+ }),
392
+ )
393
+ const req = mockReq({ url: '/', host: 'site-a.com' })
394
+ const { res } = mockRes()
395
+ const handled = await dispatchToSite(sites, req, res)
396
+ assert.equal(handled, true)
397
+ })
398
+
399
+ test('site config is accessible via getSite in a route handler', async () => {
400
+ const sites = await buildMultisiteApps(
401
+ defineSites<{ name: string }>({
402
+ sites: [
403
+ {
404
+ name: 'site-a',
405
+ hostname: 'site-a.com',
406
+ routesDir: siteADir,
407
+ config: { name: 'My Site A' },
408
+ },
409
+ ],
410
+ }),
411
+ )
412
+ const { status, body } = await siteRequest(sites, { url: '/config', host: 'site-a.com' })
413
+ assert.equal(status, 200)
414
+ assert.ok(body.includes('site-config:My Site A'))
415
+ })
416
+
417
+ test('getSite returns undefined when site has no config', async () => {
418
+ const sites = await buildMultisiteApps(
419
+ defineSites({
420
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
421
+ }),
422
+ )
423
+ const { body } = await siteRequest(sites, { url: '/config', host: 'site-a.com' })
424
+ assert.ok(body.includes('site-config:none'))
425
+ })
426
+
427
+ test('site with no routesDir serves only base routes', async () => {
428
+ const sites = await buildMultisiteApps(
429
+ defineSites({
430
+ baseDir,
431
+ sites: [{ name: 'docs', hostname: 'docs.com' }],
432
+ }),
433
+ )
434
+ const { status, body } = await siteRequest(sites, { url: '/about', host: 'docs.com' })
435
+ assert.equal(status, 200)
436
+ assert.ok(body.includes('About (base)'))
437
+ })
438
+
439
+ test('site with no baseDir serves only its own routes', async () => {
440
+ const sites = await buildMultisiteApps(
441
+ defineSites({
442
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
443
+ }),
444
+ )
445
+ // /about is only in base — 404 when no baseDir
446
+ const notFound = await siteRequest(sites, { url: '/about', host: 'site-a.com' })
447
+ assert.equal(notFound.status, 404)
448
+ // /shop is site-a-only — should work
449
+ const found = await siteRequest(sites, { url: '/shop', host: 'site-a.com' })
450
+ assert.ok(found.body.includes('Shop (site-a)'))
451
+ })
452
+
453
+ test('site-b custom error page is used for 404s on site-b', async () => {
454
+ const sites = await buildMultisiteApps(
455
+ defineSites({
456
+ baseDir,
457
+ sites: [{ name: 'site-b', hostname: 'site-b.com', routesDir: siteBDir }],
458
+ }),
459
+ )
460
+ const { status, body } = await siteRequest(sites, { url: '/nonexistent', host: 'site-b.com' })
461
+ assert.equal(status, 404)
462
+ assert.ok(body.includes('site-b error: 404'))
463
+ })
464
+
465
+ test('multiple hostnames on one site all resolve to the same app', async () => {
466
+ const sites = await buildMultisiteApps(
467
+ defineSites({
468
+ baseDir,
469
+ sites: [
470
+ {
471
+ name: 'site-a',
472
+ hostname: ['site-a.com', 'www.site-a.com'],
473
+ routesDir: siteADir,
474
+ },
475
+ ],
476
+ }),
477
+ )
478
+ const a1 = await siteRequest(sites, { url: '/', host: 'site-a.com' })
479
+ const a2 = await siteRequest(sites, { url: '/', host: 'www.site-a.com' })
480
+ assert.equal(a1.body, a2.body)
481
+ assert.ok(a1.body.includes('Home (site-a)'))
482
+ })
483
+
484
+ test('port in Host header is stripped for hostname matching', async () => {
485
+ const sites = await buildMultisiteApps(
486
+ defineSites({
487
+ baseDir,
488
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
489
+ }),
490
+ )
491
+ const { status, body } = await siteRequest(sites, { url: '/', host: 'site-a.com:3000' })
492
+ assert.equal(status, 200)
493
+ assert.ok(body.includes('Home (site-a)'))
494
+ })
495
+
496
+ test('site middleware runs and sets state for its routes', async () => {
497
+ const sites = await buildMultisiteApps(
498
+ defineSites({
499
+ baseDir,
500
+ sites: [{ name: 'site-a', hostname: 'site-a.com', routesDir: siteADir }],
501
+ }),
502
+ )
503
+ // site-a's _middleware.ts sets ctx.state.visited = 'site-a'
504
+ const { body } = await siteRequest(sites, { url: '/state', host: 'site-a.com' })
505
+ assert.ok(body.includes('state:site-a'))
506
+ })
507
+ })
508
+
509
+ // ─── startMultisiteServer — HTTP integration tests ────────────────────────────
510
+
511
+ describe('startMultisiteServer', () => {
512
+ let server: ReturnType<typeof startMultisiteServer>
513
+ let serverPort: number
514
+ let tmpDir: string
515
+
516
+ before(async () => {
517
+ tmpDir = mkdtempSync(join(tmpdir(), 'davaux-ms-test-'))
518
+
519
+ // Pre-built assets for site-a (production mode picks these up automatically)
520
+ mkdirSync(join(tmpDir, '_davaux', 'site-a'), { recursive: true })
521
+ writeFileSync(join(tmpDir, '_davaux', 'site-a', 'islands.js'), '/* islands-a */')
522
+ writeFileSync(join(tmpDir, '_davaux', 'site-a', 'client.js'), '/* client-a */')
523
+ writeFileSync(join(tmpDir, '_davaux', 'styles.css'), '/* styles */')
524
+
525
+ // Shared public dir
526
+ const sharedPublic = join(tmpDir, 'public')
527
+ mkdirSync(sharedPublic)
528
+ writeFileSync(join(sharedPublic, 'shared.txt'), 'shared-content')
529
+ writeFileSync(join(sharedPublic, 'both.txt'), 'from-shared')
530
+
531
+ // Site-a per-site public dir
532
+ const siteAPublic = join(tmpDir, 'site-a-public')
533
+ mkdirSync(siteAPublic)
534
+ writeFileSync(join(siteAPublic, 'sitea.txt'), 'sitea-content')
535
+ writeFileSync(join(siteAPublic, 'both.txt'), 'from-sitea')
536
+
537
+ const apps = await buildMultisiteApps(
538
+ defineSites({
539
+ baseDir,
540
+ publicDir: sharedPublic,
541
+ sites: [
542
+ {
543
+ name: 'site-a',
544
+ hostname: 'site-a.com',
545
+ routesDir: siteADir,
546
+ publicDir: siteAPublic,
547
+ },
548
+ {
549
+ name: 'site-b',
550
+ hostname: 'site-b.com',
551
+ routesDir: siteBDir,
552
+ // no islands, no client, no per-site publicDir
553
+ },
554
+ ],
555
+ }),
556
+ { isDev: false, cwd: tmpDir },
557
+ )
558
+
559
+ server = startMultisiteServer(apps, { port: 0, hostname: '127.0.0.1' })
560
+ await once(server, 'listening')
561
+ serverPort = (server.address() as { port: number }).port
562
+ })
563
+
564
+ after(() => {
565
+ server?.close()
566
+ rmSync(tmpDir, { recursive: true, force: true })
567
+ })
568
+
569
+ test('unknown host with no wildcard returns 404', async () => {
570
+ const { status } = await httpGet(serverPort, '/', 'unknown.com')
571
+ assert.equal(status, 404)
572
+ })
573
+
574
+ test('/_davaux/livereload.js returns 200 with JS content-type', async () => {
575
+ const { status, type } = await httpGet(serverPort, '/_davaux/livereload.js', 'site-a.com')
576
+ assert.equal(status, 200)
577
+ assert.ok(type.includes('javascript'))
578
+ })
579
+
580
+ test('/_davaux/livereload-worker.js returns 200 with JS content-type', async () => {
581
+ const { status, type } = await httpGet(
582
+ serverPort,
583
+ '/_davaux/livereload-worker.js',
584
+ 'site-a.com',
585
+ )
586
+ assert.equal(status, 200)
587
+ assert.ok(type.includes('javascript'))
588
+ })
589
+
590
+ test('/_davaux/islands.js served from islandsPath for site-a', async () => {
591
+ const { status, body } = await httpGet(serverPort, '/_davaux/islands.js', 'site-a.com')
592
+ assert.equal(status, 200)
593
+ assert.ok(body.includes('islands-a'))
594
+ })
595
+
596
+ test('/_davaux/islands.js returns 404 for site with no islands file', async () => {
597
+ const { status } = await httpGet(serverPort, '/_davaux/islands.js', 'site-b.com')
598
+ assert.equal(status, 404)
599
+ })
600
+
601
+ test('/_davaux/styles.css served from shared stylesPath', async () => {
602
+ const { status, body, type } = await httpGet(serverPort, '/_davaux/styles.css', 'site-a.com')
603
+ assert.equal(status, 200)
604
+ assert.ok(type.includes('css'))
605
+ assert.ok(body.includes('styles'))
606
+ })
607
+
608
+ test('/_davaux/client.js served from clientPath for site-a', async () => {
609
+ const { status, body } = await httpGet(serverPort, '/_davaux/client.js', 'site-a.com')
610
+ assert.equal(status, 200)
611
+ assert.ok(body.includes('client-a'))
612
+ })
613
+
614
+ test('/_davaux/client.js returns 404 for site with no client file', async () => {
615
+ const { status } = await httpGet(serverPort, '/_davaux/client.js', 'site-b.com')
616
+ assert.equal(status, 404)
617
+ })
618
+
619
+ test('static file served from shared publicDir', async () => {
620
+ const { status, body } = await httpGet(serverPort, '/shared.txt', 'site-b.com')
621
+ assert.equal(status, 200)
622
+ assert.equal(body, 'shared-content')
623
+ })
624
+
625
+ test('static file served from site-specific publicDir', async () => {
626
+ const { status, body } = await httpGet(serverPort, '/sitea.txt', 'site-a.com')
627
+ assert.equal(status, 200)
628
+ assert.equal(body, 'sitea-content')
629
+ })
630
+
631
+ test('per-site publicDir takes priority over shared publicDir', async () => {
632
+ const a = await httpGet(serverPort, '/both.txt', 'site-a.com')
633
+ const b = await httpGet(serverPort, '/both.txt', 'site-b.com')
634
+ assert.equal(a.body, 'from-sitea') // per-site file wins for site-a
635
+ assert.equal(b.body, 'from-shared') // shared file used for site-b
636
+ })
637
+
638
+ test('path traversal does not serve files outside publicDir', async () => {
639
+ const secret = join(tmpDir, 'secret.txt')
640
+ writeFileSync(secret, 'secret-data')
641
+ const { body } = await httpGet(serverPort, '/../secret.txt', 'site-a.com')
642
+ assert.ok(!body.includes('secret-data'))
643
+ })
644
+
645
+ test('routes are dispatched correctly through the server', async () => {
646
+ const { status, body } = await httpGet(serverPort, '/', 'site-a.com')
647
+ assert.equal(status, 200)
648
+ assert.ok(body.includes('Home (site-a)'))
649
+ })
650
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "skipLibCheck": true,
13
+ "lib": ["ESNext"],
14
+ "types": ["node"]
15
+ },
16
+ "include": ["src/**/*"]
17
+ }