Skip to content

Commit 1ca16be

Browse files
committed
feat(drivers): add overridable public asset mounting
- move h3 static asset handling into driver-h3 - make H3Driver mountPublicAssets optional with a built-in default - add mountPublicAssets override support to ExpressDriver - remove app-level h3 public asset registration - add tests for default and override public asset behavior
1 parent 4438fcf commit 1ca16be

9 files changed

Lines changed: 235 additions & 53 deletions

File tree

h3/src/app/http/middlewares/staticAssetHandler.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

h3/src/core/app.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { H3Driver, type H3Middleware } from '@arkstack/driver-h3'
55
import { H3 } from 'h3'
66
import { Router } from 'src/core/router'
77
import ErrorHandler from './utils/request-handlers'
8-
import { staticAssetHandler } from '@app/http/middlewares/staticAssetHandler'
98

109
export default class Application implements ArkstackRouterAwareCore<H3, unknown> {
1110
private app: H3
@@ -27,9 +26,6 @@ export default class Application implements ArkstackRouterAwareCore<H3, unknown>
2726
bindRouter: async (runtime) => {
2827
await Router.bind(runtime)
2928
},
30-
mountPublicAssets: (runtime) => {
31-
runtime.use(staticAssetHandler())
32-
},
3329
})
3430

3531
this.app = app ?? this.driver.createApp()

packages/driver-express/src/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Logger } from '@arkstack/common'
55

66
export interface ExpressDriverOptions {
77
bindRouter: (app: Express) => PromiseOrValue<void>;
8+
mountPublicAssets?: (app: Express, publicPath: string) => PromiseOrValue<void>;
89
errorHandler?: ErrorRequestHandler | Handler;
910
}
1011

