@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -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 +9 -2
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/index.d.ts +1 -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 +3 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +2 -1
- package/dist/runtime/composables/index.js.map +1 -1
- 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 +34 -8
- package/dist/runtime/composables/use-runtime-config.js.map +1 -1
- 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 +7 -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 +1 -0
- package/docs/composables.md +32 -7
- package/docs/configuration.md +53 -3
- package/docs/middleware.md +53 -25
- package/e2e/cypress/e2e/middleware.cy.ts +45 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
- package/package.json +1 -1
- 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/resolve-config.test.ts +18 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -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-runtime-config.test.ts +62 -1
- package/src/cli/commands/preview-isr.ts +14 -0
- package/src/cli/commands/preview.ts +12 -1
- package/src/index.ts +1 -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 +3 -1
- package/src/runtime/composables/use-runtime-config.ts +57 -11
- package/src/runtime/entry-server-template.ts +7 -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,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
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { useRuntimeConfig, initRuntimeConfig } from '../../runtime/composables/use-runtime-config.js'
|
|
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,65 @@ 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
|
+
})
|
|
59
120
|
})
|
|
@@ -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,
|
|
@@ -70,6 +71,13 @@ function serveStaticFile(
|
|
|
70
71
|
): boolean {
|
|
71
72
|
const urlPath = (req.url ?? '/').split('?')[0]
|
|
72
73
|
|
|
74
|
+
// Guard against path traversal: resolved path must stay within distDir.
|
|
75
|
+
if (!isPathBounded(distDir, urlPath)) {
|
|
76
|
+
res.statusCode = 400
|
|
77
|
+
res.end('Bad Request')
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
// Try exact file path
|
|
74
82
|
let filePath = join(distDir, urlPath)
|
|
75
83
|
|
|
@@ -329,7 +337,10 @@ export function previewCommand(): Command {
|
|
|
329
337
|
const ext = extname(urlPath).toLowerCase()
|
|
330
338
|
if (ext && ext !== '.html' && existsSync(clientDist)) {
|
|
331
339
|
const assetPath = join(clientDist, urlPath)
|
|
332
|
-
if (
|
|
340
|
+
if (
|
|
341
|
+
isPathBounded(clientDist, urlPath) &&
|
|
342
|
+
existsSync(assetPath) && !statSync(assetPath).isDirectory()
|
|
343
|
+
) {
|
|
333
344
|
res.setHeader('Content-Type', getMimeType(assetPath))
|
|
334
345
|
res.setHeader('Cache-Control', 'no-cache')
|
|
335
346
|
createReadStream(assetPath).pipe(res)
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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
11
|
|
|
12
12
|
// Re-export resolved config type for use in build scripts
|
|
13
13
|
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']
|
|
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('')
|
package/src/plugin/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
|
|
|
94
94
|
},
|
|
95
95
|
runtimeConfig: {
|
|
96
96
|
public: userConfig.runtimeConfig?.public ?? {},
|
|
97
|
+
private: userConfig.runtimeConfig?.private ?? {},
|
|
97
98
|
},
|
|
98
99
|
}
|
|
99
100
|
}
|
|
@@ -104,6 +105,7 @@ export function resolveConfig(userConfig: CerAppConfig, root: string = process.c
|
|
|
104
105
|
async function generateVirtualModule(
|
|
105
106
|
id: string,
|
|
106
107
|
config: ResolvedCerConfig,
|
|
108
|
+
ssr = false,
|
|
107
109
|
): Promise<string | null> {
|
|
108
110
|
switch (id) {
|
|
109
111
|
case RESOLVED_IDS.routes:
|
|
@@ -123,7 +125,7 @@ async function generateVirtualModule(
|
|
|
123
125
|
case RESOLVED_IDS.serverMiddleware:
|
|
124
126
|
return generateServerMiddlewareCode(config.serverMiddlewareDir)
|
|
125
127
|
case RESOLVED_IDS.appConfig:
|
|
126
|
-
return generateAppConfigModule(config)
|
|
128
|
+
return generateAppConfigModule(config, ssr)
|
|
127
129
|
case RESOLVED_IDS.loading:
|
|
128
130
|
return generateLoadingCode(config.srcDir)
|
|
129
131
|
case RESOLVED_IDS.error:
|
|
@@ -135,21 +137,29 @@ async function generateVirtualModule(
|
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
139
|
* Generates a virtual module that exports the resolved app config.
|
|
140
|
+
* When `ssr` is true, also exports `_runtimePrivateDefaults` (server-only).
|
|
141
|
+
* The client bundle never receives private keys.
|
|
138
142
|
*/
|
|
139
|
-
function generateAppConfigModule(config: ResolvedCerConfig): string {
|
|
143
|
+
function generateAppConfigModule(config: ResolvedCerConfig, ssr = false): string {
|
|
140
144
|
const exportedConfig = {
|
|
141
145
|
mode: config.mode,
|
|
142
146
|
router: config.router,
|
|
143
147
|
ssg: config.ssg,
|
|
144
148
|
}
|
|
145
149
|
const publicConfig = config.runtimeConfig.public
|
|
146
|
-
|
|
150
|
+
let code =
|
|
147
151
|
`// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\n` +
|
|
148
152
|
`export const appConfig = ${JSON.stringify(exportedConfig, null, 2)}\n` +
|
|
149
153
|
`export default appConfig\n` +
|
|
150
154
|
`\n` +
|
|
151
155
|
`export const runtimeConfig = { public: ${JSON.stringify(publicConfig, null, 2)} }\n`
|
|
152
|
-
|
|
156
|
+
|
|
157
|
+
if (ssr) {
|
|
158
|
+
const privateDefaults = config.runtimeConfig.private
|
|
159
|
+
code += `\nexport const _runtimePrivateDefaults = ${JSON.stringify(privateDefaults, null, 2)}\n`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return code
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
/**
|
|
@@ -245,21 +255,26 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
|
|
|
245
255
|
}
|
|
246
256
|
},
|
|
247
257
|
|
|
248
|
-
async load(id: string) {
|
|
258
|
+
async load(id: string, options?: { ssr?: boolean }) {
|
|
249
259
|
if (id === RESOLVED_APP_ENTRY) return APP_ENTRY_TEMPLATE
|
|
250
260
|
|
|
251
261
|
const allResolved = Object.values(RESOLVED_IDS) as string[]
|
|
252
262
|
if (!allResolved.includes(id)) return null
|
|
253
263
|
|
|
264
|
+
const ssr = options?.ssr ?? false
|
|
265
|
+
// For virtual:cer-app-config the SSR and client variants differ (private
|
|
266
|
+
// defaults are only included in the SSR bundle), so use separate cache keys.
|
|
267
|
+
const cacheKey = id === RESOLVED_IDS.appConfig ? `${id}:${ssr ? 'ssr' : 'client'}` : id
|
|
268
|
+
|
|
254
269
|
// Return from cache if available
|
|
255
|
-
if (moduleCache.has(
|
|
256
|
-
return moduleCache.get(
|
|
270
|
+
if (moduleCache.has(cacheKey)) {
|
|
271
|
+
return moduleCache.get(cacheKey)!
|
|
257
272
|
}
|
|
258
273
|
|
|
259
274
|
// Generate and cache
|
|
260
|
-
const code = await generateVirtualModule(id, config)
|
|
275
|
+
const code = await generateVirtualModule(id, config, ssr)
|
|
261
276
|
if (code !== null) {
|
|
262
|
-
moduleCache.set(
|
|
277
|
+
moduleCache.set(cacheKey, code)
|
|
263
278
|
return code
|
|
264
279
|
}
|
|
265
280
|
|
|
@@ -364,11 +379,17 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
|
|
|
364
379
|
composableExports = await scanComposableExports(config.composablesDir)
|
|
365
380
|
await writeAutoImportDts(config.root, config.composablesDir, composableExports)
|
|
366
381
|
writeTsconfigPaths(config.root, config.srcDir)
|
|
367
|
-
// Warm the virtual module cache
|
|
382
|
+
// Warm the virtual module cache.
|
|
383
|
+
// virtual:cer-app-config is cached under separate :client/:ssr keys (SSR
|
|
384
|
+
// build omits _runtimePrivateDefaults from the client variant), so use the
|
|
385
|
+
// correct key suffix here so load() hits the cache instead of regenerating.
|
|
368
386
|
for (const resolvedId of Object.values(RESOLVED_IDS)) {
|
|
369
387
|
const code = await generateVirtualModule(resolvedId, config)
|
|
370
388
|
if (code !== null) {
|
|
371
|
-
|
|
389
|
+
const cacheKey = resolvedId === RESOLVED_IDS.appConfig
|
|
390
|
+
? `${resolvedId}:client`
|
|
391
|
+
: resolvedId
|
|
392
|
+
moduleCache.set(cacheKey, code)
|
|
372
393
|
}
|
|
373
394
|
}
|
|
374
395
|
},
|
|
@@ -11,9 +11,9 @@ const RUNTIME_IMPORTS = `import { component, html, css, ref, computed, watch, wa
|
|
|
11
11
|
|
|
12
12
|
const DIRECTIVE_IMPORTS = `import { when, each, match, anchorBlock } from '@jasonshimmy/custom-elements-runtime/directives';`
|
|
13
13
|
|
|
14
|
-
const FRAMEWORK_IMPORTS = `import { useHead, usePageData, useInject, useRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables';`
|
|
14
|
+
const FRAMEWORK_IMPORTS = `import { useHead, usePageData, useInject, useRuntimeConfig, defineMiddleware } from '@jasonshimmy/vite-plugin-cer-app/composables';`
|
|
15
15
|
|
|
16
|
-
const FRAMEWORK_IDENTIFIERS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig']
|
|
16
|
+
const FRAMEWORK_IDENTIFIERS = ['useHead', 'usePageData', 'useInject', 'useRuntimeConfig', 'defineMiddleware']
|
|
17
17
|
|
|
18
18
|
const RUNTIME_IDENTIFIERS = [
|
|
19
19
|
'component',
|
|
@@ -63,12 +63,13 @@ export function autoImportTransform(
|
|
|
63
63
|
const normalizedId = normalize(id)
|
|
64
64
|
const srcDir = normalize(opts.srcDir)
|
|
65
65
|
|
|
66
|
-
// Transform files inside app/pages/, app/layouts/, app/components/,
|
|
66
|
+
// Transform files inside app/pages/, app/layouts/, app/components/, app/middleware/
|
|
67
67
|
// AND special convention files directly in app/ (loading.ts, error.ts, etc.)
|
|
68
68
|
const isSubDir =
|
|
69
69
|
normalizedId.startsWith(srcDir + '/pages/') ||
|
|
70
70
|
normalizedId.startsWith(srcDir + '/layouts/') ||
|
|
71
|
-
normalizedId.startsWith(srcDir + '/components/')
|
|
71
|
+
normalizedId.startsWith(srcDir + '/components/') ||
|
|
72
|
+
normalizedId.startsWith(srcDir + '/middleware/')
|
|
72
73
|
// Files directly in srcDir root (e.g. app/loading.ts, app/error.ts)
|
|
73
74
|
const isRootConventionFile =
|
|
74
75
|
normalizedId.startsWith(srcDir + '/') &&
|
|
@@ -256,7 +256,13 @@ export async function generateRoutesCode(pagesDir: string): Promise<string> {
|
|
|
256
256
|
` for (const name of ${mwLiteral}) {\n` +
|
|
257
257
|
` const handler = middleware[name]\n` +
|
|
258
258
|
` if (typeof handler !== 'function') continue\n` +
|
|
259
|
-
`
|
|
259
|
+
` let result\n` +
|
|
260
|
+
` try {\n` +
|
|
261
|
+
` result = await handler(to, from)\n` +
|
|
262
|
+
` } catch (err) {\n` +
|
|
263
|
+
` console.error('[cer-app] Middleware "' + name + '" threw an error:', err)\n` +
|
|
264
|
+
` return false\n` +
|
|
265
|
+
` }\n` +
|
|
260
266
|
` if (typeof result === 'string') return result\n` +
|
|
261
267
|
` if (result === false) return false\n` +
|
|
262
268
|
` }\n` +
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MiddlewareFn } from '../../types/middleware.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identity helper that gives TypeScript the correct `MiddlewareFn` type
|
|
5
|
+
* without any runtime cost.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // app/middleware/auth.ts
|
|
9
|
+
* export default defineMiddleware(async (to, from) => {
|
|
10
|
+
* const isLoggedIn = checkSession()
|
|
11
|
+
* if (!isLoggedIn) return '/login' // redirect
|
|
12
|
+
* return true // allow
|
|
13
|
+
* })
|
|
14
|
+
*/
|
|
15
|
+
export function defineMiddleware(fn: MiddlewareFn): MiddlewareFn {
|
|
16
|
+
return fn
|
|
17
|
+
}
|
|
@@ -2,4 +2,6 @@ export { useHead, beginHeadCollection, endHeadCollection, serializeHeadTags } fr
|
|
|
2
2
|
export type { HeadInput } from './use-head.js'
|
|
3
3
|
export { usePageData } from './use-page-data.js'
|
|
4
4
|
export { useInject } from './use-inject.js'
|
|
5
|
-
export { useRuntimeConfig, initRuntimeConfig } from './use-runtime-config.js'
|
|
5
|
+
export { useRuntimeConfig, initRuntimeConfig, resolvePrivateConfig } from './use-runtime-config.js'
|
|
6
|
+
export type { RuntimeConfigResult, RuntimeConfigPublic, RuntimeConfigPrivate } from './use-runtime-config.js'
|
|
7
|
+
export { defineMiddleware } from './define-middleware.js'
|
|
@@ -1,29 +1,42 @@
|
|
|
1
|
+
export interface RuntimeConfigPublic {
|
|
2
|
+
[key: string]: unknown
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface RuntimeConfigPrivate {
|
|
6
|
+
[key: string]: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RuntimeConfigResult {
|
|
10
|
+
public: RuntimeConfigPublic
|
|
11
|
+
private?: RuntimeConfigPrivate
|
|
12
|
+
}
|
|
13
|
+
|
|
1
14
|
/**
|
|
2
|
-
* Returns the
|
|
3
|
-
* `runtimeConfig.public`. Available on both server and client.
|
|
15
|
+
* Returns the runtime configuration set in `cer.config.ts`.
|
|
4
16
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
17
|
+
* - `public` — available on both server and client.
|
|
18
|
+
* - `private` — available on the server only (resolved from `process.env` at
|
|
19
|
+
* startup). Never present on the client.
|
|
8
20
|
*
|
|
9
21
|
* @example
|
|
10
22
|
* // cer.config.ts
|
|
11
23
|
* export default defineConfig({
|
|
12
24
|
* runtimeConfig: {
|
|
13
25
|
* public: { apiBase: process.env.VITE_API_BASE ?? '/api' },
|
|
26
|
+
* private: { dbUrl: '', secretKey: '' },
|
|
14
27
|
* },
|
|
15
28
|
* })
|
|
16
29
|
*
|
|
17
|
-
* // app/pages/index.ts
|
|
18
|
-
* const
|
|
19
|
-
*
|
|
30
|
+
* // app/pages/index.ts (loader — server-only)
|
|
31
|
+
* const { private: priv } = useRuntimeConfig()
|
|
32
|
+
* const rows = await db.query(priv.dbUrl)
|
|
20
33
|
*/
|
|
21
|
-
export function useRuntimeConfig():
|
|
34
|
+
export function useRuntimeConfig(): RuntimeConfigResult {
|
|
22
35
|
// Dynamic import resolved at runtime — avoids a static circular dependency
|
|
23
36
|
// between the composable and the virtual module.
|
|
24
37
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
38
|
const mod = (globalThis as any).__cerRuntimeConfig
|
|
26
|
-
if (mod) return mod as
|
|
39
|
+
if (mod) return mod as RuntimeConfigResult
|
|
27
40
|
|
|
28
41
|
// Fallback: empty config (e.g. in test environments without the virtual module).
|
|
29
42
|
return { public: {} }
|
|
@@ -34,7 +47,40 @@ export function useRuntimeConfig(): { public: Record<string, unknown> } {
|
|
|
34
47
|
* globalThis so useRuntimeConfig() can access it synchronously in any context
|
|
35
48
|
* (component render, composable, server handler).
|
|
36
49
|
*/
|
|
37
|
-
export function initRuntimeConfig(config:
|
|
50
|
+
export function initRuntimeConfig(config: RuntimeConfigResult): void {
|
|
38
51
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
52
|
;(globalThis as any).__cerRuntimeConfig = config
|
|
40
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Converts a camelCase or mixed key to UPPER_SNAKE_CASE for env var lookup.
|
|
57
|
+
* Examples: `dbUrl` → `DB_URL`, `secretKey` → `SECRET_KEY`, `API_KEY` → `API_KEY`.
|
|
58
|
+
*/
|
|
59
|
+
function toUpperSnakeCase(key: string): string {
|
|
60
|
+
return key
|
|
61
|
+
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
|
62
|
+
.toUpperCase()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolves a private config object by looking up each key in the supplied
|
|
67
|
+
* environment variable map, with the following precedence:
|
|
68
|
+
*
|
|
69
|
+
* 1. `env[key]` — exact case (e.g. `process.env.dbUrl`)
|
|
70
|
+
* 2. `env[UPPER_SNAKE_CASE(key)]` — conventional env var form (e.g. `process.env.DB_URL`)
|
|
71
|
+
* 3. `defaultValue` — the declared default from `cer.config.ts`
|
|
72
|
+
*
|
|
73
|
+
* Accepts an optional `env` parameter so the function is unit-testable
|
|
74
|
+
* without mutating `process.env`.
|
|
75
|
+
*/
|
|
76
|
+
export function resolvePrivateConfig(
|
|
77
|
+
defaults: Record<string, string>,
|
|
78
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
79
|
+
): Record<string, string> {
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(defaults).map(([key, defaultValue]) => [
|
|
82
|
+
key,
|
|
83
|
+
env[key] ?? env[toUpperSnakeCase(key)] ?? defaultValue,
|
|
84
|
+
]),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -21,17 +21,21 @@ import routes from 'virtual:cer-routes'
|
|
|
21
21
|
import layouts from 'virtual:cer-layouts'
|
|
22
22
|
import plugins from 'virtual:cer-plugins'
|
|
23
23
|
import apiRoutes from 'virtual:cer-server-api'
|
|
24
|
-
import { runtimeConfig } from 'virtual:cer-app-config'
|
|
24
|
+
import { runtimeConfig, _runtimePrivateDefaults } from 'virtual:cer-app-config'
|
|
25
25
|
import { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'
|
|
26
26
|
import { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'
|
|
27
27
|
import entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'
|
|
28
28
|
import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
|
|
29
|
-
import { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
29
|
+
import { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig, resolvePrivateConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
|
|
30
30
|
import { errorTag } from 'virtual:cer-error'
|
|
31
31
|
import { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'
|
|
32
32
|
|
|
33
33
|
registerBuiltinComponents()
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
// Resolve private config from environment variables at server startup.
|
|
36
|
+
// Each key declared in runtimeConfig.private is looked up in process.env,
|
|
37
|
+
// first as-is, then as ALL_CAPS. The declared default is used as fallback.
|
|
38
|
+
initRuntimeConfig({ ...runtimeConfig, private: resolvePrivateConfig(_runtimePrivateDefaults ?? {}) })
|
|
35
39
|
|
|
36
40
|
// Pre-load the full HTML entity map so named entities like — decode
|
|
37
41
|
// correctly during SSR. Without this the bundled runtime falls back to a
|
package/src/types/config.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface RuntimePublicConfig {
|
|
|
22
22
|
[key: string]: unknown
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface RuntimePrivateConfig {
|
|
26
|
+
[key: string]: string
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
export interface RuntimeConfig {
|
|
26
30
|
/**
|
|
27
31
|
* Public runtime config — available on both server and client via
|
|
@@ -36,6 +40,17 @@ export interface RuntimeConfig {
|
|
|
36
40
|
* }
|
|
37
41
|
*/
|
|
38
42
|
public?: RuntimePublicConfig
|
|
43
|
+
/**
|
|
44
|
+
* Server-only secrets — never serialized into the client bundle.
|
|
45
|
+
* Declare keys with empty-string defaults here; at server startup each key
|
|
46
|
+
* is resolved from `process.env[KEY]` (case-insensitive, ALL_CAPS preferred).
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* runtimeConfig: {
|
|
50
|
+
* private: { dbUrl: '', secretKey: '' },
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
private?: RuntimePrivateConfig
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
export interface CerAppConfig {
|
package/src/types/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig, RuntimeConfig, RuntimePublicConfig } from './config.js'
|
|
1
|
+
export type { CerAppConfig, SsgConfig, JitCssConfig, AutoImportsConfig, RuntimeConfig, RuntimePublicConfig, RuntimePrivateConfig } from './config.js'
|
|
2
2
|
export { defineConfig } from './config.js'
|
|
3
3
|
export type { HydrateStrategy, SsgPathsContext, PageSsgConfig, PageMeta, PageLoaderContext, PageLoader } from './page.js'
|
|
4
4
|
export type { ApiRequest, ApiResponse, ApiHandler, ApiContext } from './api.js'
|
|
5
5
|
export type { AppContext, AppPlugin } from './plugin.js'
|
|
6
|
-
export type {
|
|
6
|
+
export type { MiddlewareFn, GuardResult, ServerMiddleware } from './middleware.js'
|
package/src/types/middleware.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { RouteState } from '@jasonshimmy/custom-elements-runtime/router'
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Return value from a route middleware function:
|
|
6
|
+
* - `true` — allow navigation
|
|
7
|
+
* - `false` — block navigation
|
|
8
|
+
* - `string` — redirect to that path
|
|
9
|
+
*/
|
|
10
|
+
export type GuardResult = boolean | string | Promise<boolean | string>
|
|
5
11
|
|
|
6
|
-
export type
|
|
7
|
-
to: RouteState,
|
|
8
|
-
from: RouteState | null,
|
|
9
|
-
next: NextFunction,
|
|
10
|
-
) => void | Promise<void>
|
|
12
|
+
export type MiddlewareFn = (to: RouteState, from: RouteState | null) => GuardResult
|
|
11
13
|
|
|
12
14
|
export type ServerMiddleware = (
|
|
13
15
|
req: IncomingMessage,
|