@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ROADMAP.md +278 -0
  3. package/commits.txt +1 -1
  4. package/dist/cli/commands/preview-isr.d.ts +6 -0
  5. package/dist/cli/commands/preview-isr.d.ts.map +1 -1
  6. package/dist/cli/commands/preview-isr.js +12 -0
  7. package/dist/cli/commands/preview-isr.js.map +1 -1
  8. package/dist/cli/commands/preview.d.ts.map +1 -1
  9. package/dist/cli/commands/preview.js +66 -6
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/plugin/dev-server.d.ts +1 -0
  14. package/dist/plugin/dev-server.d.ts.map +1 -1
  15. package/dist/plugin/dts-generator.d.ts.map +1 -1
  16. package/dist/plugin/dts-generator.js +4 -2
  17. package/dist/plugin/dts-generator.js.map +1 -1
  18. package/dist/plugin/index.d.ts.map +1 -1
  19. package/dist/plugin/index.js +30 -12
  20. package/dist/plugin/index.js.map +1 -1
  21. package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
  22. package/dist/plugin/transforms/auto-import.js +5 -4
  23. package/dist/plugin/transforms/auto-import.js.map +1 -1
  24. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  25. package/dist/plugin/virtual/routes.js +7 -1
  26. package/dist/plugin/virtual/routes.js.map +1 -1
  27. package/dist/runtime/composables/define-middleware.d.ts +15 -0
  28. package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
  29. package/dist/runtime/composables/define-middleware.js +16 -0
  30. package/dist/runtime/composables/define-middleware.js.map +1 -0
  31. package/dist/runtime/composables/index.d.ts +7 -1
  32. package/dist/runtime/composables/index.d.ts.map +1 -1
  33. package/dist/runtime/composables/index.js +4 -1
  34. package/dist/runtime/composables/index.js.map +1 -1
  35. package/dist/runtime/composables/use-cookie.d.ts +38 -0
  36. package/dist/runtime/composables/use-cookie.d.ts.map +1 -0
  37. package/dist/runtime/composables/use-cookie.js +104 -0
  38. package/dist/runtime/composables/use-cookie.js.map +1 -0
  39. package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
  40. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  41. package/dist/runtime/composables/use-runtime-config.js +42 -8
  42. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  43. package/dist/runtime/composables/use-seo-meta.d.ts +42 -0
  44. package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -0
  45. package/dist/runtime/composables/use-seo-meta.js +58 -0
  46. package/dist/runtime/composables/use-seo-meta.js.map +1 -0
  47. package/dist/runtime/entry-server-template.d.ts +1 -1
  48. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  49. package/dist/runtime/entry-server-template.js +15 -3
  50. package/dist/runtime/entry-server-template.js.map +1 -1
  51. package/dist/types/config.d.ts +14 -0
  52. package/dist/types/config.d.ts.map +1 -1
  53. package/dist/types/config.js.map +1 -1
  54. package/dist/types/index.d.ts +2 -2
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/dist/types/middleware.d.ts +8 -2
  57. package/dist/types/middleware.d.ts.map +1 -1
  58. package/docs/cli.md +5 -0
  59. package/docs/composables.md +165 -7
  60. package/docs/configuration.md +53 -3
  61. package/docs/middleware.md +53 -25
  62. package/e2e/cypress/e2e/cookie.cy.ts +68 -0
  63. package/e2e/cypress/e2e/middleware.cy.ts +45 -0
  64. package/e2e/cypress/e2e/preview-hardening.cy.ts +79 -0
  65. package/e2e/cypress/e2e/seo-meta.cy.ts +108 -0
  66. package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
  67. package/e2e/kitchen-sink/app/pages/cookie-test.ts +22 -0
  68. package/e2e/kitchen-sink/app/pages/seo-test.ts +23 -0
  69. package/package.json +1 -1
  70. package/src/__tests__/cli/preview-hardening.test.ts +175 -0
  71. package/src/__tests__/cli/preview-isr.test.ts +30 -0
  72. package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
  73. package/src/__tests__/plugin/entry-server-template.test.ts +21 -0
  74. package/src/__tests__/plugin/resolve-config.test.ts +18 -0
  75. package/src/__tests__/plugin/transforms/auto-import.test.ts +39 -0
  76. package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
  77. package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
  78. package/src/__tests__/runtime/define-middleware.test.ts +43 -0
  79. package/src/__tests__/runtime/use-cookie.test.ts +218 -0
  80. package/src/__tests__/runtime/use-runtime-config.test.ts +86 -2
  81. package/src/__tests__/runtime/use-seo-meta.test.ts +109 -0
  82. package/src/cli/commands/preview-isr.ts +14 -0
  83. package/src/cli/commands/preview.ts +78 -6
  84. package/src/index.ts +3 -1
  85. package/src/plugin/dev-server.ts +1 -1
  86. package/src/plugin/dts-generator.ts +4 -2
  87. package/src/plugin/index.ts +32 -11
  88. package/src/plugin/transforms/auto-import.ts +5 -4
  89. package/src/plugin/virtual/routes.ts +7 -1
  90. package/src/runtime/composables/define-middleware.ts +17 -0
  91. package/src/runtime/composables/index.ts +7 -1
  92. package/src/runtime/composables/use-cookie.ts +128 -0
  93. package/src/runtime/composables/use-runtime-config.ts +67 -11
  94. package/src/runtime/composables/use-seo-meta.ts +75 -0
  95. package/src/runtime/entry-server-template.ts +15 -3
  96. package/src/types/config.ts +15 -0
  97. package/src/types/index.ts +2 -2
  98. package/src/types/middleware.ts +8 -6
