@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,175 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'pathe'
|
|
4
|
+
|
|
5
|
+
// Read the preview command source to verify hardening requirements are present.
|
|
6
|
+
const src = readFileSync(
|
|
7
|
+
resolve(import.meta.dirname, '../../cli/commands/preview.ts'),
|
|
8
|
+
'utf-8',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// ─── Security headers ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe('preview server — security headers', () => {
|
|
14
|
+
it('defines setSecurityHeaders helper', () => {
|
|
15
|
+
expect(src).toContain('function setSecurityHeaders(')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('sets X-Content-Type-Options: nosniff', () => {
|
|
19
|
+
expect(src).toContain("'X-Content-Type-Options'")
|
|
20
|
+
expect(src).toContain('nosniff')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('sets X-Frame-Options: DENY', () => {
|
|
24
|
+
expect(src).toContain("'X-Frame-Options'")
|
|
25
|
+
expect(src).toContain('DENY')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('sets Referrer-Policy: strict-origin-when-cross-origin', () => {
|
|
29
|
+
expect(src).toContain("'Referrer-Policy'")
|
|
30
|
+
expect(src).toContain('strict-origin-when-cross-origin')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('calls setSecurityHeaders in serveStaticFile', () => {
|
|
34
|
+
const fnStart = src.indexOf('function serveStaticFile(')
|
|
35
|
+
const nextFn = src.indexOf('\nfunction ', fnStart + 1)
|
|
36
|
+
const body = src.slice(fnStart, nextFn > -1 ? nextFn : undefined)
|
|
37
|
+
expect(body).toContain('setSecurityHeaders(res)')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('calls setSecurityHeaders at the top of the SSR request handler', () => {
|
|
41
|
+
const handlerStart = src.indexOf('createHttpServer(async (req: IncomingMessage, res: ServerResponse)')
|
|
42
|
+
const handlerEnd = src.indexOf('server.listen(port', handlerStart)
|
|
43
|
+
const handler = src.slice(handlerStart, handlerEnd)
|
|
44
|
+
expect(handler).toContain('setSecurityHeaders(res)')
|
|
45
|
+
// It should be the first thing done before any routing logic
|
|
46
|
+
const secHeadersIdx = handler.indexOf('setSecurityHeaders(res)')
|
|
47
|
+
const urlParseIdx = handler.indexOf('req.url ?? ')
|
|
48
|
+
expect(secHeadersIdx).toBeLessThan(urlParseIdx)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('calls setSecurityHeaders at the top of the static request handler', () => {
|
|
52
|
+
const handlerStart = src.indexOf('createHttpServer((req: IncomingMessage, res: ServerResponse)')
|
|
53
|
+
const handlerEnd = src.indexOf('server.listen(port', handlerStart)
|
|
54
|
+
const handler = src.slice(handlerStart, handlerEnd)
|
|
55
|
+
expect(handler).toContain('setSecurityHeaders(res)')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ─── Cache-Control ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe('preview server — Cache-Control', () => {
|
|
62
|
+
it('defines getCacheControl helper', () => {
|
|
63
|
+
expect(src).toContain('function getCacheControl(')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns immutable cache for /assets/ paths', () => {
|
|
67
|
+
expect(src).toContain("'/assets/'")
|
|
68
|
+
expect(src).toContain('public, max-age=31536000, immutable')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns no-cache for non-asset paths', () => {
|
|
72
|
+
// The getCacheControl function must return 'no-cache' as fallback
|
|
73
|
+
const fnStart = src.indexOf('function getCacheControl(')
|
|
74
|
+
const fnEnd = src.indexOf('\nfunction ', fnStart + 1)
|
|
75
|
+
const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
|
|
76
|
+
expect(body).toContain("'no-cache'")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('uses getCacheControl in serveStaticFile instead of hardcoded no-cache', () => {
|
|
80
|
+
const fnStart = src.indexOf('function serveStaticFile(')
|
|
81
|
+
const nextFn = src.indexOf('\nfunction ', fnStart + 1)
|
|
82
|
+
const body = src.slice(fnStart, nextFn > -1 ? nextFn : undefined)
|
|
83
|
+
expect(body).toContain('getCacheControl(filePath)')
|
|
84
|
+
// Must NOT have a hardcoded no-cache
|
|
85
|
+
expect(body).not.toContain("'no-cache'")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('uses getCacheControl for client assets in the static server', () => {
|
|
89
|
+
// The static server also serves assets from dist/client — it should use getCacheControl
|
|
90
|
+
const staticHandlerStart = src.indexOf('createHttpServer((req: IncomingMessage, res: ServerResponse)')
|
|
91
|
+
expect(src.slice(staticHandlerStart)).toContain('getCacheControl(')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ─── Graceful shutdown ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('preview server — graceful shutdown', () => {
|
|
98
|
+
it('defines registerGracefulShutdown helper', () => {
|
|
99
|
+
expect(src).toContain('function registerGracefulShutdown(')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('calls server.close() for graceful drain', () => {
|
|
103
|
+
const fnStart = src.indexOf('function registerGracefulShutdown(')
|
|
104
|
+
const fnEnd = src.indexOf('\nexport ', fnStart)
|
|
105
|
+
const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
|
|
106
|
+
expect(body).toContain('server.close(')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('sets a 10-second force-exit timeout', () => {
|
|
110
|
+
const fnStart = src.indexOf('function registerGracefulShutdown(')
|
|
111
|
+
const fnEnd = src.indexOf('\nexport ', fnStart)
|
|
112
|
+
const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
|
|
113
|
+
expect(body).toContain('10_000')
|
|
114
|
+
expect(body).toContain('process.exit(1)')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('calls t.unref() so the timeout does not keep the event loop alive', () => {
|
|
118
|
+
const fnStart = src.indexOf('function registerGracefulShutdown(')
|
|
119
|
+
const fnEnd = src.indexOf('\nexport ', fnStart)
|
|
120
|
+
const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
|
|
121
|
+
expect(body).toContain('.unref()')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('listens for both SIGTERM and SIGINT', () => {
|
|
125
|
+
expect(src).toContain("'SIGTERM'")
|
|
126
|
+
expect(src).toContain("'SIGINT'")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('logs the signal name on shutdown', () => {
|
|
130
|
+
const fnStart = src.indexOf('function registerGracefulShutdown(')
|
|
131
|
+
const fnEnd = src.indexOf('\nexport ', fnStart)
|
|
132
|
+
const body = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined)
|
|
133
|
+
expect(body).toContain('signal')
|
|
134
|
+
expect(body).toContain('console.log(')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('calls registerGracefulShutdown for the SSR server', () => {
|
|
138
|
+
// registerGracefulShutdown must be called after each server's listen()
|
|
139
|
+
const firstListen = src.indexOf('server.listen(port')
|
|
140
|
+
const firstShutdown = src.indexOf('registerGracefulShutdown(server)')
|
|
141
|
+
expect(firstShutdown).toBeGreaterThan(firstListen)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('calls registerGracefulShutdown for the static server', () => {
|
|
145
|
+
// Both SSR and static server paths should register graceful shutdown
|
|
146
|
+
const allShutdowns = src.split('registerGracefulShutdown(server)').length - 1
|
|
147
|
+
expect(allShutdowns).toBeGreaterThanOrEqual(2)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ─── Request timeouts ─────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe('preview server — request timeouts', () => {
|
|
154
|
+
it('sets server.headersTimeout to protect against slow-send attacks', () => {
|
|
155
|
+
expect(src).toContain('server.headersTimeout')
|
|
156
|
+
expect(src).toContain('10_000')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('sets server.requestTimeout to limit total request duration', () => {
|
|
160
|
+
expect(src).toContain('server.requestTimeout')
|
|
161
|
+
expect(src).toContain('30_000')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('applies timeouts to the SSR server', () => {
|
|
165
|
+
const ssrListenIdx = src.indexOf("console.log(`[cer-app] SSR preview running at")
|
|
166
|
+
const ssrTimeoutIdx = src.lastIndexOf('server.requestTimeout', ssrListenIdx)
|
|
167
|
+
expect(ssrTimeoutIdx).toBeGreaterThan(-1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('applies timeouts to the static server', () => {
|
|
171
|
+
const staticListenIdx = src.indexOf("console.log(`[cer-app] Static preview running at")
|
|
172
|
+
const staticTimeoutIdx = src.lastIndexOf('server.requestTimeout', staticListenIdx)
|
|
173
|
+
expect(staticTimeoutIdx).toBeGreaterThan(-1)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
3
|
import {
|
|
4
|
+
isPathBounded,
|
|
4
5
|
matchRoutePattern,
|
|
5
6
|
findRevalidate,
|
|
6
7
|
findRenderMode,
|
|
@@ -9,6 +10,35 @@ import {
|
|
|
9
10
|
type IsrCacheEntry,
|
|
10
11
|
} from '../../cli/commands/preview-isr.js'
|
|
11
12
|
|
|
13
|
+
// ─── isPathBounded ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('isPathBounded', () => {
|
|
16
|
+
it('allows normal file paths inside the root', () => {
|
|
17
|
+
expect(isPathBounded('/dist', '/index.html')).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('allows nested paths inside the root', () => {
|
|
21
|
+
expect(isPathBounded('/dist', '/assets/app.js')).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('allows the root path itself', () => {
|
|
25
|
+
expect(isPathBounded('/dist', '/')).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('blocks simple path traversal (../ escape)', () => {
|
|
29
|
+
expect(isPathBounded('/dist', '/../../../../etc/passwd')).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('blocks a path that escapes by one level', () => {
|
|
33
|
+
expect(isPathBounded('/dist', '/../secret')).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not confuse a sibling directory for the root', () => {
|
|
37
|
+
// /dist-other starts with /dist but is NOT inside /dist
|
|
38
|
+
expect(isPathBounded('/dist', '/../dist-other/file')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
12
42
|
// ─── matchRoutePattern ────────────────────────────────────────────────────────
|
|
13
43
|
|
|
14
44
|
describe('matchRoutePattern', () => {
|
|
@@ -245,6 +245,36 @@ describe('cerApp plugin — load hook', () => {
|
|
|
245
245
|
expect(result).toContain('appConfig')
|
|
246
246
|
expect(result).toContain('ssg')
|
|
247
247
|
})
|
|
248
|
+
|
|
249
|
+
it('excludes _runtimePrivateDefaults from the client bundle', async () => {
|
|
250
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } })
|
|
251
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
252
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
253
|
+
// Client load (ssr: false / omitted)
|
|
254
|
+
const clientResult = await plugin.load('\0virtual:cer-app-config') as string
|
|
255
|
+
expect(clientResult).not.toContain('_runtimePrivateDefaults')
|
|
256
|
+
expect(clientResult).not.toContain('dbUrl')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('includes _runtimePrivateDefaults in the SSR bundle', async () => {
|
|
260
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } })
|
|
261
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
262
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
263
|
+
// SSR load (ssr: true)
|
|
264
|
+
const ssrResult = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
|
|
265
|
+
expect(ssrResult).toContain('_runtimePrivateDefaults')
|
|
266
|
+
expect(ssrResult).toContain('dbUrl')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('caches SSR and client virtual:cer-app-config separately', async () => {
|
|
270
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { token: '' } } })
|
|
271
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
272
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
273
|
+
const client = await plugin.load('\0virtual:cer-app-config') as string
|
|
274
|
+
const ssr = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
|
|
275
|
+
expect(client).not.toContain('_runtimePrivateDefaults')
|
|
276
|
+
expect(ssr).toContain('_runtimePrivateDefaults')
|
|
277
|
+
})
|
|
248
278
|
})
|
|
249
279
|
|
|
250
280
|
describe('cerApp plugin — transform hook', () => {
|
|
@@ -363,6 +393,26 @@ describe('cerApp plugin — buildStart hook', () => {
|
|
|
363
393
|
await plugin.buildStart()
|
|
364
394
|
expect(writeTsconfigPaths).toHaveBeenCalledTimes(1)
|
|
365
395
|
})
|
|
396
|
+
|
|
397
|
+
it('warms virtual:cer-app-config cache under :client key so load() hits it', async () => {
|
|
398
|
+
// After buildStart(), a subsequent client load (ssr: false) should be served
|
|
399
|
+
// from cache (generateAppConfigModule is not a public mock, so we verify by
|
|
400
|
+
// ensuring the load hook returns a non-null result without triggering the
|
|
401
|
+
// real generator — which is mocked at module level to a fixed string '// mock'
|
|
402
|
+
// for other modules; appConfig is not mocked, so it generates real code).
|
|
403
|
+
const plugin = getCerPlugin({ runtimeConfig: { private: { token: '' } } })
|
|
404
|
+
plugin.config({ root: '/project' }, { command: 'build', mode: 'production' })
|
|
405
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
406
|
+
await plugin.buildStart()
|
|
407
|
+
|
|
408
|
+
// Client load should return a result that does NOT include _runtimePrivateDefaults
|
|
409
|
+
const result = await plugin.load('\0virtual:cer-app-config') as string
|
|
410
|
+
expect(result).not.toContain('_runtimePrivateDefaults')
|
|
411
|
+
|
|
412
|
+
// SSR load (different cache key) should include _runtimePrivateDefaults
|
|
413
|
+
const ssrResult = await plugin.load('\0virtual:cer-app-config', { ssr: true }) as string
|
|
414
|
+
expect(ssrResult).toContain('_runtimePrivateDefaults')
|
|
415
|
+
})
|
|
366
416
|
})
|
|
367
417
|
|
|
368
418
|
describe('cerApp plugin — configureServer hook', () => {
|
|
@@ -195,4 +195,25 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
|
|
|
195
195
|
expect(src).toContain('export const isrHandler')
|
|
196
196
|
expect(src).toContain('createIsrHandler(routes, handler)')
|
|
197
197
|
})
|
|
198
|
+
|
|
199
|
+
// ─── useCookie req/res store ──────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
it('creates _cerReqStore AsyncLocalStorage for request-scoped cookie access', () => {
|
|
202
|
+
expect(src).toContain('_cerReqStore')
|
|
203
|
+
expect(src).toContain('__CER_REQ_STORE__')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('exposes _cerReqStore on globalThis as __CER_REQ_STORE__', () => {
|
|
207
|
+
expect(src).toContain('__CER_REQ_STORE__ = _cerReqStore')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('wraps handler body in _cerReqStore.run({ req, res }, ...) so useCookie can access req/res', () => {
|
|
211
|
+
expect(src).toContain('_cerReqStore.run({ req, res }')
|
|
212
|
+
// req/res store wraps the data store — it must appear before _cerDataStore.run
|
|
213
|
+
const reqStoreIdx = src.indexOf('_cerReqStore.run(')
|
|
214
|
+
const dataStoreIdx = src.indexOf('_cerDataStore.run(')
|
|
215
|
+
expect(reqStoreIdx).toBeGreaterThan(-1)
|
|
216
|
+
expect(dataStoreIdx).toBeGreaterThan(-1)
|
|
217
|
+
expect(reqStoreIdx).toBeLessThan(dataStoreIdx)
|
|
218
|
+
})
|
|
198
219
|
})
|
|
@@ -155,4 +155,22 @@ describe('resolveConfig', () => {
|
|
|
155
155
|
const cfg = resolveConfig({ runtimeConfig: { public: { foo: 42 } } }, ROOT)
|
|
156
156
|
expect(cfg.runtimeConfig.public.foo).toBe(42)
|
|
157
157
|
})
|
|
158
|
+
|
|
159
|
+
it('defaults runtimeConfig.private to an empty object', () => {
|
|
160
|
+
const cfg = resolveConfig({}, ROOT)
|
|
161
|
+
expect(cfg.runtimeConfig.private).toEqual({})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('passes runtimeConfig.private values through', () => {
|
|
165
|
+
const cfg = resolveConfig({ runtimeConfig: { private: { dbUrl: '', secretKey: '' } } }, ROOT)
|
|
166
|
+
expect(cfg.runtimeConfig.private).toEqual({ dbUrl: '', secretKey: '' })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('preserves both public and private when both are supplied', () => {
|
|
170
|
+
const cfg = resolveConfig({
|
|
171
|
+
runtimeConfig: { public: { apiBase: '/api' }, private: { token: '' } },
|
|
172
|
+
}, ROOT)
|
|
173
|
+
expect(cfg.runtimeConfig.public).toEqual({ apiBase: '/api' })
|
|
174
|
+
expect(cfg.runtimeConfig.private).toEqual({ token: '' })
|
|
175
|
+
})
|
|
158
176
|
})
|
|
@@ -55,6 +55,22 @@ describe('autoImportTransform — target directory gating', () => {
|
|
|
55
55
|
)
|
|
56
56
|
expect(result).not.toBeNull()
|
|
57
57
|
})
|
|
58
|
+
|
|
59
|
+
it('transforms files in middleware/ (so defineMiddleware is auto-imported)', () => {
|
|
60
|
+
const result = autoImportTransform(
|
|
61
|
+
"export default defineMiddleware(() => true)",
|
|
62
|
+
'/project/app/middleware/auth.ts',
|
|
63
|
+
opts,
|
|
64
|
+
)
|
|
65
|
+
expect(result).not.toBeNull()
|
|
66
|
+
expect(result).toContain('defineMiddleware')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('still returns null for composables/ (not in scope)', () => {
|
|
70
|
+
expect(
|
|
71
|
+
autoImportTransform("export default defineMiddleware(() => true)", '/project/app/composables/useTheme.ts', opts),
|
|
72
|
+
).toBeNull()
|
|
73
|
+
})
|
|
58
74
|
})
|
|
59
75
|
|
|
60
76
|
// ─── No injection needed ─────────────────────────────────────────────────────
|
|
@@ -249,6 +265,29 @@ describe('autoImportTransform — framework composable injection', () => {
|
|
|
249
265
|
const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
|
|
250
266
|
expect(count).toBe(1)
|
|
251
267
|
})
|
|
268
|
+
|
|
269
|
+
it('injects useSeoMeta import when useSeoMeta is used', () => {
|
|
270
|
+
const code = "component('page-home', () => { useSeoMeta({ title: 'Home', description: 'Welcome' }); return html`<h1>Home</h1>` })"
|
|
271
|
+
const result = autoImportTransform(code, '/project/app/pages/index.ts', opts)!
|
|
272
|
+
expect(result).toContain(`from ${FRAMEWORK_PKG}`)
|
|
273
|
+
expect(result).toContain('useSeoMeta')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('injects useCookie import when useCookie is used', () => {
|
|
277
|
+
const code = "component('page-profile', () => { const session = useCookie('session'); return html`<div></div>` })"
|
|
278
|
+
const result = autoImportTransform(code, '/project/app/pages/profile.ts', opts)!
|
|
279
|
+
expect(result).toContain(`from ${FRAMEWORK_PKG}`)
|
|
280
|
+
expect(result).toContain('useCookie')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('injects useSeoMeta and useCookie alongside other framework composables in a single import', () => {
|
|
284
|
+
const code = "component('page-shop', () => { useSeoMeta({ title: 'Shop' }); const cart = useCookie('cart'); return html`<div></div>` })"
|
|
285
|
+
const result = autoImportTransform(code, '/project/app/pages/shop.ts', opts)!
|
|
286
|
+
expect(result).toContain('useSeoMeta')
|
|
287
|
+
expect(result).toContain('useCookie')
|
|
288
|
+
const count = result.split(`from ${FRAMEWORK_PKG}`).length - 1
|
|
289
|
+
expect(count).toBe(1)
|
|
290
|
+
})
|
|
252
291
|
})
|
|
253
292
|
|
|
254
293
|
// ─── Composable import injection ─────────────────────────────────────────────
|
|
@@ -65,4 +65,19 @@ describe('generateMiddlewareCode', () => {
|
|
|
65
65
|
expect(code).toContain('export const middleware')
|
|
66
66
|
expect(code).toContain('export default middleware')
|
|
67
67
|
})
|
|
68
|
+
|
|
69
|
+
it('prefixes identifier with underscore when filename starts with a digit', async () => {
|
|
70
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${MIDDLEWARE}/2fa.ts`])
|
|
71
|
+
const code = await generateMiddlewareCode(MIDDLEWARE)
|
|
72
|
+
// Leading digit is not valid in a JS identifier — must be prefixed
|
|
73
|
+
expect(code).toContain('_m__2fa')
|
|
74
|
+
expect(code).toContain('"2fa"')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('strips .js extension as well as .ts', async () => {
|
|
78
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${MIDDLEWARE}/auth.js`])
|
|
79
|
+
const code = await generateMiddlewareCode(MIDDLEWARE)
|
|
80
|
+
expect(code).toContain('_m_auth')
|
|
81
|
+
expect(code).toContain('"auth": _m_auth')
|
|
82
|
+
})
|
|
68
83
|
})
|
|
@@ -129,6 +129,38 @@ describe('generateRoutesCode', () => {
|
|
|
129
129
|
const code = await generateRoutesCode(PAGES)
|
|
130
130
|
expect(code).not.toContain('beforeEnter')
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
it('uses return-value API (await handler) not callback-style (new Promise)', async () => {
|
|
134
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
135
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
136
|
+
`component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
|
|
137
|
+
)
|
|
138
|
+
const code = await generateRoutesCode(PAGES)
|
|
139
|
+
expect(code).toContain('await handler(to, from)')
|
|
140
|
+
expect(code).not.toContain('new Promise')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('wraps handler call in try-catch (blocks navigation on error)', async () => {
|
|
144
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
145
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
146
|
+
`component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
|
|
147
|
+
)
|
|
148
|
+
const code = await generateRoutesCode(PAGES)
|
|
149
|
+
expect(code).toContain('try {')
|
|
150
|
+
expect(code).toContain('} catch (err) {')
|
|
151
|
+
expect(code).toContain('return false')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('logs the middleware name in the error message', async () => {
|
|
155
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
156
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
157
|
+
`component('page-dashboard', () => html\`<div/>\`)\nexport const meta = { middleware: ['auth'] }` as never,
|
|
158
|
+
)
|
|
159
|
+
const code = await generateRoutesCode(PAGES)
|
|
160
|
+
expect(code).toContain('console.error')
|
|
161
|
+
expect(code).toContain('Middleware')
|
|
162
|
+
expect(code).toContain('err)')
|
|
163
|
+
})
|
|
132
164
|
})
|
|
133
165
|
|
|
134
166
|
// ─── meta.layout ─────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { defineMiddleware } from '../../runtime/composables/define-middleware.js'
|
|
3
|
+
import type { MiddlewareFn } from '../../types/middleware.js'
|
|
4
|
+
|
|
5
|
+
describe('defineMiddleware', () => {
|
|
6
|
+
it('returns the function passed to it unchanged', () => {
|
|
7
|
+
const fn: MiddlewareFn = async () => true
|
|
8
|
+
expect(defineMiddleware(fn)).toBe(fn)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('the returned function returns true to allow navigation', async () => {
|
|
12
|
+
const mw = defineMiddleware(async () => true)
|
|
13
|
+
const result = await mw({} as never, null)
|
|
14
|
+
expect(result).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('the returned function returns false to block navigation', async () => {
|
|
18
|
+
const mw = defineMiddleware(async () => false)
|
|
19
|
+
const result = await mw({} as never, null)
|
|
20
|
+
expect(result).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('the returned function returns a string to redirect', async () => {
|
|
24
|
+
const mw = defineMiddleware(async () => '/login')
|
|
25
|
+
const result = await mw({} as never, null)
|
|
26
|
+
expect(result).toBe('/login')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('the returned function receives to and from route states', async () => {
|
|
30
|
+
let capturedTo: unknown
|
|
31
|
+
let capturedFrom: unknown
|
|
32
|
+
const mw = defineMiddleware((to, from) => {
|
|
33
|
+
capturedTo = to
|
|
34
|
+
capturedFrom = from
|
|
35
|
+
return true
|
|
36
|
+
})
|
|
37
|
+
const to = { path: '/dashboard', params: {}, query: {} }
|
|
38
|
+
const from = { path: '/login', params: {}, query: {} }
|
|
39
|
+
await mw(to as never, from as never)
|
|
40
|
+
expect(capturedTo).toBe(to)
|
|
41
|
+
expect(capturedFrom).toBe(from)
|
|
42
|
+
})
|
|
43
|
+
})
|