@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.9.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.
- package/CHANGELOG.md +8 -0
- package/ROADMAP.md +278 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +6 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -1
- package/dist/cli/commands/preview-isr.js +12 -0
- package/dist/cli/commands/preview-isr.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +66 -6
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin/dev-server.d.ts +1 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +4 -2
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +30 -12
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
- package/dist/plugin/transforms/auto-import.js +5 -4
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +7 -1
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/composables/define-middleware.d.ts +15 -0
- package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
- package/dist/runtime/composables/define-middleware.js +16 -0
- package/dist/runtime/composables/define-middleware.js.map +1 -0
- package/dist/runtime/composables/index.d.ts +7 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +4 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-cookie.d.ts +38 -0
- package/dist/runtime/composables/use-cookie.d.ts.map +1 -0
- package/dist/runtime/composables/use-cookie.js +104 -0
- package/dist/runtime/composables/use-cookie.js.map +1 -0
- package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
- package/dist/runtime/composables/use-runtime-config.js +42 -8
- package/dist/runtime/composables/use-runtime-config.js.map +1 -1
- package/dist/runtime/composables/use-seo-meta.d.ts +42 -0
- package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -0
- package/dist/runtime/composables/use-seo-meta.js +58 -0
- package/dist/runtime/composables/use-seo-meta.js.map +1 -0
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +15 -3
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/types/config.d.ts +14 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/middleware.d.ts +8 -2
- package/dist/types/middleware.d.ts.map +1 -1
- package/docs/cli.md +5 -0
- package/docs/composables.md +165 -7
- package/docs/configuration.md +53 -3
- package/docs/middleware.md +53 -25
- package/e2e/cypress/e2e/cookie.cy.ts +68 -0
- package/e2e/cypress/e2e/middleware.cy.ts +45 -0
- package/e2e/cypress/e2e/preview-hardening.cy.ts +79 -0
- package/e2e/cypress/e2e/seo-meta.cy.ts +108 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
- package/e2e/kitchen-sink/app/pages/cookie-test.ts +22 -0
- package/e2e/kitchen-sink/app/pages/seo-test.ts +23 -0
- package/package.json +1 -1
- package/src/__tests__/cli/preview-hardening.test.ts +175 -0
- package/src/__tests__/cli/preview-isr.test.ts +30 -0
- package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +21 -0
- package/src/__tests__/plugin/resolve-config.test.ts +18 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +39 -0
- package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
- package/src/__tests__/runtime/define-middleware.test.ts +43 -0
- package/src/__tests__/runtime/use-cookie.test.ts +218 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +86 -2
- package/src/__tests__/runtime/use-seo-meta.test.ts +109 -0
- package/src/cli/commands/preview-isr.ts +14 -0
- package/src/cli/commands/preview.ts +78 -6
- package/src/index.ts +3 -1
- package/src/plugin/dev-server.ts +1 -1
- package/src/plugin/dts-generator.ts +4 -2
- package/src/plugin/index.ts +32 -11
- package/src/plugin/transforms/auto-import.ts +5 -4
- package/src/plugin/virtual/routes.ts +7 -1
- package/src/runtime/composables/define-middleware.ts +17 -0
- package/src/runtime/composables/index.ts +7 -1
- package/src/runtime/composables/use-cookie.ts +128 -0
- package/src/runtime/composables/use-runtime-config.ts +67 -11
- package/src/runtime/composables/use-seo-meta.ts +75 -0
- package/src/runtime/entry-server-template.ts +15 -3
- package/src/types/config.ts +15 -0
- package/src/types/index.ts +2 -2
- package/src/types/middleware.ts +8 -6
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
7
|
+
import { useCookie } from '../../runtime/composables/use-cookie.js'
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeReqRes(cookieHeader = ''): { req: IncomingMessage; res: ServerResponse } {
|
|
12
|
+
const req = { headers: { cookie: cookieHeader } } as unknown as IncomingMessage
|
|
13
|
+
const headers: Record<string, string | string[]> = {}
|
|
14
|
+
const res = {
|
|
15
|
+
getHeader: (name: string) => headers[name.toLowerCase()],
|
|
16
|
+
setHeader(name: string, value: string | string[]) { headers[name.toLowerCase()] = value },
|
|
17
|
+
} as unknown as ServerResponse
|
|
18
|
+
return { req, res }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── SSR path ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('useCookie — SSR (via AsyncLocalStorage)', () => {
|
|
24
|
+
const store = new AsyncLocalStorage<{ req: IncomingMessage; res: ServerResponse }>()
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
;(globalThis as Record<string, unknown>)['__CER_REQ_STORE__'] = store
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
delete (globalThis as Record<string, unknown>)['__CER_REQ_STORE__']
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('reads a cookie from the request', () => {
|
|
35
|
+
const { req, res } = makeReqRes('token=abc123; other=xyz')
|
|
36
|
+
store.run({ req, res }, () => {
|
|
37
|
+
const cookie = useCookie('token')
|
|
38
|
+
expect(cookie.value).toBe('abc123')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns undefined for a missing cookie', () => {
|
|
43
|
+
const { req, res } = makeReqRes('other=xyz')
|
|
44
|
+
store.run({ req, res }, () => {
|
|
45
|
+
const cookie = useCookie('missing')
|
|
46
|
+
expect(cookie.value).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('writes a Set-Cookie header on set()', () => {
|
|
51
|
+
const { req, res } = makeReqRes()
|
|
52
|
+
store.run({ req, res }, () => {
|
|
53
|
+
useCookie('session').set('s1')
|
|
54
|
+
})
|
|
55
|
+
const header = res.getHeader('Set-Cookie') as string[]
|
|
56
|
+
expect(header).toBeDefined()
|
|
57
|
+
const value = Array.isArray(header) ? header[0] : header
|
|
58
|
+
expect(value).toContain('session=s1')
|
|
59
|
+
expect(value).toContain('Path=/')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('appends to existing Set-Cookie headers', () => {
|
|
63
|
+
const { req, res } = makeReqRes()
|
|
64
|
+
store.run({ req, res }, () => {
|
|
65
|
+
useCookie('a').set('1')
|
|
66
|
+
useCookie('b').set('2')
|
|
67
|
+
})
|
|
68
|
+
const header = res.getHeader('Set-Cookie') as string[]
|
|
69
|
+
expect(Array.isArray(header)).toBe(true)
|
|
70
|
+
expect(header).toHaveLength(2)
|
|
71
|
+
expect(header[0]).toContain('a=1')
|
|
72
|
+
expect(header[1]).toContain('b=2')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('writes Max-Age=0 on remove()', () => {
|
|
76
|
+
const { req, res } = makeReqRes('session=old')
|
|
77
|
+
store.run({ req, res }, () => {
|
|
78
|
+
useCookie('session').remove()
|
|
79
|
+
})
|
|
80
|
+
const header = res.getHeader('Set-Cookie') as string[]
|
|
81
|
+
const value = Array.isArray(header) ? header[0] : header
|
|
82
|
+
expect(value).toContain('Max-Age=0')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('forwards options (httpOnly, secure, sameSite) when setting', () => {
|
|
86
|
+
const { req, res } = makeReqRes()
|
|
87
|
+
store.run({ req, res }, () => {
|
|
88
|
+
useCookie('auth').set('tok', { httpOnly: true, secure: true, sameSite: 'Strict' })
|
|
89
|
+
})
|
|
90
|
+
const header = res.getHeader('Set-Cookie') as string[]
|
|
91
|
+
const value = Array.isArray(header) ? header[0] : header
|
|
92
|
+
expect(value).toContain('HttpOnly')
|
|
93
|
+
expect(value).toContain('Secure')
|
|
94
|
+
expect(value).toContain('SameSite=Strict')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('forwards options (maxAge, path, domain) when removing', () => {
|
|
98
|
+
const { req, res } = makeReqRes('auth=tok')
|
|
99
|
+
store.run({ req, res }, () => {
|
|
100
|
+
useCookie('auth').remove({ path: '/app', domain: 'example.com' })
|
|
101
|
+
})
|
|
102
|
+
const header = res.getHeader('Set-Cookie') as string[]
|
|
103
|
+
const value = Array.isArray(header) ? header[0] : header
|
|
104
|
+
expect(value).toContain('Max-Age=0')
|
|
105
|
+
expect(value).toContain('Path=/app')
|
|
106
|
+
expect(value).toContain('Domain=example.com')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('decodes percent-encoded cookie values', () => {
|
|
110
|
+
const { req, res } = makeReqRes(`msg=${encodeURIComponent('hello world')}`)
|
|
111
|
+
store.run({ req, res }, () => {
|
|
112
|
+
const cookie = useCookie('msg')
|
|
113
|
+
expect(cookie.value).toBe('hello world')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('round-trips values with special characters (spaces, slashes, unicode)', () => {
|
|
118
|
+
const specialValue = 'hello world/path?q=1&r=2 ✓'
|
|
119
|
+
const { req, res } = makeReqRes(`special=${encodeURIComponent(specialValue)}`)
|
|
120
|
+
store.run({ req, res }, () => {
|
|
121
|
+
const cookie = useCookie('special')
|
|
122
|
+
expect(cookie.value).toBe(specialValue)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('encodes special characters in Set-Cookie when setting a value', () => {
|
|
127
|
+
const { req, res } = makeReqRes()
|
|
128
|
+
const specialValue = 'hello world/path?q=1'
|
|
129
|
+
store.run({ req, res }, () => {
|
|
130
|
+
useCookie('data').set(specialValue)
|
|
131
|
+
})
|
|
132
|
+
const header = res.getHeader('Set-Cookie') as string[]
|
|
133
|
+
const cookieStr = Array.isArray(header) ? header[0] : header
|
|
134
|
+
// Value must be percent-encoded in the Set-Cookie header
|
|
135
|
+
expect(cookieStr).toContain(encodeURIComponent(specialValue))
|
|
136
|
+
expect(cookieStr).not.toContain(specialValue)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('survives a malformed cookie segment without throwing', () => {
|
|
140
|
+
// A segment with no '=' should be skipped gracefully
|
|
141
|
+
const { req, res } = makeReqRes('broken; valid=ok; =noname')
|
|
142
|
+
store.run({ req, res }, () => {
|
|
143
|
+
const cookie = useCookie('valid')
|
|
144
|
+
expect(cookie.value).toBe('ok')
|
|
145
|
+
expect(useCookie('broken').value).toBeUndefined()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns undefined value outside a store context', () => {
|
|
150
|
+
// store is registered but no run() context — getStore() returns null
|
|
151
|
+
const cookie = useCookie('x')
|
|
152
|
+
expect(cookie.value).toBeUndefined()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ─── Client path ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('useCookie — client (document.cookie)', () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
// Ensure no SSR store leaks into client tests
|
|
161
|
+
delete (globalThis as Record<string, unknown>)['__CER_REQ_STORE__']
|
|
162
|
+
// Clear document cookies
|
|
163
|
+
document.cookie.split(';').forEach((c) => {
|
|
164
|
+
const name = c.split('=')[0].trim()
|
|
165
|
+
if (name) document.cookie = `${name}=; Max-Age=0; Path=/`
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('reads a cookie set via document.cookie', () => {
|
|
170
|
+
document.cookie = 'theme=dark'
|
|
171
|
+
const cookie = useCookie('theme')
|
|
172
|
+
expect(cookie.value).toBe('dark')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('returns undefined when the cookie is not set', () => {
|
|
176
|
+
const cookie = useCookie('nonexistent')
|
|
177
|
+
expect(cookie.value).toBeUndefined()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('writes to document.cookie on set()', () => {
|
|
181
|
+
useCookie('lang').set('en')
|
|
182
|
+
const cookie = useCookie('lang')
|
|
183
|
+
expect(cookie.value).toBe('en')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('removes a cookie on remove()', () => {
|
|
187
|
+
document.cookie = 'removeme=yes'
|
|
188
|
+
useCookie('removeme').remove()
|
|
189
|
+
const cookie = useCookie('removeme')
|
|
190
|
+
// After removal the cookie value should be empty or undefined
|
|
191
|
+
expect(cookie.value === undefined || cookie.value === '').toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('percent-encodes values with special characters', () => {
|
|
195
|
+
useCookie('data').set('hello world')
|
|
196
|
+
const raw = document.cookie
|
|
197
|
+
expect(raw).toContain('hello%20world')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ─── Build-time / unknown context ─────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
describe('useCookie — unknown context (no store, no document)', () => {
|
|
204
|
+
it('returns undefined value and no-op set/remove when neither SSR nor client context is available', () => {
|
|
205
|
+
delete (globalThis as Record<string, unknown>)['__CER_REQ_STORE__']
|
|
206
|
+
// Simulate a non-browser, non-SSR context by verifying the composable
|
|
207
|
+
// falls through to the default branch (value === undefined, methods are no-ops).
|
|
208
|
+
// We can verify this by calling with a store that has no active context.
|
|
209
|
+
const store = new AsyncLocalStorage()
|
|
210
|
+
;(globalThis as Record<string, unknown>)['__CER_REQ_STORE__'] = store
|
|
211
|
+
// getStore() returns null because we're outside a run() context
|
|
212
|
+
const cookie = useCookie('x')
|
|
213
|
+
expect(cookie.value).toBeUndefined()
|
|
214
|
+
expect(() => cookie.set('val')).not.toThrow()
|
|
215
|
+
expect(() => cookie.remove()).not.toThrow()
|
|
216
|
+
delete (globalThis as Record<string, unknown>)['__CER_REQ_STORE__']
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { useRuntimeConfig, initRuntimeConfig } from '../../runtime/composables/use-runtime-config.js'
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useRuntimeConfig, initRuntimeConfig, resolvePrivateConfig } from '../../runtime/composables/use-runtime-config.js'
|
|
3
3
|
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
// Reset global state between tests
|
|
@@ -56,4 +56,88 @@ describe('useRuntimeConfig', () => {
|
|
|
56
56
|
const config = useRuntimeConfig()
|
|
57
57
|
expect(config.public).toEqual({})
|
|
58
58
|
})
|
|
59
|
+
|
|
60
|
+
it('returns private config when initialized with it', () => {
|
|
61
|
+
initRuntimeConfig({ public: {}, private: { dbUrl: 'postgres://localhost', secretKey: 'abc' } })
|
|
62
|
+
const config = useRuntimeConfig()
|
|
63
|
+
expect(config.private).toEqual({ dbUrl: 'postgres://localhost', secretKey: 'abc' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('private is undefined when not supplied', () => {
|
|
67
|
+
initRuntimeConfig({ public: { apiBase: '/api' } })
|
|
68
|
+
const config = useRuntimeConfig()
|
|
69
|
+
expect(config.private).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns empty private config when initialized with empty object', () => {
|
|
73
|
+
initRuntimeConfig({ public: {}, private: {} })
|
|
74
|
+
expect(useRuntimeConfig().private).toEqual({})
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ─── resolvePrivateConfig ─────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('resolvePrivateConfig', () => {
|
|
81
|
+
it('resolves a key from the exact-case env var', () => {
|
|
82
|
+
const result = resolvePrivateConfig({ dbUrl: '' }, { dbUrl: 'postgres://localhost' })
|
|
83
|
+
expect(result.dbUrl).toBe('postgres://localhost')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('resolves a key from the ALL_CAPS env var when exact case is absent', () => {
|
|
87
|
+
const result = resolvePrivateConfig({ dbUrl: '' }, { DB_URL: 'postgres://prod' })
|
|
88
|
+
expect(result.dbUrl).toBe('postgres://prod')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('falls back to the declared default when neither env var is set', () => {
|
|
92
|
+
const result = resolvePrivateConfig({ dbUrl: 'default-db' }, {})
|
|
93
|
+
expect(result.dbUrl).toBe('default-db')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('exact-case env var takes precedence over ALL_CAPS', () => {
|
|
97
|
+
const result = resolvePrivateConfig({ dbUrl: '' }, { dbUrl: 'exact', DB_URL: 'caps' })
|
|
98
|
+
expect(result.dbUrl).toBe('exact')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('handles multiple keys independently', () => {
|
|
102
|
+
const result = resolvePrivateConfig(
|
|
103
|
+
{ dbUrl: '', secretKey: '', apiToken: 'default-token' },
|
|
104
|
+
{ dbUrl: 'pg://host', SECRET_KEY: 's3cr3t' },
|
|
105
|
+
)
|
|
106
|
+
expect(result.dbUrl).toBe('pg://host')
|
|
107
|
+
expect(result.secretKey).toBe('s3cr3t')
|
|
108
|
+
expect(result.apiToken).toBe('default-token')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns an empty object when defaults is empty', () => {
|
|
112
|
+
expect(resolvePrivateConfig({}, { ANY: 'value' })).toEqual({})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('preserves key names exactly as declared in output (does not rename keys)', () => {
|
|
116
|
+
const result = resolvePrivateConfig({ camelCase: 'def' }, { CAMEL_CASE: 'val' })
|
|
117
|
+
expect(Object.keys(result)).toEqual(['camelCase'])
|
|
118
|
+
expect(result.camelCase).toBe('val')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('emits a console.warn when a key has an empty-string default and no env var is set', () => {
|
|
122
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
123
|
+
resolvePrivateConfig({ dbUrl: '' }, {})
|
|
124
|
+
expect(warn).toHaveBeenCalledOnce()
|
|
125
|
+
expect(warn.mock.calls[0][0]).toContain('dbUrl')
|
|
126
|
+
expect(warn.mock.calls[0][0]).toContain('DB_URL')
|
|
127
|
+
warn.mockRestore()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does NOT warn when the env var supplies a value for an empty-default key', () => {
|
|
131
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
132
|
+
resolvePrivateConfig({ dbUrl: '' }, { DB_URL: 'postgres://prod' })
|
|
133
|
+
expect(warn).not.toHaveBeenCalled()
|
|
134
|
+
warn.mockRestore()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does NOT warn when the declared default is non-empty and no env var is set', () => {
|
|
138
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
139
|
+
resolvePrivateConfig({ apiToken: 'fallback-token' }, {})
|
|
140
|
+
expect(warn).not.toHaveBeenCalled()
|
|
141
|
+
warn.mockRestore()
|
|
142
|
+
})
|
|
59
143
|
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
|
+
import { beginHeadCollection, endHeadCollection } from '../../runtime/composables/use-head.js'
|
|
3
|
+
import { useSeoMeta } from '../../runtime/composables/use-seo-meta.js'
|
|
4
|
+
|
|
5
|
+
// All tests run in SSR collection mode so we can inspect the output without a DOM.
|
|
6
|
+
|
|
7
|
+
describe('useSeoMeta — SSR collection', () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
endHeadCollection()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('sets the document title', () => {
|
|
13
|
+
beginHeadCollection()
|
|
14
|
+
useSeoMeta({ title: 'My Page' })
|
|
15
|
+
const [head] = endHeadCollection()
|
|
16
|
+
expect(head.title).toBe('My Page')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('emits a description meta tag', () => {
|
|
20
|
+
beginHeadCollection()
|
|
21
|
+
useSeoMeta({ description: 'A great page.' })
|
|
22
|
+
const [head] = endHeadCollection()
|
|
23
|
+
expect(head.meta).toContainEqual({ name: 'description', content: 'A great page.' })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('emits Open Graph meta tags', () => {
|
|
27
|
+
beginHeadCollection()
|
|
28
|
+
useSeoMeta({
|
|
29
|
+
ogTitle: 'OG Title',
|
|
30
|
+
ogDescription: 'OG Desc',
|
|
31
|
+
ogImage: 'https://example.com/og.png',
|
|
32
|
+
ogUrl: 'https://example.com/',
|
|
33
|
+
ogType: 'website',
|
|
34
|
+
ogSiteName: 'Example',
|
|
35
|
+
})
|
|
36
|
+
const [head] = endHeadCollection()
|
|
37
|
+
expect(head.meta).toContainEqual({ property: 'og:title', content: 'OG Title' })
|
|
38
|
+
expect(head.meta).toContainEqual({ property: 'og:description', content: 'OG Desc' })
|
|
39
|
+
expect(head.meta).toContainEqual({ property: 'og:image', content: 'https://example.com/og.png' })
|
|
40
|
+
expect(head.meta).toContainEqual({ property: 'og:url', content: 'https://example.com/' })
|
|
41
|
+
expect(head.meta).toContainEqual({ property: 'og:type', content: 'website' })
|
|
42
|
+
expect(head.meta).toContainEqual({ property: 'og:site_name', content: 'Example' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('emits Twitter Card meta tags', () => {
|
|
46
|
+
beginHeadCollection()
|
|
47
|
+
useSeoMeta({
|
|
48
|
+
twitterCard: 'summary_large_image',
|
|
49
|
+
twitterTitle: 'Tweet Title',
|
|
50
|
+
twitterDescription: 'Tweet Desc',
|
|
51
|
+
twitterImage: 'https://example.com/tw.png',
|
|
52
|
+
twitterSite: '@mysite',
|
|
53
|
+
})
|
|
54
|
+
const [head] = endHeadCollection()
|
|
55
|
+
expect(head.meta).toContainEqual({ name: 'twitter:card', content: 'summary_large_image' })
|
|
56
|
+
expect(head.meta).toContainEqual({ name: 'twitter:title', content: 'Tweet Title' })
|
|
57
|
+
expect(head.meta).toContainEqual({ name: 'twitter:description', content: 'Tweet Desc' })
|
|
58
|
+
expect(head.meta).toContainEqual({ name: 'twitter:image', content: 'https://example.com/tw.png' })
|
|
59
|
+
expect(head.meta).toContainEqual({ name: 'twitter:site', content: '@mysite' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('emits a canonical link element', () => {
|
|
63
|
+
beginHeadCollection()
|
|
64
|
+
useSeoMeta({ canonical: 'https://example.com/my-page' })
|
|
65
|
+
const [head] = endHeadCollection()
|
|
66
|
+
expect(head.link).toContainEqual({ rel: 'canonical', href: 'https://example.com/my-page' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('omits meta/link arrays when no tags are provided', () => {
|
|
70
|
+
beginHeadCollection()
|
|
71
|
+
useSeoMeta({ title: 'Only Title' })
|
|
72
|
+
const [head] = endHeadCollection()
|
|
73
|
+
expect(head.meta).toBeUndefined()
|
|
74
|
+
expect(head.link).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('only emits tags for fields that are explicitly set', () => {
|
|
78
|
+
beginHeadCollection()
|
|
79
|
+
useSeoMeta({ ogTitle: 'Just OG' })
|
|
80
|
+
const [head] = endHeadCollection()
|
|
81
|
+
// description was not provided — must not appear
|
|
82
|
+
expect(head.meta?.some((m) => m.name === 'description')).toBeFalsy()
|
|
83
|
+
expect(head.meta).toContainEqual({ property: 'og:title', content: 'Just OG' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('allows all fields to be set together', () => {
|
|
87
|
+
beginHeadCollection()
|
|
88
|
+
useSeoMeta({
|
|
89
|
+
title: 'Full Page',
|
|
90
|
+
description: 'Full description.',
|
|
91
|
+
ogTitle: 'Full OG Title',
|
|
92
|
+
ogDescription: 'Full OG Desc',
|
|
93
|
+
ogImage: 'https://example.com/og.png',
|
|
94
|
+
ogUrl: 'https://example.com/',
|
|
95
|
+
ogType: 'article',
|
|
96
|
+
ogSiteName: 'My Site',
|
|
97
|
+
twitterCard: 'summary',
|
|
98
|
+
twitterTitle: 'Full Twitter Title',
|
|
99
|
+
twitterDescription: 'Full Twitter Desc',
|
|
100
|
+
twitterImage: 'https://example.com/tw.png',
|
|
101
|
+
twitterSite: '@site',
|
|
102
|
+
canonical: 'https://example.com/full',
|
|
103
|
+
})
|
|
104
|
+
const [head] = endHeadCollection()
|
|
105
|
+
expect(head.title).toBe('Full Page')
|
|
106
|
+
expect(head.meta).toHaveLength(12) // description + 6 OG + 5 Twitter
|
|
107
|
+
expect(head.link).toHaveLength(1)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* from the HTTP server wiring in preview.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolve, join } from 'pathe'
|
|
8
9
|
import { Readable } from 'node:stream'
|
|
9
10
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
11
|
|
|
@@ -24,6 +25,19 @@ export type IsrCacheStatus = 'HIT' | 'STALE' | 'MISS'
|
|
|
24
25
|
|
|
25
26
|
export type SsrHandlerFn = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
26
27
|
|
|
28
|
+
// ─── Path traversal guard ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when `urlPath` joined onto `rootDir` resolves to a path that
|
|
32
|
+
* is strictly inside (or equal to) `rootDir`. A traversal attempt such as
|
|
33
|
+
* `../../../../etc/passwd` would resolve outside `rootDir` and return `false`.
|
|
34
|
+
*/
|
|
35
|
+
export function isPathBounded(rootDir: string, urlPath: string): boolean {
|
|
36
|
+
const safeRoot = resolve(rootDir)
|
|
37
|
+
const resolved = resolve(join(rootDir, urlPath))
|
|
38
|
+
return resolved === safeRoot || resolved.startsWith(safeRoot + '/')
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
// ─── Route pattern matching ───────────────────────────────────────────────────
|
|
28
42
|
|
|
29
43
|
/**
|
|
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
6
6
|
import {
|
|
7
7
|
type IsrCacheEntry,
|
|
8
8
|
type SsrHandlerFn,
|
|
9
|
+
isPathBounded,
|
|
9
10
|
findRevalidate,
|
|
10
11
|
findRenderMode,
|
|
11
12
|
renderForIsr,
|
|
@@ -59,6 +60,25 @@ function getMimeType(filePath: string): string {
|
|
|
59
60
|
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Returns the appropriate Cache-Control header value for a file.
|
|
65
|
+
* Vite content-hashes assets placed in the /assets/ directory, so they
|
|
66
|
+
* can be cached indefinitely. Everything else (HTML, etc.) must not be cached.
|
|
67
|
+
*/
|
|
68
|
+
function getCacheControl(filePath: string): string {
|
|
69
|
+
if (filePath.includes('/assets/')) return 'public, max-age=31536000, immutable'
|
|
70
|
+
return 'no-cache'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sets standard security headers on every response.
|
|
75
|
+
*/
|
|
76
|
+
function setSecurityHeaders(res: ServerResponse): void {
|
|
77
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
78
|
+
res.setHeader('X-Frame-Options', 'DENY')
|
|
79
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
/**
|
|
63
83
|
* Serves a static file from the dist directory.
|
|
64
84
|
* Returns true if the file was served, false otherwise.
|
|
@@ -70,6 +90,13 @@ function serveStaticFile(
|
|
|
70
90
|
): boolean {
|
|
71
91
|
const urlPath = (req.url ?? '/').split('?')[0]
|
|
72
92
|
|
|
93
|
+
// Guard against path traversal: resolved path must stay within distDir.
|
|
94
|
+
if (!isPathBounded(distDir, urlPath)) {
|
|
95
|
+
res.statusCode = 400
|
|
96
|
+
res.end('Bad Request')
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
// Try exact file path
|
|
74
101
|
let filePath = join(distDir, urlPath)
|
|
75
102
|
|
|
@@ -87,11 +114,36 @@ function serveStaticFile(
|
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
res.setHeader('Content-Type', getMimeType(filePath))
|
|
90
|
-
res.setHeader('Cache-Control',
|
|
117
|
+
res.setHeader('Cache-Control', getCacheControl(filePath))
|
|
118
|
+
setSecurityHeaders(res)
|
|
91
119
|
createReadStream(filePath).pipe(res)
|
|
92
120
|
return true
|
|
93
121
|
}
|
|
94
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Registers SIGTERM/SIGINT handlers that gracefully drain the server before exiting.
|
|
125
|
+
* Waits up to 10 seconds for in-flight requests to complete, then force-exits.
|
|
126
|
+
*/
|
|
127
|
+
function registerGracefulShutdown(server: ReturnType<typeof createHttpServer>): void {
|
|
128
|
+
function shutdown(signal: string): void {
|
|
129
|
+
console.log(`[cer-app] Received ${signal}, shutting down gracefully...`)
|
|
130
|
+
server.close(() => {
|
|
131
|
+
console.log('[cer-app] Server closed.')
|
|
132
|
+
process.exit(0)
|
|
133
|
+
})
|
|
134
|
+
// Force exit if connections haven't drained within 10 seconds.
|
|
135
|
+
const t = setTimeout(() => {
|
|
136
|
+
console.error('[cer-app] Forced shutdown after 10 s — connections did not drain.')
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}, 10_000)
|
|
139
|
+
// Allow the Node.js event loop to exit before the timeout fires if all other work is done.
|
|
140
|
+
t.unref()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
144
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
145
|
+
}
|
|
146
|
+
|
|
95
147
|
export function previewCommand(): Command {
|
|
96
148
|
return new Command('preview')
|
|
97
149
|
.description('Preview the production build')
|
|
@@ -149,6 +201,11 @@ export function previewCommand(): Command {
|
|
|
149
201
|
const isrCache = new Map<string, IsrCacheEntry>()
|
|
150
202
|
|
|
151
203
|
const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
204
|
+
setSecurityHeaders(res)
|
|
205
|
+
// Default: HTML and API responses must not be cached. Asset paths below
|
|
206
|
+
// override this with getCacheControl() for content-hashed files.
|
|
207
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
208
|
+
|
|
152
209
|
const url = req.url ?? '/'
|
|
153
210
|
const urlPath = url.split('?')[0]
|
|
154
211
|
const method = req.method ?? 'GET'
|
|
@@ -308,9 +365,16 @@ export function previewCommand(): Command {
|
|
|
308
365
|
}
|
|
309
366
|
})
|
|
310
367
|
|
|
368
|
+
// Protect against slow-send attacks: abort requests that stall during
|
|
369
|
+
// header delivery or that take too long to complete.
|
|
370
|
+
server.headersTimeout = 10_000 // 10 s to receive all request headers
|
|
371
|
+
server.requestTimeout = 30_000 // 30 s for the full request/response cycle
|
|
372
|
+
|
|
311
373
|
server.listen(port, options.host, () => {
|
|
312
374
|
console.log(`[cer-app] SSR preview running at http://${options.host}:${port}`)
|
|
313
375
|
})
|
|
376
|
+
|
|
377
|
+
registerGracefulShutdown(server)
|
|
314
378
|
} else {
|
|
315
379
|
// Static file server (SPA / SSG)
|
|
316
380
|
console.log('[cer-app] Starting static preview server...')
|
|
@@ -321,6 +385,9 @@ export function previewCommand(): Command {
|
|
|
321
385
|
}
|
|
322
386
|
|
|
323
387
|
const server = createHttpServer((req: IncomingMessage, res: ServerResponse) => {
|
|
388
|
+
setSecurityHeaders(res)
|
|
389
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
390
|
+
|
|
324
391
|
const urlPath = (req.url ?? '/').split('?')[0]
|
|
325
392
|
// SSG builds put assets in dist/client/ while HTML lives in dist/.
|
|
326
393
|
// For requests with a non-HTML file extension, check dist/client/ first
|
|
@@ -329,9 +396,12 @@ export function previewCommand(): Command {
|
|
|
329
396
|
const ext = extname(urlPath).toLowerCase()
|
|
330
397
|
if (ext && ext !== '.html' && existsSync(clientDist)) {
|
|
331
398
|
const assetPath = join(clientDist, urlPath)
|
|
332
|
-
if (
|
|
399
|
+
if (
|
|
400
|
+
isPathBounded(clientDist, urlPath) &&
|
|
401
|
+
existsSync(assetPath) && !statSync(assetPath).isDirectory()
|
|
402
|
+
) {
|
|
333
403
|
res.setHeader('Content-Type', getMimeType(assetPath))
|
|
334
|
-
res.setHeader('Cache-Control',
|
|
404
|
+
res.setHeader('Cache-Control', getCacheControl(assetPath))
|
|
335
405
|
createReadStream(assetPath).pipe(res)
|
|
336
406
|
return
|
|
337
407
|
}
|
|
@@ -343,12 +413,14 @@ export function previewCommand(): Command {
|
|
|
343
413
|
}
|
|
344
414
|
})
|
|
345
415
|
|
|
416
|
+
server.headersTimeout = 10_000
|
|
417
|
+
server.requestTimeout = 30_000
|
|
418
|
+
|
|
346
419
|
server.listen(port, options.host, () => {
|
|
347
420
|
console.log(`[cer-app] Static preview running at http://${options.host}:${port}`)
|
|
348
421
|
})
|
|
349
|
-
}
|
|
350
422
|
|
|
351
|
-
|
|
352
|
-
|
|
423
|
+
registerGracefulShutdown(server)
|
|
424
|
+
}
|
|
353
425
|
})
|
|
354
426
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,9 @@ export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig } from '.
|
|
|
7
7
|
export type { HydrateStrategy, SsgPathsContext, PageSsgConfig, PageMeta, PageLoaderContext, PageLoader } from './types/page.js'
|
|
8
8
|
export type { ApiRequest, ApiResponse, ApiHandler, ApiContext } from './types/api.js'
|
|
9
9
|
export type { AppContext, AppPlugin } from './types/plugin.js'
|
|
10
|
-
export type {
|
|
10
|
+
export type { MiddlewareFn, GuardResult, ServerMiddleware } from './types/middleware.js'
|
|
11
|
+
export type { SeoMetaInput } from './runtime/composables/use-seo-meta.js'
|
|
12
|
+
export type { CookieOptions, CookieRef } from './runtime/composables/use-cookie.js'
|
|
11
13
|
|
|
12
14
|
// Re-export resolved config type for use in build scripts
|
|
13
15
|
export type { ResolvedCerConfig } from './plugin/dev-server.js'
|
package/src/plugin/dev-server.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ResolvedCerConfig {
|
|
|
19
19
|
router: { base?: string; scrollToFragment?: boolean | object }
|
|
20
20
|
jitCss: { content: string[]; extendedColors: boolean }
|
|
21
21
|
autoImports: { components: boolean; composables: boolean; directives: boolean; runtime: boolean }
|
|
22
|
-
runtimeConfig: { public: Record<string, unknown> }
|
|
22
|
+
runtimeConfig: { public: Record<string, unknown>; private: Record<string, string> }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -76,7 +76,7 @@ const RUNTIME_GLOBALS = [
|
|
|
76
76
|
|
|
77
77
|
const DIRECTIVE_GLOBALS = ['when', 'each', 'match', 'anchorBlock']
|
|
78
78
|
|
|
79
|
-
const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig']
|
|
79
|
+
const FRAMEWORK_GLOBALS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig', 'defineMiddleware', 'useSeoMeta', 'useCookie']
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Scans a composables directory and returns a map of export name → file path.
|
|
@@ -214,9 +214,11 @@ export async function generateVirtualModuleDts(
|
|
|
214
214
|
lines.push(`}`)
|
|
215
215
|
lines.push('')
|
|
216
216
|
lines.push(`declare module 'virtual:cer-app-config' {`)
|
|
217
|
-
lines.push(` import type { RuntimePublicConfig } from '@jasonshimmy/vite-plugin-cer-app/types'`)
|
|
217
|
+
lines.push(` import type { RuntimePublicConfig, RuntimePrivateConfig } from '@jasonshimmy/vite-plugin-cer-app/types'`)
|
|
218
218
|
lines.push(` export const appConfig: { mode: string; router: Record<string, unknown>; ssg: Record<string, unknown> }`)
|
|
219
219
|
lines.push(` export const runtimeConfig: { public: RuntimePublicConfig }`)
|
|
220
|
+
lines.push(` /** Server-only — present only in the SSR bundle. Always \`undefined\` in the client bundle. */`)
|
|
221
|
+
lines.push(` export const _runtimePrivateDefaults: RuntimePrivateConfig | undefined`)
|
|
220
222
|
lines.push(` export default appConfig`)
|
|
221
223
|
lines.push(`}`)
|
|
222
224
|
lines.push('')
|