@@ -41,8 +42,20 @@ export class ExpressDriver extends ArkstackKitDriver<Express, Handler> {
4142
* @param app
4243
* @param publicPath
4344
*/
44-
mountPublicAssets (app: Express, publicPath: string): void {
45-
app.use(express.static(publicPath))
45+
mountPublicAssets (app: Express, publicPath: string): PromiseOrValue<void> {
46+
if (this.options.mountPublicAssets) {
47+
return this.options.mountPublicAssets(app, publicPath)
48+
}
49+
50+
app.use(express.static(publicPath, {
51+
maxAge: '1y',
52+
immutable: true,
53+
setHeaders: (res) => {
54+
res.setHeader('Access-Control-Allow-Origin', '*')
55+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
56+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
57+
},
58+
}))
4659
}
4760

4861
/**
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import express, { type Express } from 'express'
3+
4+
import { ExpressDriver } from '../src/index'
5+
6+
describe('ExpressDriver', () => {
7+
beforeEach(() => {
8+
vi.restoreAllMocks()
9+
})
10+
11+
it('uses a custom mountPublicAssets override when provided', async () => {
12+
const app = { use: vi.fn() } as unknown as Express
13+
const mountPublicAssets = vi.fn().mockResolvedValue(undefined)
14+
const staticSpy = vi.spyOn(express, 'static')
15+
const driver = new ExpressDriver({
16+
bindRouter: vi.fn(),
17+
mountPublicAssets,
18+
})
19+
20+
await driver.mountPublicAssets(app, '/tmp/public')
21+
22+
expect(mountPublicAssets).toHaveBeenCalledWith(app, '/tmp/public')
23+
expect(staticSpy).not.toHaveBeenCalled()
24+
expect(app.use).not.toHaveBeenCalled()
25+
})
26+
27+
it('falls back to express.static with cache and cors headers', () => {
28+
const app = { use: vi.fn() } as unknown as Express
29+
const staticMiddleware = vi.fn() as ReturnType<typeof express.static>
30+
const staticSpy = vi.spyOn(express, 'static').mockReturnValue(staticMiddleware)
31+
const driver = new ExpressDriver({
32+
bindRouter: vi.fn(),
33+
})
34+
35+
driver.mountPublicAssets(app, '/tmp/public')
36+
37+
expect(staticSpy).toHaveBeenCalledTimes(1)
38+
expect(staticSpy).toHaveBeenCalledWith('/tmp/public', expect.objectContaining({
39+
maxAge: '1y',
40+
immutable: true,
41+
setHeaders: expect.any(Function),
42+
}))
43+
expect(app.use).toHaveBeenCalledWith(staticMiddleware)
44+
45+
const staticOptions = staticSpy.mock.calls[0][1] as {
46+
setHeaders: (res: { setHeader: (name: string, value: string) => void }) => void;
47+
}
48+
const headers = new Map<string, string>()
49+
50+
staticOptions.setHeaders({
51+
setHeader: (name, value) => {
52+
headers.set(name, value)
53+
},
54+
})
55+
56+
expect(headers.get('Access-Control-Allow-Origin')).toBe('*')
57+
expect(headers.get('Access-Control-Allow-Methods')).toBe('GET, HEAD, OPTIONS')
58+
expect(headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization')
59+
})
60+
})

packages/driver-h3/src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { H3, serve, toResponse } from 'h3'
33

44
import { Middleware as H3BaseMiddleware } from 'clear-router/types/h3'
55
import { Logger } from '@arkstack/common'
6+
import { staticAssetHandler } from './middlewares'
67

78
// oxlint-disable-next-line typescript/no-explicit-any
89
export type H3Middleware = H3BaseMiddleware | [H3BaseMiddleware, Record<string, any>];
910

1011
export interface H3DriverOptions {
1112
bindRouter: (app: H3) => PromiseOrValue<void>;
12-
mountPublicAssets: (app: H3, publicPath: string) => PromiseOrValue<void>;
13+
mountPublicAssets?: (app: H3, publicPath: string) => PromiseOrValue<void>;
1314
createApp?: () => H3;
1415
}
1516

@@ -60,7 +61,11 @@ export class H3Driver extends ArkstackKitDriver<H3, H3Middleware> {
6061
* @param publicPath
6162
*/
6263
mountPublicAssets (app: H3, publicPath: string): PromiseOrValue<void> {
63-
return this.options.mountPublicAssets(app, publicPath)
64+
if (this.options.mountPublicAssets) {
65+
return this.options.mountPublicAssets(app, publicPath)
66+
}
67+
68+
app.use(staticAssetHandler(publicPath))
6469
}
6570

6671
/**
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from './request-logger'
1+
export * from './request-logger'
2+
export * from './static-asset-handler'
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { H3Event, serveStatic } from 'h3'
2+
import { readFile, stat } from 'node:fs/promises'
3+
import { join, resolve } from 'node:path'
4+
5+
export const staticAssetHandler = (publicPath: string = 'public') => {
6+
const rootPath = resolve(process.cwd(), publicPath)
7+
8+
return (event: H3Event) => {
9+
const { pathname } = new URL(event.req.url)
10+
11+
if (!/\.[a-zA-Z0-9]+$/.test(pathname)) return
12+
if (pathname.startsWith('/.') || pathname.includes('..')) return
13+
14+
event.res.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
15+
event.res.headers.set('Access-Control-Allow-Origin', '*')
16+
event.res.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
17+
event.res.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
18+
19+
return serveStatic(event, {
20+
indexNames: ['/index.html'],
21+
getContents: (id) => {
22+
const relativePath = id.replace(/^\/+/, '')
23+
const file = join(rootPath, relativePath)
24+
25+
return readFile(file).catch(() => null) as never
26+
},
27+
getMeta: async (id) => {
28+
const relativePath = id.replace(/^\/+/, '')
29+
const file = join(rootPath, relativePath)
30+
const stats = await stat(file).catch(() => undefined)
31+
32+
if (stats?.isFile()) {
33+
return {
34+
size: stats.size,
35+
mtime: stats.mtimeMs,
36+
}
37+
}
38+
},
39+
})
40+
}
41+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { H3 } from 'h3'
3+
4+
import { H3Driver } from '../src/index'
5+
6+
describe('H3Driver', () => {
7+
beforeEach(() => {
8+
vi.clearAllMocks()
9+
})
10+
11+
it('uses a custom mountPublicAssets override when provided', async () => {
12+
const app = { use: vi.fn() } as unknown as H3
13+
const mountPublicAssets = vi.fn()
14+
const driver = new H3Driver({
15+
bindRouter: vi.fn(),
16+
mountPublicAssets,
17+
})
18+
19+
await driver.mountPublicAssets(app, 'public')
20+
21+
expect(mountPublicAssets).toHaveBeenCalledWith(app, 'public')
22+
expect(app.use).not.toHaveBeenCalled()
23+
})
24+
25+
it('registers the built-in static asset handler by default', () => {
26+
const app = { use: vi.fn() } as unknown as H3
27+
const driver = new H3Driver({
28+
bindRouter: vi.fn(),
29+
})
30+
31+
driver.mountPublicAssets(app, 'public')
32+
33+
expect(app.use).toHaveBeenCalledTimes(1)
34+
expect(app.use).toHaveBeenCalledWith(expect.any(Function))
35+
})
36+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { serveStatic } from 'h3'
4+
import { staticAssetHandler } from '../src/middlewares/static-asset-handler'
5+
6+
vi.mock('h3', async () => {
7+
const actual = await vi.importActual<typeof import('h3')>('h3')
8+
9+
return {
10+
...actual,
11+
serveStatic: vi.fn(),
12+
}
13+
})
14+
15+
16+
17+
describe('staticAssetHandler', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
})
21+
22+
it('ignores requests that do not target a static asset', () => {
23+
const handler = staticAssetHandler('public')
24+
const event = {
25+
req: { url: 'http://localhost/posts' },
26+
res: { headers: new Headers() },
27+
}
28+
29+
const result = handler(event as never)
30+
31+
expect(result).toBeUndefined()
32+
expect(serveStatic).not.toHaveBeenCalled()
33+
})
34+
35+
it('blocks dotfile and traversal requests', () => {
36+
const handler = staticAssetHandler('public')
37+
38+
handler({
39+
req: { url: 'http://localhost/.env' },
40+
res: { headers: new Headers() },
41+
} as never)
42+
43+
handler({
44+
req: { url: 'http://localhost/..%2Fsecret.txt' },
45+
res: { headers: new Headers() },
46+
} as never)
47+
48+
expect(serveStatic).not.toHaveBeenCalled()
49+
})
50+
51+
it('serves asset requests and applies cache and cors headers', () => {
52+
const handler = staticAssetHandler('public')
53+
const event = {
54+
req: { url: 'http://localhost/app.js' },
55+
res: { headers: new Headers() },
56+
}
57+
58+
vi.mocked(serveStatic).mockReturnValue('served' as never)
59+
60+
const result = handler(event as never)
61+
62+
expect(result).toBe('served')
63+
expect(event.res.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable')
64+
expect(event.res.headers.get('Access-Control-Allow-Origin')).toBe('*')
65+
expect(event.res.headers.get('Access-Control-Allow-Methods')).toBe('GET, HEAD, OPTIONS')
66+
expect(event.res.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization')
67+
expect(serveStatic).toHaveBeenCalledTimes(1)
68+
expect(serveStatic).toHaveBeenCalledWith(event, expect.objectContaining({
69+
indexNames: ['/index.html'],
70+
getContents: expect.any(Function),
71+
getMeta: expect.any(Function),
72+
}))
73+
})
74+
})

0 commit comments

Comments
 (0)