@@ -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, useSeoMeta, useCookie } 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', 'useSeoMeta', 'useCookie']
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,10 @@ 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'
8
+ export { useSeoMeta } from './use-seo-meta.js'
9
+ export type { SeoMetaInput } from './use-seo-meta.js'
10
+ export { useCookie } from './use-cookie.js'
11
+ export type { CookieOptions, CookieRef } from './use-cookie.js'
@@ -0,0 +1,128 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+
3
+ export interface CookieOptions {
4
+ /** Cookie path. Defaults to '/' when setting/removing. */
5
+ path?: string
6
+ domain?: string
7
+ /** Max age in seconds. */
8
+ maxAge?: number
9
+ expires?: Date
10
+ httpOnly?: boolean
11
+ secure?: boolean
12
+ sameSite?: 'Strict' | 'Lax' | 'None'
13
+ }
14
+
15
+ export interface CookieRef {
16
+ /** The current cookie value, or undefined if not set. */
17
+ readonly value: string | undefined
18
+ /** Write the cookie value. */
19
+ set(value: string, options?: CookieOptions): void
20
+ /** Remove the cookie by setting Max-Age=0. */
21
+ remove(options?: CookieOptions): void
22
+ }
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function parseCookies(cookieHeader: string): Record<string, string> {
27
+ const result: Record<string, string> = {}
28
+ for (const part of cookieHeader.split(';')) {
29
+ const eqIdx = part.indexOf('=')
30
+ if (eqIdx < 0) continue
31
+ const key = part.slice(0, eqIdx).trim()
32
+ const rawValue = part.slice(eqIdx + 1).trim()
33
+ try {
34
+ result[key] = decodeURIComponent(rawValue)
35
+ } catch {
36
+ result[key] = rawValue
37
+ }
38
+ }
39
+ return result
40
+ }
41
+
42
+ function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
43
+ let str = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
44
+ const path = options.path ?? '/'
45
+ if (path) str += `; Path=${path}`
46
+ if (options.domain) str += `; Domain=${options.domain}`
47
+ if (options.maxAge !== undefined) str += `; Max-Age=${options.maxAge}`
48
+ if (options.expires) str += `; Expires=${options.expires.toUTCString()}`
49
+ if (options.httpOnly) str += '; HttpOnly'
50
+ if (options.secure) str += '; Secure'
51
+ if (options.sameSite) str += `; SameSite=${options.sameSite}`
52
+ return str
53
+ }
54
+
55
+ function appendSetCookie(res: ServerResponse, cookie: string): void {
56
+ const existing = res.getHeader('Set-Cookie')
57
+ const list: string[] = existing == null
58
+ ? []
59
+ : Array.isArray(existing) ? existing : [String(existing)]
60
+ list.push(cookie)
61
+ res.setHeader('Set-Cookie', list)
62
+ }
63
+
64
+ // ─── Composable ───────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Isomorphic cookie composable.
68
+ *
69
+ * - **Server (SSR/SSG)**: reads from `req.headers.cookie` via AsyncLocalStorage;
70
+ * writes/removes via `res.setHeader('Set-Cookie', ...)`.
71
+ * - **Client**: reads and writes `document.cookie`.
72
+ *
73
+ * It is auto-imported, so you don't need to import it manually.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const token = useCookie('auth-token')
78
+ * console.log(token.value) // read
79
+ * token.set('abc123') // write
80
+ * token.remove() // delete
81
+ * ```
82
+ */
83
+ export function useCookie(name: string, defaultOptions: CookieOptions = {}): CookieRef {
84
+ const g = globalThis as Record<string, unknown>
85
+
86
+ // ── SSR path ──────────────────────────────────────────────────────────────
87
+ const store = g['__CER_REQ_STORE__'] as
88
+ | { getStore(): { req: IncomingMessage; res: ServerResponse } | null }
89
+ | undefined
90
+
91
+ if (store) {
92
+ const ctx = store.getStore()
93
+ if (ctx) {
94
+ const { req, res } = ctx
95
+ const parsed = parseCookies(req.headers['cookie'] ?? '')
96
+ return {
97
+ value: parsed[name],
98
+ set(value: string, options?: CookieOptions) {
99
+ appendSetCookie(res, serializeCookie(name, value, { ...defaultOptions, ...options }))
100
+ },
101
+ remove(options?: CookieOptions) {
102
+ appendSetCookie(res, serializeCookie(name, '', { ...defaultOptions, ...options, maxAge: 0 }))
103
+ },
104
+ }
105
+ }
106
+ }
107
+
108
+ // ── Client path ───────────────────────────────────────────────────────────
109
+ if (typeof document !== 'undefined') {
110
+ const parsed = parseCookies(document.cookie)
111
+ return {
112
+ value: parsed[name],
113
+ set(value: string, options?: CookieOptions) {
114
+ document.cookie = serializeCookie(name, value, { ...defaultOptions, ...options })
115
+ },
116
+ remove(options?: CookieOptions) {
117
+ document.cookie = serializeCookie(name, '', { ...defaultOptions, ...options, maxAge: 0 })
118
+ },
119
+ }
120
+ }
121
+
122
+ // ── Build-time / unknown context ──────────────────────────────────────────
123
+ return {
124
+ value: undefined,
125
+ set() {},
126
+ remove() {},
127
+ }
128
+ }
@@ -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,50 @@ 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
+ const envKey = toUpperSnakeCase(key)
83
+ const resolved = env[key] ?? env[envKey] ?? defaultValue
84
+ // Warn when no env var was found and the declared default is an empty string.
85
+ // An empty-string default is the conventional way to declare a required secret
86
+ // (the key exists for typing purposes but has no safe default value).
87
+ if (resolved === '' && env[key] === undefined && env[envKey] === undefined) {
88
+ console.warn(
89
+ `[cer-app] runtimeConfig.private: "${key}" is an empty string — ` +
90
+ `set ${envKey} in the environment to provide a value.`,
91
+ )
92
+ }
93
+ return [key, resolved]
94
+ }),
95
+ )
96
+ }
@@ -0,0 +1,75 @@
1
+ import { useHead } from './use-head.js'
2
+
3
+ export interface SeoMetaInput {
4
+ /** Sets the document title. */
5
+ title?: string
6
+ /** Sets the meta description. */
7
+ description?: string
8
+ // Open Graph
9
+ ogTitle?: string
10
+ ogDescription?: string
11
+ ogImage?: string
12
+ ogUrl?: string
13
+ /** e.g. `'website'`, `'article'`. No tag is emitted when omitted. */
14
+ ogType?: string
15
+ ogSiteName?: string
16
+ // Twitter / X
17
+ /** e.g. `'summary'`, `'summary_large_image'`. No tag is emitted when omitted. */
18
+ twitterCard?: string
19
+ twitterTitle?: string
20
+ twitterDescription?: string
21
+ twitterImage?: string
22
+ /** Twitter/X site handle, e.g. '@mysite'. */
23
+ twitterSite?: string
24
+ /** Canonical URL injected as <link rel="canonical">. */
25
+ canonical?: string
26
+ }
27
+
28
+ /**
29
+ * Thin wrapper over `useHead()` for common SEO tags.
30
+ *
31
+ * Sets the page title, meta description, Open Graph tags, Twitter Card tags,
32
+ * and a canonical link element. Only tags with a non-undefined value are emitted.
33
+ *
34
+ * It is auto-imported, so you don't need to import it manually.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * useSeoMeta({
39
+ * title: 'My page',
40
+ * description: 'A great page.',
41
+ * ogImage: 'https://example.com/og.png',
42
+ * canonical: 'https://example.com/my-page',
43
+ * })
44
+ * ```
45
+ */
46
+ export function useSeoMeta(input: SeoMetaInput): void {
47
+ const meta: Array<Record<string, string>> = []
48
+ const link: Array<Record<string, string>> = []
49
+
50
+ if (input.description !== undefined) meta.push({ name: 'description', content: input.description })
51
+
52
+ // Open Graph
53
+ if (input.ogTitle !== undefined) meta.push({ property: 'og:title', content: input.ogTitle })
54
+ if (input.ogDescription !== undefined) meta.push({ property: 'og:description', content: input.ogDescription })
55
+ if (input.ogImage !== undefined) meta.push({ property: 'og:image', content: input.ogImage })
56
+ if (input.ogUrl !== undefined) meta.push({ property: 'og:url', content: input.ogUrl })
57
+ if (input.ogType !== undefined) meta.push({ property: 'og:type', content: input.ogType })
58
+ if (input.ogSiteName !== undefined) meta.push({ property: 'og:site_name', content: input.ogSiteName })
59
+
60
+ // Twitter / X
61
+ if (input.twitterCard !== undefined) meta.push({ name: 'twitter:card', content: input.twitterCard })
62
+ if (input.twitterTitle !== undefined) meta.push({ name: 'twitter:title', content: input.twitterTitle })
63
+ if (input.twitterDescription !== undefined) meta.push({ name: 'twitter:description', content: input.twitterDescription })
64
+ if (input.twitterImage !== undefined) meta.push({ name: 'twitter:image', content: input.twitterImage })
65
+ if (input.twitterSite !== undefined) meta.push({ name: 'twitter:site', content: input.twitterSite })
66
+
67
+ // Canonical link
68
+ if (input.canonical !== undefined) link.push({ rel: 'canonical', href: input.canonical })
69
+
70
+ useHead({
71
+ title: input.title,
72
+ meta: meta.length > 0 ? meta : undefined,
73
+ link: link.length > 0 ? link : undefined,
74
+ })
75
+ }
@@ -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
@@ -64,6 +68,12 @@ const _cerDataStore = new AsyncLocalStorage()
64
68
  // Expose the store so the usePageData() composable can read it server-side.
