@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +4 -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 +9 -2
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/index.d.ts +1 -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 +3 -1
  32. package/dist/runtime/composables/index.d.ts.map +1 -1
  33. package/dist/runtime/composables/index.js +2 -1
  34. package/dist/runtime/composables/index.js.map +1 -1
  35. package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
  36. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  37. package/dist/runtime/composables/use-runtime-config.js +34 -8
  38. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  39. package/dist/runtime/entry-server-template.d.ts +1 -1
  40. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  41. package/dist/runtime/entry-server-template.js +7 -3
  42. package/dist/runtime/entry-server-template.js.map +1 -1
  43. package/dist/types/config.d.ts +14 -0
  44. package/dist/types/config.d.ts.map +1 -1
  45. package/dist/types/config.js.map +1 -1
  46. package/dist/types/index.d.ts +2 -2
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/middleware.d.ts +8 -2
  49. package/dist/types/middleware.d.ts.map +1 -1
  50. package/docs/cli.md +1 -0
  51. package/docs/composables.md +32 -7
  52. package/docs/configuration.md +53 -3
  53. package/docs/middleware.md +53 -25
  54. package/e2e/cypress/e2e/middleware.cy.ts +45 -0
  55. package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
  56. package/package.json +1 -1
  57. package/src/__tests__/cli/preview-isr.test.ts +30 -0
  58. package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
  59. package/src/__tests__/plugin/resolve-config.test.ts +18 -0
  60. package/src/__tests__/plugin/transforms/auto-import.test.ts +16 -0
  61. package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
  62. package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
  63. package/src/__tests__/runtime/define-middleware.test.ts +43 -0
  64. package/src/__tests__/runtime/use-runtime-config.test.ts +62 -1
  65. package/src/cli/commands/preview-isr.ts +14 -0
  66. package/src/cli/commands/preview.ts +12 -1
  67. package/src/index.ts +1 -1
  68. package/src/plugin/dev-server.ts +1 -1
  69. package/src/plugin/dts-generator.ts +4 -2
  70. package/src/plugin/index.ts +32 -11
  71. package/src/plugin/transforms/auto-import.ts +5 -4
  72. package/src/plugin/virtual/routes.ts +7 -1
  73. package/src/runtime/composables/define-middleware.ts +17 -0
  74. package/src/runtime/composables/index.ts +3 -1
  75. package/src/runtime/composables/use-runtime-config.ts +57 -11
  76. package/src/runtime/entry-server-template.ts +7 -3
  77. package/src/types/config.ts +15 -0
  78. package/src/types/index.ts +2 -2
  79. 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 (existsSync(assetPath) && !statSync(assetPath).isDirectory()) {
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 { NextFunction, RouteMiddleware, ServerMiddleware } from './types/middleware.js'
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'
@@ -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('')
@@ -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
- return (
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(id)) {
256
- return moduleCache.get(id)!
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(id, code)
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
- moduleCache.set(resolvedId, code)
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
- ` const result = await new Promise((resolve) => handler(to, from, resolve))\n` +
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 public runtime configuration set in `cer.config.ts` under
3
- * `runtimeConfig.public`. Available on both server and client.
15
+ * Returns the runtime configuration set in `cer.config.ts`.
4
16
  *
5
- * Values are baked in at build time from `virtual:cer-app-config`, so only
6
- * static/env-var values should be placed here. For truly dynamic config,
7
- * use a loader or API route.
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 config = useRuntimeConfig()
19
- * fetch(config.public.apiBase + '/posts')
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(): { public: Record<string, unknown> } {
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 { public: Record<string, unknown> }
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: { public: Record<string, unknown> }): void {
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
- initRuntimeConfig(runtimeConfig)
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 &mdash; decode
37
41
  // correctly during SSR. Without this the bundled runtime falls back to a
@@ -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 {
@@ -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 { NextFunction, RouteMiddleware, ServerMiddleware } from './middleware.js'
6
+ export type { MiddlewareFn, GuardResult, ServerMiddleware } from './middleware.js'
@@ -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
- export type NextFunction = (redirectTo?: string) => void
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 RouteMiddleware = (
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,