@jasonshimmy/vite-plugin-cer-app 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/ROADMAP.md +278 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/preview-isr.d.ts +6 -0
- package/dist/cli/commands/preview-isr.d.ts.map +1 -1
- package/dist/cli/commands/preview-isr.js +12 -0
- package/dist/cli/commands/preview-isr.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +66 -6
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin/dev-server.d.ts +1 -0
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +4 -2
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +30 -12
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/transforms/auto-import.d.ts.map +1 -1
- package/dist/plugin/transforms/auto-import.js +5 -4
- package/dist/plugin/transforms/auto-import.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +7 -1
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/composables/define-middleware.d.ts +15 -0
- package/dist/runtime/composables/define-middleware.d.ts.map +1 -0
- package/dist/runtime/composables/define-middleware.js +16 -0
- package/dist/runtime/composables/define-middleware.js.map +1 -0
- package/dist/runtime/composables/index.d.ts +7 -1
- package/dist/runtime/composables/index.d.ts.map +1 -1
- package/dist/runtime/composables/index.js +4 -1
- package/dist/runtime/composables/index.js.map +1 -1
- package/dist/runtime/composables/use-cookie.d.ts +38 -0
- package/dist/runtime/composables/use-cookie.d.ts.map +1 -0
- package/dist/runtime/composables/use-cookie.js +104 -0
- package/dist/runtime/composables/use-cookie.js.map +1 -0
- package/dist/runtime/composables/use-runtime-config.d.ts +32 -14
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
- package/dist/runtime/composables/use-runtime-config.js +42 -8
- package/dist/runtime/composables/use-runtime-config.js.map +1 -1
- package/dist/runtime/composables/use-seo-meta.d.ts +42 -0
- package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -0
- package/dist/runtime/composables/use-seo-meta.js +58 -0
- package/dist/runtime/composables/use-seo-meta.js.map +1 -0
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +15 -3
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/types/config.d.ts +14 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/middleware.d.ts +8 -2
- package/dist/types/middleware.d.ts.map +1 -1
- package/docs/cli.md +5 -0
- package/docs/composables.md +165 -7
- package/docs/configuration.md +53 -3
- package/docs/middleware.md +53 -25
- package/e2e/cypress/e2e/cookie.cy.ts +68 -0
- package/e2e/cypress/e2e/middleware.cy.ts +45 -0
- package/e2e/cypress/e2e/preview-hardening.cy.ts +79 -0
- package/e2e/cypress/e2e/seo-meta.cy.ts +108 -0
- package/e2e/kitchen-sink/app/middleware/auth.ts +3 -7
- package/e2e/kitchen-sink/app/pages/cookie-test.ts +22 -0
- package/e2e/kitchen-sink/app/pages/seo-test.ts +23 -0
- package/package.json +1 -1
- package/src/__tests__/cli/preview-hardening.test.ts +175 -0
- package/src/__tests__/cli/preview-isr.test.ts +30 -0
- package/src/__tests__/plugin/cer-app-plugin.test.ts +50 -0
- package/src/__tests__/plugin/entry-server-template.test.ts +21 -0
- package/src/__tests__/plugin/resolve-config.test.ts +18 -0
- package/src/__tests__/plugin/transforms/auto-import.test.ts +39 -0
- package/src/__tests__/plugin/virtual/middleware.test.ts +15 -0
- package/src/__tests__/plugin/virtual/routes.test.ts +32 -0
- package/src/__tests__/runtime/define-middleware.test.ts +43 -0
- package/src/__tests__/runtime/use-cookie.test.ts +218 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +86 -2
- package/src/__tests__/runtime/use-seo-meta.test.ts +109 -0
- package/src/cli/commands/preview-isr.ts +14 -0
- package/src/cli/commands/preview.ts +78 -6
- package/src/index.ts +3 -1
- package/src/plugin/dev-server.ts +1 -1
- package/src/plugin/dts-generator.ts +4 -2
- package/src/plugin/index.ts +32 -11
- package/src/plugin/transforms/auto-import.ts +5 -4
- package/src/plugin/virtual/routes.ts +7 -1
- package/src/runtime/composables/define-middleware.ts +17 -0
- package/src/runtime/composables/index.ts +7 -1
- package/src/runtime/composables/use-cookie.ts +128 -0
- package/src/runtime/composables/use-runtime-config.ts +67 -11
- package/src/runtime/composables/use-seo-meta.ts +75 -0
- package/src/runtime/entry-server-template.ts +15 -3
- package/src/types/config.ts +15 -0
- package/src/types/index.ts +2 -2
- package/src/types/middleware.ts +8 -6
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, 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
|
-
`
|
|
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
|
|
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,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:
|
|
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
|
-
|
|
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
|
|
@@ -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).
|
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,
|