65
69
  ;(globalThis).__CER_DATA_STORE__ = _cerDataStore
66
70
 
71
+ // Async-local storage for request-scoped req/res access.
72
+ // Allows isomorphic composables (e.g. useCookie) to read/write HTTP headers
73
+ // without prop-drilling the request context through the component tree.
74
+ const _cerReqStore = new AsyncLocalStorage()
75
+ ;(globalThis).__CER_REQ_STORE__ = _cerReqStore
76
+
67
77
  // Load the Vite-built client index.html (dist/client/index.html) so every SSR
68
78
  // response includes the client-side scripts needed for hydration and routing.
69
79
  // The server bundle lives at dist/server/server.js, so ../client resolves correctly.
@@ -188,6 +198,7 @@ const _prepareRequest = async (req) => {
188
198
  }
189
199
 
190
200
  export const handler = async (req, res) => {
201
+ await _cerReqStore.run({ req, res }, async () => {
191
202
  await _cerDataStore.run(null, async () => {
192
203
  const { vnode, router, head, status } = await _prepareRequest(req)
193
204
  if (status != null) res.statusCode = status
@@ -247,6 +258,7 @@ export const handler = async (req, res) => {
247
258
  // Inject DSD polyfill immediately before </body>, then close the document.
248
259
  res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)
249
260
  })
261
+ })
250
262
  }
251
263
 
252
264
  // ISR-wrapped handler for production integrations (Express, Hono, Fastify).
@@ -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,