@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ROADMAP.md +278 -0
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/preview-isr.d.ts +6 -0
  5. package/dist/cli/commands/preview-isr.d.ts.map +1 -1
  6. package/dist/cli/commands/preview-isr.js +12 -0
  7. package/dist/cli/commands/preview-isr.js.map +1 -1
  8. package/dist/cli/commands/preview.d.ts.map +1 -1
  9. package/dist/cli/commands/preview.js +66 -6
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/plugin/dev-server.d.ts +1 -0
  14. package/dist/plugin/dev-server.d.ts.map +1 -1
  15. package/dist/plugin/dts-generator.d.ts.map +1 -1
  16. package/dist/plugin/dts-generator.js +4 -2
  17. package/dist/plugin/dts-generator.js.map +1 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +30 -12
  20. package/dist/plugin/index.js.map +1 -1
  21. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  22. package/dist/plugin/transforms/auto-import.js +5 -4
  23. package/dist/plugin/transforms/auto-import.js.map +1 -1
  24. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  25. package/dist/plugin/virtual/routes.js +7 -1
  26. package/dist/plugin/virtual/routes.js.map +1 -1
  27. package/dist/runtime/composables/define-middleware.d.ts +15 -0
  28. package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
  29. package/dist/runtime/composables/define-middleware.js +16 -0
  30. package/dist/runtime/composables/define-middleware.js.map +1 -0
  31. package/dist/runtime/composables/index.d.ts +7 -1
  32. package/dist/runtime/composables/index.d.ts.map +1 -1
  33. package/dist/runtime/composables/index.js +4 -1
  34. package/dist/runtime/composables/index.js.map +1 -1
  35. package/dist/runtime/composables/use-cookie.d.ts +38 -0
  36. package/dist/runtime/composables/use-cookie.d.ts.map +1 -0
  37. package/dist/runtime/composables/use-cookie.js +104 -0
  38. package/dist/runtime/composables/use-cookie.js.map +1 -0
  39. package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
  40. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  41. package/dist/runtime/composables/use-runtime-config.js +42 -8
  42. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  43. package/dist/runtime/composables/use-seo-meta.d.ts +42 -0
  44. package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -0
  45. package/dist/runtime/composables/use-seo-meta.js +58 -0
  46. package/dist/runtime/composables/use-seo-meta.js.map +1 -0
  47. package/dist/runtime/entry-server-template.d.ts +1 -1
  48. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  49. package/dist/runtime/entry-server-template.js +15 -3
  50. package/dist/runtime/entry-server-template.js.map +1 -1
  51. package/dist/types/config.d.ts +14 -0
  52. package/dist/types/config.d.ts.map +1 -1
  53. package/dist/types/config.js.map +1 -1
  54. package/dist/types/index.d.ts +2 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/dist/types/middleware.d.ts +8 -2
  57. package/dist/types/middleware.d.ts.map +1 -1
  58. package/docs/cli.md +5 -0
  59. package/docs/composables.md +165 -7
  60. package/docs/configuration.md +53 -3
  61. package/docs/middleware.md +53 -25
  62. package/e2e/cypress/e2e/cookie.cy.ts +68 -0
  63. package/e2e/cypress/e2e/middleware.cy.ts +45 -0
  64. package/e2e/cypress/e2e/preview-hardening.cy.ts +79 -0
  65. package/e2e/cypress/e2e/seo-meta.cy.ts +108 -0
  66. package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
  67. package/e2e/kitchen-sink/app/pages/cookie-test.ts +22 -0
  68. package/e2e/kitchen-sink/app/pages/seo-test.ts +23 -0
  69. package/package.json +1 -1
  70. package/src/__tests__/cli/preview-hardening.test.ts +175 -0
  71. package/src/__tests__/cli/preview-isr.test.ts +30 -0
  72. package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
  73. package/src/__tests__/plugin/entry-server-template.test.ts +21 -0
  74. package/src/__tests__/plugin/resolve-config.test.ts +18 -0
  75. package/src/__tests__/plugin/transforms/auto-import.test.ts +39 -0
  76. package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
  77. package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
  78. package/src/__tests__/runtime/define-middleware.test.ts +43 -0
  79. package/src/__tests__/runtime/use-cookie.test.ts +218 -0
  80. package/src/__tests__/runtime/use-runtime-config.test.ts +86 -2
  81. package/src/__tests__/runtime/use-seo-meta.test.ts +109 -0
  82. package/src/cli/commands/preview-isr.ts +14 -0
  83. package/src/cli/commands/preview.ts +78 -6
  84. package/src/index.ts +3 -1
  85. package/src/plugin/dev-server.ts +1 -1
  86. package/src/plugin/dts-generator.ts +4 -2
  87. package/src/plugin/index.ts +32 -11
  88. package/src/plugin/transforms/auto-import.ts +5 -4
  89. package/src/plugin/virtual/routes.ts +7 -1
  90. package/src/runtime/composables/define-middleware.ts +17 -0
  91. package/src/runtime/composables/index.ts +7 -1
  92. package/src/runtime/composables/use-cookie.ts +128 -0
  93. package/src/runtime/composables/use-runtime-config.ts +67 -11
  94. package/src/runtime/composables/use-seo-meta.ts +75 -0
  95. package/src/runtime/entry-server-template.ts +15 -3
  96. package/src/types/config.ts +15 -0
  97. package/src/types/index.ts +2 -2
  98. 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', 'no-cache')
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 (existsSync(assetPath) && !statSync(assetPath).isDirectory()) {
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', 'no-cache')
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
- process.on('SIGTERM', () => process.exit(0))
352
- process.on('SIGINT', () => process.exit(0))
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 { NextFunction, RouteMiddleware, ServerMiddleware } from './types/middleware.js'
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'
@@ -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('')