@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
@@ -1,22 +1,22 @@
1
1
  /**
2
- * Returns the public runtime configuration set in `cer.config.ts` under
3
- * `runtimeConfig.public`. Available on both server and client.
2
+ * Returns the runtime configuration set in `cer.config.ts`.
4
3
  *
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.
4
+ * - `public` available on both server and client.
5
+ * - `private` available on the server only (resolved from `process.env` at
6
+ * startup). Never present on the client.
8
7
  *
9
8
  * @example
10
9
  * // cer.config.ts
11
10
  * export default defineConfig({
12
11
  * runtimeConfig: {
13
12
  * public: { apiBase: process.env.VITE_API_BASE ?? '/api' },
13
+ * private: { dbUrl: '', secretKey: '' },
14
14
  * },
15
15
  * })
16
16
  *
17
- * // app/pages/index.ts
18
- * const config = useRuntimeConfig()
19
- * fetch(config.public.apiBase + '/posts')
17
+ * // app/pages/index.ts (loader — server-only)
18
+ * const { private: priv } = useRuntimeConfig()
19
+ * const rows = await db.query(priv.dbUrl)
20
20
  */
21
21
  export function useRuntimeConfig() {
22
22
  // Dynamic import resolved at runtime — avoids a static circular dependency
@@ -38,4 +38,38 @@ export function initRuntimeConfig(config) {
38
38
  ;
39
39
  globalThis.__cerRuntimeConfig = config;
40
40
  }
41
+ /**
42
+ * Converts a camelCase or mixed key to UPPER_SNAKE_CASE for env var lookup.
43
+ * Examples: `dbUrl` → `DB_URL`, `secretKey` → `SECRET_KEY`, `API_KEY` → `API_KEY`.
44
+ */
45
+ function toUpperSnakeCase(key) {
46
+ return key
47
+ .replace(/([a-z])([A-Z])/g, '$1_$2')
48
+ .toUpperCase();
49
+ }
50
+ /**
51
+ * Resolves a private config object by looking up each key in the supplied
52
+ * environment variable map, with the following precedence:
53
+ *
54
+ * 1. `env[key]` — exact case (e.g. `process.env.dbUrl`)
55
+ * 2. `env[UPPER_SNAKE_CASE(key)]` — conventional env var form (e.g. `process.env.DB_URL`)
56
+ * 3. `defaultValue` — the declared default from `cer.config.ts`
57
+ *
58
+ * Accepts an optional `env` parameter so the function is unit-testable
59
+ * without mutating `process.env`.
60
+ */
61
+ export function resolvePrivateConfig(defaults, env = process.env) {
62
+ return Object.fromEntries(Object.entries(defaults).map(([key, defaultValue]) => {
63
+ const envKey = toUpperSnakeCase(key);
64
+ const resolved = env[key] ?? env[envKey] ?? defaultValue;
65
+ // Warn when no env var was found and the declared default is an empty string.
66
+ // An empty-string default is the conventional way to declare a required secret
67
+ // (the key exists for typing purposes but has no safe default value).
68
+ if (resolved === '' && env[key] === undefined && env[envKey] === undefined) {
69
+ console.warn(`[cer-app] runtimeConfig.private: "${key}" is an empty string — ` +
70
+ `set ${envKey} in the environment to provide a value.`);
71
+ }
72
+ return [key, resolved];
73
+ }));
74
+ }
41
75
  //# sourceMappingURL=use-runtime-config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-runtime-config.js","sourceRoot":"","sources":["../../../src/runtime/composables/use-runtime-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,gBAAgB;IAC9B,2EAA2E;IAC3E,iDAAiD;IACjD,8DAA8D;IAC9D,MAAM,GAAG,GAAI,UAAkB,CAAC,kBAAkB,CAAA;IAClD,IAAI,GAAG;QAAE,OAAO,GAA0C,CAAA;IAE1D,iFAAiF;IACjF,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;AACvB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAA2C;IAC3E,8DAA8D;IAC9D,CAAC;IAAC,UAAkB,CAAC,kBAAkB,GAAG,MAAM,CAAA;AAClD,CAAC"}
1
+ {"version":3,"file":"use-runtime-config.js","sourceRoot":"","sources":["../../../src/runtime/composables/use-runtime-config.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,gBAAgB;IAC9B,2EAA2E;IAC3E,iDAAiD;IACjD,8DAA8D;IAC9D,MAAM,GAAG,GAAI,UAAkB,CAAC,kBAAkB,CAAA;IAClD,IAAI,GAAG;QAAE,OAAO,GAA0B,CAAA;IAE1C,iFAAiF;IACjF,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;AACvB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAA2B;IAC3D,8DAA8D;IAC9D,CAAC;IAAC,UAAkB,CAAC,kBAAkB,GAAG,MAAM,CAAA;AAClD,CAAC;AAED;;;GAGG;AACH,SAAS,gBAAgB,CAAC,GAAW;IACnC,OAAO,GAAG;SACP,OAAO,CAAC,iBAAiB,EAAE,OAAO,CAAC;SACnC,WAAW,EAAE,CAAA;AAClB,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,oBAAoB,CAClC,QAAgC,EAChC,MAA0C,OAAO,CAAC,GAAyC;IAE3F,OAAO,MAAM,CAAC,WAAW,CACvB,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,YAAY,CAAC,EAAE,EAAE;QACnD,MAAM,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAA;QACpC,MAAM,QAAQ,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,YAAY,CAAA;QACxD,8EAA8E;QAC9E,+EAA+E;QAC/E,sEAAsE;QACtE,IAAI,QAAQ,KAAK,EAAE,IAAI,GAAG,CAAC,GAAG,CAAC,KAAK,SAAS,IAAI,GAAG,CAAC,MAAM,CAAC,KAAK,SAAS,EAAE,CAAC;YAC3E,OAAO,CAAC,IAAI,CACV,qCAAqC,GAAG,yBAAyB;gBACjE,OAAO,MAAM,yCAAyC,CACvD,CAAA;QACH,CAAC;QACD,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAA;IACxB,CAAC,CAAC,CACH,CAAA;AACH,CAAC"}
@@ -0,0 +1,42 @@
1
+ export interface SeoMetaInput {
2
+ /** Sets the document title. */
3
+ title?: string;
4
+ /** Sets the meta description. */
5
+ description?: string;
6
+ ogTitle?: string;
7
+ ogDescription?: string;
8
+ ogImage?: string;
9
+ ogUrl?: string;
10
+ /** e.g. `'website'`, `'article'`. No tag is emitted when omitted. */
11
+ ogType?: string;
12
+ ogSiteName?: string;
13
+ /** e.g. `'summary'`, `'summary_large_image'`. No tag is emitted when omitted. */
14
+ twitterCard?: string;
15
+ twitterTitle?: string;
16
+ twitterDescription?: string;
17
+ twitterImage?: string;
18
+ /** Twitter/X site handle, e.g. '@mysite'. */
19
+ twitterSite?: string;
20
+ /** Canonical URL injected as <link rel="canonical">. */
21
+ canonical?: string;
22
+ }
23
+ /**
24
+ * Thin wrapper over `useHead()` for common SEO tags.
25
+ *
26
+ * Sets the page title, meta description, Open Graph tags, Twitter Card tags,
27
+ * and a canonical link element. Only tags with a non-undefined value are emitted.
28
+ *
29
+ * It is auto-imported, so you don't need to import it manually.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * useSeoMeta({
34
+ * title: 'My page',
35
+ * description: 'A great page.',
36
+ * ogImage: 'https://example.com/og.png',
37
+ * canonical: 'https://example.com/my-page',
38
+ * })
39
+ * ```
40
+ */
41
+ export declare function useSeoMeta(input: SeoMetaInput): void;
42
+ //# sourceMappingURL=use-seo-meta.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-seo-meta.d.ts","sourceRoot":"","sources":["../../../src/runtime/composables/use-seo-meta.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iCAAiC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAA;IAEpB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IAEnB,iFAAiF;IACjF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CA6BpD"}
@@ -0,0 +1,58 @@
1
+ import { useHead } from './use-head.js';
2
+ /**
3
+ * Thin wrapper over `useHead()` for common SEO tags.
4
+ *
5
+ * Sets the page title, meta description, Open Graph tags, Twitter Card tags,
6
+ * and a canonical link element. Only tags with a non-undefined value are emitted.
7
+ *
8
+ * It is auto-imported, so you don't need to import it manually.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * useSeoMeta({
13
+ * title: 'My page',
14
+ * description: 'A great page.',
15
+ * ogImage: 'https://example.com/og.png',
16
+ * canonical: 'https://example.com/my-page',
17
+ * })
18
+ * ```
19
+ */
20
+ export function useSeoMeta(input) {
21
+ const meta = [];
22
+ const link = [];
23
+ if (input.description !== undefined)
24
+ meta.push({ name: 'description', content: input.description });
25
+ // Open Graph
26
+ if (input.ogTitle !== undefined)
27
+ meta.push({ property: 'og:title', content: input.ogTitle });
28
+ if (input.ogDescription !== undefined)
29
+ meta.push({ property: 'og:description', content: input.ogDescription });
30
+ if (input.ogImage !== undefined)
31
+ meta.push({ property: 'og:image', content: input.ogImage });
32
+ if (input.ogUrl !== undefined)
33
+ meta.push({ property: 'og:url', content: input.ogUrl });
34
+ if (input.ogType !== undefined)
35
+ meta.push({ property: 'og:type', content: input.ogType });
36
+ if (input.ogSiteName !== undefined)
37
+ meta.push({ property: 'og:site_name', content: input.ogSiteName });
38
+ // Twitter / X
39
+ if (input.twitterCard !== undefined)
40
+ meta.push({ name: 'twitter:card', content: input.twitterCard });
41
+ if (input.twitterTitle !== undefined)
42
+ meta.push({ name: 'twitter:title', content: input.twitterTitle });
43
+ if (input.twitterDescription !== undefined)
44
+ meta.push({ name: 'twitter:description', content: input.twitterDescription });
45
+ if (input.twitterImage !== undefined)
46
+ meta.push({ name: 'twitter:image', content: input.twitterImage });
47
+ if (input.twitterSite !== undefined)
48
+ meta.push({ name: 'twitter:site', content: input.twitterSite });
49
+ // Canonical link
50
+ if (input.canonical !== undefined)
51
+ link.push({ rel: 'canonical', href: input.canonical });
52
+ useHead({
53
+ title: input.title,
54
+ meta: meta.length > 0 ? meta : undefined,
55
+ link: link.length > 0 ? link : undefined,
56
+ });
57
+ }
58
+ //# sourceMappingURL=use-seo-meta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-seo-meta.js","sourceRoot":"","sources":["../../../src/runtime/composables/use-seo-meta.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AA2BvC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,UAAU,CAAC,KAAmB;IAC5C,MAAM,IAAI,GAAkC,EAAE,CAAA;IAC9C,MAAM,IAAI,GAAkC,EAAE,CAAA;IAE9C,IAAI,KAAK,CAAC,WAAW,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAA;IAEnG,aAAa;IACb,IAAI,KAAK,CAAC,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAA;IAC5F,IAAI,KAAK,CAAC,aAAa,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,KAAK,CAAC,aAAa,EAAE,CAAC,CAAA;IAC9G,IAAI,KAAK,CAAC,OAAO,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAA;IAC5F,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAA;IACtF,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACzF,IAAI,KAAK,CAAC,UAAU,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,CAAA;IAEtG,cAAc;IACd,IAAI,KAAK,CAAC,WAAW,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAA;IACpG,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,KAAK,CAAC,YAAY,EAAE,CAAC,CAAA;IACvG,IAAI,KAAK,CAAC,kBAAkB,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,qBAAqB,EAAE,OAAO,EAAE,KAAK,CAAC,kBAAkB,EAAE,CAAC,CAAA;IACzH,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,KAAK,CAAC,YAAY,EAAE,CAAC,CAAA;IACvG,IAAI,KAAK,CAAC,WAAW,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAA;IAEpG,iBAAiB;IACjB,IAAI,KAAK,CAAC,SAAS,KAAK,SAAS;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,CAAA;IAEzF,OAAO,CAAC;QACN,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,IAAI,EAAE,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;QACxC,IAAI,EAAE,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;KACzC,CAAC,CAAA;AACJ,CAAC"}
@@ -11,5 +11,5 @@
11
11
  * - useHead() support via beginHeadCollection / endHeadCollection
12
12
  * - DSD polyfill injected at end of <body> after client-template merge
13
13
  */
14
- export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport { runtimeConfig } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'\nimport { errorTag } from 'virtual:cer-error'\nimport { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'\n\nregisterBuiltinComponents()\ninitRuntimeConfig(runtimeConfig)\n\n// Pre-load the full HTML entity map so named entities like &mdash; decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (&lt;, &gt;, &amp; \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\nconst _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\nconst _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n if (pageTag) {\n pageVnode = { tag: pageTag, props: { attrs: { ...params } }, children: [] }\n }\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n }\n }\n } catch (err) {\n // Loader threw \u2014 render the error page server-side if app/error.ts exists.\n const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')\n ? err.status : 500\n const message = (err instanceof Error) ? err.message : String(err)\n if (!errorTag) {\n console.error('[cer-app] Loader error (no app/error.ts defined):', err)\n }\n const errVnode = errorTag\n ? { tag: errorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: errVnode, router, head: undefined, status }\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n return { vnode, router, head, status: null }\n}\n\nexport const handler = async (req, res) => {\n await _cerDataStore.run(null, async () => {\n const { vnode, router, head, status } = await _prepareRequest(req)\n if (status != null) res.statusCode = status\n\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk.\n const { value: firstChunk = '' } = await reader.read()\n\n // Merge loader data script + useHead() tags into the document head.\n const headContent = [head, headTags].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n const merged = _clientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n })\n}\n\n// ISR-wrapped handler for production integrations (Express, Hono, Fastify).\n// Routes with meta.ssg.revalidate are served stale-while-revalidate.\nexport const isrHandler = createIsrHandler(routes, handler)\n\nexport { apiRoutes, plugins, layouts, routes }\nexport default handler\n";
14
+ export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport 'virtual:cer-components'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport { runtimeConfig, _runtimePrivateDefaults } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig, resolvePrivateConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'\nimport { errorTag } from 'virtual:cer-error'\nimport { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'\n\nregisterBuiltinComponents()\n\n// Resolve private config from environment variables at server startup.\n// Each key declared in runtimeConfig.private is looked up in process.env,\n// first as-is, then as ALL_CAPS. The declared default is used as fallback.\ninitRuntimeConfig({ ...runtimeConfig, private: resolvePrivateConfig(_runtimePrivateDefaults ?? {}) })\n\n// Pre-load the full HTML entity map so named entities like &mdash; decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (&lt;, &gt;, &amp; \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Async-local storage for request-scoped req/res access.\n// Allows isomorphic composables (e.g. useCookie) to read/write HTTP headers\n// without prop-drilling the request context through the component tree.\nconst _cerReqStore = new AsyncLocalStorage()\n;(globalThis).__CER_REQ_STORE__ = _cerReqStore\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\nconst _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\nconst _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n if (pageTag) {\n pageVnode = { tag: pageTag, props: { attrs: { ...params } }, children: [] }\n }\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n }\n }\n } catch (err) {\n // Loader threw \u2014 render the error page server-side if app/error.ts exists.\n const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')\n ? err.status : 500\n const message = (err instanceof Error) ? err.message : String(err)\n if (!errorTag) {\n console.error('[cer-app] Loader error (no app/error.ts defined):', err)\n }\n const errVnode = errorTag\n ? { tag: errorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: errVnode, router, head: undefined, status }\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n return { vnode, router, head, status: null }\n}\n\nexport const handler = async (req, res) => {\n await _cerReqStore.run({ req, res }, async () => {\n await _cerDataStore.run(null, async () => {\n const { vnode, router, head, status } = await _prepareRequest(req)\n if (status != null) res.statusCode = status\n\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk.\n const { value: firstChunk = '' } = await reader.read()\n\n // Merge loader data script + useHead() tags into the document head.\n const headContent = [head, headTags].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n const merged = _clientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n })\n })\n}\n\n// ISR-wrapped handler for production integrations (Express, Hono, Fastify).\n// Routes with meta.ssg.revalidate are served stale-while-revalidate.\nexport const isrHandler = createIsrHandler(routes, handler)\n\nexport { apiRoutes, plugins, layouts, routes }\nexport default handler\n";
15
15
  //# sourceMappingURL=entry-server-template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,6/WAoPjC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,osYAgQjC,CAAA"}
@@ -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).
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoPpC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgQpC,CAAA"}
@@ -17,6 +17,9 @@ export interface AutoImportsConfig {
17
17
  export interface RuntimePublicConfig {
18
18
  [key: string]: unknown;
19
19
  }
20
+ export interface RuntimePrivateConfig {
21
+ [key: string]: string;
22
+ }
20
23
  export interface RuntimeConfig {
21
24
  /**
22
25
  * Public runtime config — available on both server and client via
@@ -31,6 +34,17 @@ export interface RuntimeConfig {
31
34
  * }
32
35
  */
33
36
  public?: RuntimePublicConfig;
37
+ /**
38
+ * Server-only secrets — never serialized into the client bundle.
39
+ * Declare keys with empty-string defaults here; at server startup each key
40
+ * is resolved from `process.env[KEY]` (case-insensitive, ALL_CAPS preferred).
41
+ *
42
+ * @example
43
+ * runtimeConfig: {
44
+ * private: { dbUrl: '', secretKey: '' },
45
+ * }
46
+ */
47
+ private?: RuntimePrivateConfig;
34
48
  }
35
49
  export interface CerAppConfig {
36
50
  mode?: 'spa' | 'ssr' | 'ssg';
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAA;CAC9B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE/D"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,mBAAmB,CAAA;IAC5B;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,oBAAoB,CAAA;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAA;CAC9B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE/D"}
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAwDA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAuEA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -1,7 +1,7 @@
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';
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAC/H,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACzH,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC/E,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AACrJ,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,aAAa,EAAE,QAAQ,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACzH,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC/E,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA"}
@@ -1,6 +1,12 @@
1
1
  import type { RouteState } from '@jasonshimmy/custom-elements-runtime/router';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
- export type NextFunction = (redirectTo?: string) => void;
4
- export type RouteMiddleware = (to: RouteState, from: RouteState | null, next: NextFunction) => void | Promise<void>;
3
+ /**
4
+ * Return value from a route middleware function:
5
+ * - `true` — allow navigation
6
+ * - `false` — block navigation
7
+ * - `string` — redirect to that path
8
+ */
9
+ export type GuardResult = boolean | string | Promise<boolean | string>;
10
+ export type MiddlewareFn = (to: RouteState, from: RouteState | null) => GuardResult;
5
11
  export type ServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void | Promise<void>;
6
12
  //# sourceMappingURL=middleware.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/types/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6CAA6C,CAAA;AAC7E,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,MAAM,MAAM,YAAY,GAAG,CAAC,UAAU,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;AAExD,MAAM,MAAM,eAAe,GAAG,CAC5B,EAAE,EAAE,UAAU,EACd,IAAI,EAAE,UAAU,GAAG,IAAI,EACvB,IAAI,EAAE,YAAY,KACf,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;AAEzB,MAAM,MAAM,gBAAgB,GAAG,CAC7B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,IAAI,KACb,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/types/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6CAA6C,CAAA;AAC7E,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,CAAA;AAEtE,MAAM,MAAM,YAAY,GAAG,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,GAAG,IAAI,KAAK,WAAW,CAAA;AAEnF,MAAM,MAAM,gBAAgB,GAAG,CAC7B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,IAAI,KACb,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA"}
package/docs/cli.md CHANGED
@@ -110,6 +110,11 @@ cer-app preview --port 8080
110
110
  - Static assets from `dist/client/` are served first; HTML requests fall through to the SSR handler
111
111
  - Otherwise, starts a static file server with SPA fallback (serves `index.html` for unknown paths)
112
112
  - Returns 404 for paths not found in `dist/` (static mode only)
113
+ - **Path traversal protection:** all file requests are validated against the `dist/` root — requests attempting to escape it (e.g. `GET /../../../../etc/passwd`) receive a `400` response
114
+ - **Security headers:** every response includes `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin`
115
+ - **Smart Cache-Control:** Vite content-hashes assets placed in `/assets/` — these are served with `Cache-Control: public, max-age=31536000, immutable`. All other files (HTML, etc.) use `Cache-Control: no-cache` so browsers always revalidate
116
+ - **Graceful shutdown:** on `SIGTERM` or `SIGINT`, the server stops accepting new connections and waits for in-flight requests to finish before exiting. A 10-second timeout triggers a forced exit if connections do not drain
117
+ - **Request timeouts:** `headersTimeout` (10 s) aborts connections that stall while sending headers; `requestTimeout` (30 s) limits the total time allowed per request/response cycle, protecting against slow-client attacks
113
118
 
114
119
  ---
115
120
 
@@ -144,7 +144,9 @@ import { useInject } from '@jasonshimmy/vite-plugin-cer-app/composables'
144
144
 
145
145
  ### `useRuntimeConfig()`
146
146
 
147
- Returns the `public` runtime configuration object set in `cer.config.ts` under `runtimeConfig.public`. Available in all rendering modes (SPA, SSR, SSG) and on both server and client.
147
+ Returns the runtime configuration set in `cer.config.ts`. Returns `{ public, private? }`:
148
+ - `public` — available everywhere (server and client)
149
+ - `private` — server-only secrets resolved from `process.env` at startup; `undefined` on the client
148
150
 
149
151
  ```ts
150
152
  // cer.config.ts
@@ -152,28 +154,184 @@ export default defineConfig({
152
154
  runtimeConfig: {
153
155
  public: {
154
156
  apiBase: process.env.VITE_API_BASE ?? '/api',
155
- featureFlags: { darkMode: true },
157
+ },
158
+ private: {
159
+ dbUrl: '', // resolved from process.env.DB_URL at server startup
156
160
  },
157
161
  },
158
162
  })
159
163
  ```
160
164
 
161
165
  ```ts
162
- // app/pages/index.ts — auto-imported, no import statement needed
166
+ // app/pages/index.ts — public config, works on client and server
163
167
  component('page-index', () => {
164
168
  const { public: cfg } = useRuntimeConfig()
165
- // cfg.apiBase → '/api'
166
-
167
169
  return html`<p>API base: ${cfg.apiBase}</p>`
168
170
  })
169
171
  ```
170
172
 
171
- The config is initialized at app boot (both client and server) by calling `initRuntimeConfig(runtimeConfig)` with the value from `virtual:cer-app-config`. You only need `useRuntimeConfig()` to read it.
173
+ ```ts
174
+ // app/pages/data.ts — private config, server-only (loader)
175
+ export const loader = async () => {
176
+ const { private: priv } = useRuntimeConfig()
177
+ const rows = await db.query(priv!.dbUrl)
178
+ return { rows }
179
+ }
180
+ ```
172
181
 
173
- **Only use `runtimeConfig.public` for values safe to expose to the browser.** Secrets, tokens, and private keys must stay in server-only code (loaders, API handlers, server middleware).
182
+ **Only use `runtimeConfig.public` for values safe to expose to the browser.** Use `runtimeConfig.private` for secrets they are never sent to the client.
183
+
184
+ Keys declared in `runtimeConfig.private` with an empty-string default are treated as **required** secrets. If the corresponding environment variable is not set at server startup, a warning is logged:
185
+
186
+ ```
187
+ [cer-app] runtimeConfig.private: "dbUrl" is an empty string — set DB_URL in the environment to provide a value.
188
+ ```
189
+
190
+ If the default is a non-empty string it is used as a genuine fallback — no warning is emitted.
174
191
 
175
192
  If you need it outside auto-imported directories:
176
193
 
177
194
  ```ts
178
195
  import { useRuntimeConfig } from '@jasonshimmy/vite-plugin-cer-app/composables'
179
196
  ```
197
+
198
+ ---
199
+
200
+ ### `useSeoMeta(input)`
201
+
202
+ Thin wrapper over `useHead()` for the most common SEO tags — Open Graph, Twitter Card, meta description, and canonical URL. Works in SPA, SSR, and SSG modes.
203
+
204
+ ```ts
205
+ // app/pages/about.ts
206
+ component('page-about', () => {
207
+ useSeoMeta({
208
+ title: 'About Us',
209
+ description: 'Learn more about our team.',
210
+ ogTitle: 'About Us — My Site',
211
+ ogDescription: 'Learn more about our team.',
212
+ ogImage: 'https://example.com/og/about.png',
213
+ ogUrl: 'https://example.com/about',
214
+ ogType: 'website',
215
+ ogSiteName: 'My Site',
216
+ twitterCard: 'summary_large_image',
217
+ twitterSite: '@mysite',
218
+ canonical: 'https://example.com/about',
219
+ })
220
+ return html`<h1>About Us</h1>`
221
+ })
222
+ ```
223
+
224
+ Only properties you set are emitted — passing `undefined` (or omitting a property entirely) skips that tag.
225
+
226
+ #### `SeoMetaInput` fields
227
+
228
+ | Field | Tag emitted |
229
+ |---|---|
230
+ | `title` | `<title>` |
231
+ | `description` | `<meta name="description">` |
232
+ | `ogTitle` | `<meta property="og:title">` |
233
+ | `ogDescription` | `<meta property="og:description">` |
234
+ | `ogImage` | `<meta property="og:image">` |
235
+ | `ogUrl` | `<meta property="og:url">` |
236
+ | `ogType` | `<meta property="og:type">` |
237
+ | `ogSiteName` | `<meta property="og:site_name">` |
238
+ | `twitterCard` | `<meta name="twitter:card">` |
239
+ | `twitterTitle` | `<meta name="twitter:title">` |
240
+ | `twitterDescription` | `<meta name="twitter:description">` |
241
+ | `twitterImage` | `<meta name="twitter:image">` |
242
+ | `twitterSite` | `<meta name="twitter:site">` |
243
+ | `canonical` | `<link rel="canonical">` |
244
+
245
+ If you need it outside auto-imported directories:
246
+
247
+ ```ts
248
+ import { useSeoMeta } from '@jasonshimmy/vite-plugin-cer-app/composables'
249
+ ```
250
+
251
+ TypeScript types:
252
+
253
+ ```ts
254
+ import type { SeoMetaInput } from '@jasonshimmy/vite-plugin-cer-app/types'
255
+ ```
256
+
257
+ ---
258
+
259
+ ### `useCookie(name, options?)`
260
+
261
+ Isomorphic cookie composable. Reads and writes cookies transparently on both server and client:
262
+
263
+ - **Server (SSR/SSG):** reads `req.headers.cookie`; writes `Set-Cookie` response headers via `res.setHeader`.
264
+ - **Client:** reads and writes `document.cookie`.
265
+
266
+ ```ts
267
+ // app/pages/profile.ts
268
+ component('page-profile', () => {
269
+ const session = useCookie('session')
270
+
271
+ // Read
272
+ console.log(session.value) // 'abc123' | undefined
273
+
274
+ // Write
275
+ session.set('abc123', { httpOnly: true, sameSite: 'Strict' })
276
+
277
+ // Remove
278
+ session.remove()
279
+
280
+ return html`<p>Session: ${session.value ?? 'none'}</p>`
281
+ })
282
+ ```
283
+
284
+ #### `CookieRef`
285
+
286
+ | Member | Type | Description |
287
+ |---|---|---|
288
+ | `value` | `string \| undefined` | Current cookie value (read at call time) |
289
+ | `set(value, options?)` | `void` | Write the cookie |
290
+ | `remove(options?)` | `void` | Delete the cookie (sets `Max-Age=0`) |
291
+
292
+ #### `CookieOptions`
293
+
294
+ | Option | Type | Description |
295
+ |---|---|---|
296
+ | `path` | `string` | Cookie path (defaults to `/` when setting/removing) |
297
+ | `domain` | `string` | Cookie domain |
298
+ | `maxAge` | `number` | Max age in seconds |
299
+ | `expires` | `Date` | Expiry date |
300
+ | `httpOnly` | `boolean` | Set `HttpOnly` flag |
301
+ | `secure` | `boolean` | Set `Secure` flag |
302
+ | `sameSite` | `'Strict' \| 'Lax' \| 'None'` | `SameSite` attribute |
303
+
304
+ Default options can be passed as the second argument to `useCookie` — they are merged with options passed to `set()`/`remove()`:
305
+
306
+ ```ts
307
+ const auth = useCookie('auth', { httpOnly: true, secure: true, sameSite: 'Strict' })
308
+ auth.set('tok') // inherits httpOnly, secure, sameSite automatically
309
+ ```
310
+
311
+ If you need it outside auto-imported directories:
312
+
313
+ ```ts
314
+ import { useCookie } from '@jasonshimmy/vite-plugin-cer-app/composables'
315
+ ```
316
+
317
+ TypeScript types:
318
+
319
+ ```ts
320
+ import type { CookieOptions, CookieRef } from '@jasonshimmy/vite-plugin-cer-app/types'
321
+ ```
322
+
323
+ ---
324
+
325
+ ### `defineMiddleware(fn)`
326
+
327
+ Identity helper that gives TypeScript the correct `MiddlewareFn` type. Auto-imported — no import needed in `app/middleware/` files.
328
+
329
+ ```ts
330
+ // app/middleware/auth.ts
331
+ export default defineMiddleware(async (to, from) => {
332
+ const isLoggedIn = !!localStorage.getItem('token')
333
+ return isLoggedIn ? true : '/login'
334
+ })
335
+ ```
336
+
337
+ See [Middleware](./middleware.md) for full documentation.