@jasonshimmy/vite-plugin-cer-app 0.18.2 → 0.19.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 (83) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/commits.txt +2 -1
  3. package/dist/cli/create/templates/spa/package.json.tpl +1 -1
  4. package/dist/cli/create/templates/ssg/package.json.tpl +1 -1
  5. package/dist/cli/create/templates/ssr/package.json.tpl +1 -1
  6. package/dist/plugin/index.d.ts.map +1 -1
  7. package/dist/plugin/index.js +14 -0
  8. package/dist/plugin/index.js.map +1 -1
  9. package/dist/runtime/composables/use-auth.d.ts +6 -0
  10. package/dist/runtime/composables/use-auth.d.ts.map +1 -1
  11. package/dist/runtime/composables/use-auth.js.map +1 -1
  12. package/dist/runtime/composables/use-cookie.d.ts +2 -0
  13. package/dist/runtime/composables/use-cookie.d.ts.map +1 -1
  14. package/dist/runtime/composables/use-cookie.js.map +1 -1
  15. package/dist/runtime/composables/use-fetch.d.ts +1 -0
  16. package/dist/runtime/composables/use-fetch.d.ts.map +1 -1
  17. package/dist/runtime/composables/use-fetch.js.map +1 -1
  18. package/dist/runtime/composables/use-head.d.ts +4 -0
  19. package/dist/runtime/composables/use-head.d.ts.map +1 -1
  20. package/dist/runtime/composables/use-head.js.map +1 -1
  21. package/dist/runtime/composables/use-locale.d.ts +1 -0
  22. package/dist/runtime/composables/use-locale.d.ts.map +1 -1
  23. package/dist/runtime/composables/use-locale.js.map +1 -1
  24. package/dist/runtime/composables/use-runtime-config.d.ts +3 -0
  25. package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
  26. package/dist/runtime/composables/use-runtime-config.js +17 -4
  27. package/dist/runtime/composables/use-runtime-config.js.map +1 -1
  28. package/dist/runtime/composables/use-seo-meta.d.ts +5 -0
  29. package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -1
  30. package/dist/runtime/composables/use-seo-meta.js.map +1 -1
  31. package/dist/runtime/composables/use-session.d.ts +5 -0
  32. package/dist/runtime/composables/use-session.d.ts.map +1 -1
  33. package/dist/runtime/composables/use-session.js.map +1 -1
  34. package/dist/runtime/entry-server-template.d.ts +1 -1
  35. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  36. package/dist/runtime/entry-server-template.js +21 -1
  37. package/dist/runtime/entry-server-template.js.map +1 -1
  38. package/dist/runtime/isr-handler.d.ts +2 -0
  39. package/dist/runtime/isr-handler.d.ts.map +1 -1
  40. package/dist/runtime/isr-handler.js.map +1 -1
  41. package/dist/types/api.d.ts +21 -0
  42. package/dist/types/api.d.ts.map +1 -1
  43. package/dist/types/config.d.ts +120 -0
  44. package/dist/types/config.d.ts.map +1 -1
  45. package/dist/types/config.js +19 -0
  46. package/dist/types/config.js.map +1 -1
  47. package/dist/types/index.d.ts +1 -1
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/middleware.d.ts +16 -0
  50. package/dist/types/middleware.d.ts.map +1 -1
  51. package/dist/types/page.d.ts +56 -0
  52. package/dist/types/page.d.ts.map +1 -1
  53. package/dist/types/plugin.d.ts +21 -0
  54. package/dist/types/plugin.d.ts.map +1 -1
  55. package/docs/authentication.md +18 -0
  56. package/docs/configuration.md +126 -1
  57. package/e2e/cypress/e2e/observability.cy.ts +77 -0
  58. package/e2e/kitchen-sink/app/pages/observability-test.ts +25 -0
  59. package/e2e/kitchen-sink/cer.config.ts +14 -0
  60. package/package.json +1 -1
  61. package/src/__tests__/plugin/entry-server-template.test.ts +50 -0
  62. package/src/__tests__/runtime/use-runtime-config.test.ts +40 -1
  63. package/src/cli/create/templates/spa/package.json.tpl +1 -1
  64. package/src/cli/create/templates/ssg/package.json.tpl +1 -1
  65. package/src/cli/create/templates/ssr/package.json.tpl +1 -1
  66. package/src/plugin/index.ts +13 -0
  67. package/src/runtime/composables/use-auth.ts +6 -0
  68. package/src/runtime/composables/use-cookie.ts +2 -0
  69. package/src/runtime/composables/use-fetch.ts +1 -0
  70. package/src/runtime/composables/use-head.ts +4 -0
  71. package/src/runtime/composables/use-locale.ts +1 -0
  72. package/src/runtime/composables/use-runtime-config.ts +23 -3
  73. package/src/runtime/composables/use-seo-meta.ts +5 -0
  74. package/src/runtime/composables/use-session.ts +5 -0
  75. package/src/runtime/entry-server-template.ts +21 -1
  76. package/src/runtime/isr-handler.ts +2 -0
  77. package/src/types/api.ts +21 -0
  78. package/src/types/config.ts +126 -1
  79. package/src/types/index.ts +1 -1
  80. package/src/types/middleware.ts +16 -0
  81. package/src/types/page.ts +58 -2
  82. package/src/types/plugin.ts +21 -0
  83. package/docs/plan-production-hardening.md +0 -1010
@@ -1,9 +1,36 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
+ /**
3
+ * Controls when the client-side JS activates a pre-rendered page component.
4
+ *
5
+ * - `'load'` — activate immediately on page load (default).
6
+ * - `'idle'` — defer until `requestIdleCallback` fires (browser idle time).
7
+ * - `'visible'` — defer until `cer-layout-view` enters the viewport (IntersectionObserver).
8
+ * - `'none'` — never activate; SSR HTML is served as static markup with no JS.
9
+ */
2
10
  export type HydrateStrategy = 'load' | 'idle' | 'visible' | 'none';
11
+ /** Context object passed to a page's `ssg.paths()` function. Contains resolved route params for one path variant. */
3
12
  export interface SsgPathsContext {
4
13
  params: Record<string, string>;
5
14
  }
15
+ /** Per-page SSG / ISR configuration. Export as `export const meta = { ssg: { ... } }` from any page file. */
6
16
  export interface PageSsgConfig {
17
+ /**
18
+ * Factory that returns the set of param combinations to pre-render for dynamic routes.
19
+ * Required for dynamic routes (e.g. `/posts/[slug]`) — without it the route is skipped
20
+ * during the static build.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * export const meta = {
25
+ * ssg: {
26
+ * paths: async () => {
27
+ * const slugs = await fetchAllSlugs()
28
+ * return slugs.map(slug => ({ params: { slug } }))
29
+ * },
30
+ * },
31
+ * }
32
+ * ```
33
+ */
7
34
  paths?: () => Promise<SsgPathsContext[]> | SsgPathsContext[];
8
35
  /**
9
36
  * Seconds before a cached SSR response is stale and should be re-rendered.
@@ -14,8 +41,14 @@ export interface PageSsgConfig {
14
41
  */
15
42
  revalidate?: number;
16
43
  }
44
+ /**
45
+ * Per-page metadata. Export as `export const meta = { ... }` from any page file.
46
+ * Values are read at build time and embedded into the route manifest.
47
+ */
17
48
  export interface PageMeta {
49
+ /** Layout component to wrap this page. Must match a file in `app/layouts/`. Defaults to `'default'`. */
18
50
  layout?: string;
51
+ /** Named middleware files from `app/middleware/` to run before this route activates. */
19
52
  middleware?: string[];
20
53
  hydrate?: HydrateStrategy;
21
54
  ssg?: PageSsgConfig;
@@ -43,10 +76,33 @@ export interface PageMeta {
43
76
  */
44
77
  render?: 'static' | 'server' | 'spa';
45
78
  }
79
+ /**
80
+ * Context object passed to a page's `loader` function.
81
+ * Available on the server only — the loader runs before the page component renders.
82
+ */
46
83
  export interface PageLoaderContext<P extends Record<string, string> = Record<string, string>> {
47
84
  params: P;
48
85
  query: Record<string, string>;
49
86
  req: IncomingMessage;
50
87
  }
88
+ /**
89
+ * Server-side data loader for a page. Export as `export const loader` (or `export async function loader`)
90
+ * from any page file. The returned object is:
91
+ * - Made available via `usePageData()` inside the page component.
92
+ * - Primitive values are also forwarded as element attributes so `useProps()` works.
93
+ * - Serialized into `window.__CER_DATA__` for client-side hydration.
94
+ *
95
+ * Throwing an error (with an optional `.status` property) renders the page's error component
96
+ * and sets the HTTP response status code.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * export const loader: PageLoader<{ slug: string }, { post: Post }> = async ({ params }) => {
101
+ * const post = await fetchPost(params.slug)
102
+ * if (!post) throw Object.assign(new Error('Not found'), { status: 404 })
103
+ * return { post }
104
+ * }
105
+ * ```
106
+ */
51
107
  export type PageLoader<P extends Record<string, string> = Record<string, string>, D = Record<string, unknown>> = (ctx: PageLoaderContext<P>) => Promise<D> | D;
52
108
  //# sourceMappingURL=page.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/types/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAA;IAC5D;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,GAAG,CAAC,EAAE,aAAa,CAAA;IACnB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC7B;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;CACrC;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAC1F,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,eAAe,CAAA;CACrB;AAED,MAAM,MAAM,UAAU,CACpB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACzD,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACzB,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA"}
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../src/types/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAElE,qHAAqH;AACrH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED,6GAA6G;AAC7G,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;;;;;;OAgBG;IACH,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAA;IAC5D;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,wGAAwG;IACxG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,GAAG,CAAC,EAAE,aAAa,CAAA;IACnB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC7B;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;CACrC;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAC1F,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,eAAe,CAAA;CACrB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,UAAU,CACpB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACzD,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACzB,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA"}
@@ -1,10 +1,31 @@
1
1
  import type { Router } from '@jasonshimmy/custom-elements-runtime/router';
2
2
  import type { CerAppConfig } from './config.js';
3
+ /**
4
+ * Application context passed to each plugin's `setup()` function.
5
+ * Use it to provide values to the component tree, access the router,
6
+ * or read the resolved config.
7
+ */
3
8
  export interface AppContext {
4
9
  provide(key: PropertyKey, value: unknown): void;
5
10
  router: Router;
6
11
  config: CerAppConfig;
7
12
  }
13
+ /**
14
+ * A framework plugin. Plugins run once at app startup (both server and client) before
15
+ * the first route renders. Use them for global setup: registering provide/inject values,
16
+ * subscribing to router events, or initialising third-party libraries.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // app/plugins/analytics.ts
21
+ * export default {
22
+ * name: 'analytics',
23
+ * async setup({ router }) {
24
+ * router.subscribe(({ path }) => trackPageView(path))
25
+ * },
26
+ * } satisfies AppPlugin
27
+ * ```
28
+ */
8
29
  export interface AppPlugin {
9
30
  name: string;
10
31
  setup(app: AppContext): void | Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6CAA6C,CAAA;AACzE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;IAC/C,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,YAAY,CAAA;CACrB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7C"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/types/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6CAA6C,CAAA;AACzE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,OAAO,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;IAC/C,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,YAAY,CAAA;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7C"}
@@ -50,6 +50,24 @@ When `auth` is configured, the framework automatically registers:
50
50
  | `GET /api/auth/callback/:provider` | Handles the OAuth callback — exchanges the code for tokens, fetches the user profile, writes the auth session cookie, and redirects to `redirectAfterLogin` |
51
51
  | `GET /api/auth/logout` | Clears the auth session cookie and redirects to `redirectAfterLogout` |
52
52
 
53
+ ### OAuth error handling
54
+
55
+ All network calls in the callback route (token exchange and user-info fetch) are wrapped in try/catch. If a provider is unreachable or returns an unexpected response, the handler responds with HTTP 502 and a plain-text diagnostic message rather than crashing the server:
56
+
57
+ | Failure | Status | Response body |
58
+ |---|---|---|
59
+ | Token exchange network error | 502 | `[cer-app] OAuth token exchange request failed` |
60
+ | Token exchange non-OK HTTP status | 502 | `[cer-app] OAuth token exchange returned non-OK status` |
61
+ | Token exchange invalid JSON | 502 | `[cer-app] OAuth token exchange returned invalid JSON` |
62
+ | User-info network error | 502 | `[cer-app] OAuth user-info request failed` |
63
+ | User-info non-OK HTTP status | 502 | `[cer-app] OAuth user-info returned non-OK status` |
64
+ | User-info invalid JSON | 502 | `[cer-app] OAuth user-info returned invalid JSON` |
65
+ | Unknown provider | 404 | `[cer-app] Unknown OAuth provider: <name>` |
66
+ | Missing state / code param | 400 | `[cer-app] OAuth callback missing code or state parameter` |
67
+ | State mismatch (CSRF) | 400 | `[cer-app] OAuth state mismatch — possible CSRF attempt` |
68
+
69
+ These responses are intentionally terse — they are server-to-server error messages, not end-user UI. To show a branded error page when OAuth fails, redirect in a top-level error boundary or handle the 502 at your reverse proxy / CDN layer.
70
+
53
71
  ---
54
72
 
55
73
  ## `useAuth(sessionKey?)`
@@ -415,7 +415,12 @@ export const loader = async () => {
415
415
  }
416
416
  ```
417
417
 
418
- > `useRuntimeConfig().private` is `undefined` on the client. Only access it in server-only contexts (loaders, server middleware, API handlers).
418
+ > **Browser enforcement:** Accessing `useRuntimeConfig().private` in a browser context throws a clear runtime error:
419
+ > ```
420
+ > [cer-app] runtimeConfig.private is not available in the browser.
421
+ > Move this access into a server-only loader, middleware, or API handler.
422
+ > ```
423
+ > This prevents accidental secret leaks — the Proxy is applied automatically with no extra configuration required.
419
424
 
420
425
  **TypeScript:** Import `RuntimePrivateConfig` to type your private config:
421
426
 
@@ -425,6 +430,122 @@ import type { RuntimePrivateConfig } from '@jasonshimmy/vite-plugin-cer-app/type
425
430
 
426
431
  ---
427
432
 
433
+ ## Observability hooks
434
+
435
+ Three optional hooks in `cer.config.ts` give you visibility into every SSR request without modifying any application code.
436
+
437
+ > **SSR mode only.** These hooks are invoked by the Node.js SSR request handler. They do **not** fire in SSG or SPA modes:
438
+ > - **SSG** — pages are pre-rendered to static HTML at build time and served directly by the file server. No Node.js handler processes individual page requests at runtime, so no hooks fire.
439
+ > - **SPA** — there is no server-side render pass; the browser loads `index.html` and renders entirely client-side.
440
+ >
441
+ > If you need request logging in SSG/SPA deployments, add it at the reverse-proxy or CDN layer instead.
442
+
443
+ ### `onRequest`
444
+
445
+ Called at the start of every SSR request, before route matching and the data loader. Use this for request logging or to initialise per-request APM transactions.
446
+
447
+ **Type:** `(ctx: RequestHookContext) => void | Promise<void>`
448
+
449
+ | Field | Type | Description |
450
+ |---|---|---|
451
+ | `ctx.path` | `string` | URL pathname (e.g. `/about`) |
452
+ | `ctx.method` | `string` | HTTP method in upper-case (e.g. `'GET'`) |
453
+ | `ctx.req` | `IncomingMessage` | Raw Node.js incoming request |
454
+
455
+ Errors thrown inside `onRequest` are silently swallowed — the hook cannot crash the request handler.
456
+
457
+ ### `onResponse`
458
+
459
+ Called after every SSR response is sent, on both success and error paths. `ctx.duration` contains the elapsed milliseconds from the first byte received to `res.end()`. Use this for latency tracking and access logging.
460
+
461
+ **Type:** `(ctx: ResponseHookContext) => void | Promise<void>`
462
+
463
+ | Field | Type | Description |
464
+ |---|---|---|
465
+ | `ctx.path` | `string` | URL pathname |
466
+ | `ctx.method` | `string` | HTTP method |
467
+ | `ctx.statusCode` | `number` | Final HTTP status code written to the response |
468
+ | `ctx.duration` | `number` | Request duration in milliseconds |
469
+ | `ctx.req` | `IncomingMessage` | Raw Node.js incoming request |
470
+
471
+ Errors thrown inside `onResponse` are silently swallowed.
472
+
473
+ ### `onError`
474
+
475
+ Called when an error is caught by the framework's SSR error boundaries:
476
+
477
+ - **`'loader'`** — the data loader threw
478
+ - **`'render'`** — an infrastructure-level render error escaped the runtime
479
+ - **`'middleware'`** — a server middleware threw
480
+
481
+ Use this to forward errors to Sentry, Datadog, or any error-reporting service.
482
+
483
+ **Type:** `(err: unknown, ctx: ErrorHookContext) => void | Promise<void>`
484
+
485
+ | Field | Type | Description |
486
+ |---|---|---|
487
+ | `ctx.type` | `'loader' \| 'render' \| 'middleware'` | Which layer the error originated from |
488
+ | `ctx.path` | `string` | URL pathname |
489
+ | `ctx.req` | `IncomingMessage` | Raw Node.js incoming request |
490
+
491
+ Errors thrown inside `onError` are silently swallowed — the hook cannot crash the request handler.
492
+
493
+ ### Example — Sentry integration
494
+
495
+ ```ts
496
+ // cer.config.ts
497
+ import * as Sentry from '@sentry/node'
498
+ import { defineConfig } from '@jasonshimmy/vite-plugin-cer-app'
499
+
500
+ Sentry.init({ dsn: process.env.SENTRY_DSN })
501
+
502
+ export default defineConfig({
503
+ onError(err, ctx) {
504
+ Sentry.captureException(err, {
505
+ tags: { type: ctx.type, path: ctx.path },
506
+ })
507
+ },
508
+
509
+ onRequest(ctx) {
510
+ console.log(`→ ${ctx.method} ${ctx.path}`)
511
+ },
512
+
513
+ onResponse(ctx) {
514
+ console.log(`← ${ctx.statusCode} ${ctx.path} (${ctx.duration}ms)`)
515
+ },
516
+ })
517
+ ```
518
+
519
+ ### Example — console request logger
520
+
521
+ ```ts
522
+ // cer.config.ts
523
+ import { defineConfig } from '@jasonshimmy/vite-plugin-cer-app'
524
+
525
+ export default defineConfig({
526
+ onRequest({ method, path }) {
527
+ console.log(`[${new Date().toISOString()}] ${method} ${path}`)
528
+ },
529
+ onResponse({ method, path, statusCode, duration }) {
530
+ console.log(`[${new Date().toISOString()}] ${statusCode} ${method} ${path} — ${duration}ms`)
531
+ },
532
+ })
533
+ ```
534
+
535
+ ### TypeScript types
536
+
537
+ All hook context types are exported from `@jasonshimmy/vite-plugin-cer-app/types`:
538
+
539
+ ```ts
540
+ import type {
541
+ ErrorHookContext,
542
+ RequestHookContext,
543
+ ResponseHookContext,
544
+ } from '@jasonshimmy/vite-plugin-cer-app/types'
545
+ ```
546
+
547
+ ---
548
+
428
549
  ## Passing config to the Vite plugin directly
429
550
 
430
551
  When using `vite.config.ts` instead of (or alongside) `cer.config.ts`:
@@ -459,6 +580,10 @@ import type {
459
580
  AutoImportsConfig,
460
581
  RuntimeConfig,
461
582
  RuntimePublicConfig,
583
+ RuntimePrivateConfig,
462
584
  I18nConfig,
585
+ ErrorHookContext,
586
+ RequestHookContext,
587
+ ResponseHookContext,
463
588
  } from '@jasonshimmy/vite-plugin-cer-app/types'
464
589
  ```
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Observability hooks e2e tests.
3
+ *
4
+ * Log-based tests (onRequest / onResponse firing) only run in SSR mode because
5
+ * SSG serves pre-built static files — the Node.js SSR handler is never called,
6
+ * so the hooks never fire. SPA mode also has no server-side render pass.
7
+ *
8
+ * The private-config browser enforcement tests run in all modes.
9
+ */
10
+
11
+ const LOG_FILE = '/tmp/cer-hooks-test.log'
12
+ const isSSR = () => Cypress.env('mode') === 'ssr'
13
+
14
+ describe('Observability hooks — log output (SSR only)', () => {
15
+ before(() => {
16
+ if (!isSSR()) return
17
+ // Clear the log file so each run starts clean.
18
+ cy.writeFile(LOG_FILE, '')
19
+ })
20
+
21
+ it('fires onRequest and onResponse for a successful page request', function () {
22
+ if (!isSSR()) return this.skip()
23
+ cy.request('/observability-test').then(() => {
24
+ cy.readFile(LOG_FILE).then((log: string) => {
25
+ expect(log).to.match(/REQUEST GET \/observability-test/)
26
+ expect(log).to.match(/RESPONSE 200 GET \/observability-test \d+ms/)
27
+ })
28
+ })
29
+ })
30
+
31
+ it('onResponse includes a non-negative duration in milliseconds', function () {
32
+ if (!isSSR()) return this.skip()
33
+ cy.writeFile(LOG_FILE, '')
34
+ cy.request('/').then(() => {
35
+ cy.readFile(LOG_FILE).then((log: string) => {
36
+ const match = log.match(/RESPONSE \d+ GET \/ (\d+)ms/)
37
+ expect(match).to.not.be.null
38
+ const duration = parseInt(match![1], 10)
39
+ expect(duration).to.be.gte(0)
40
+ })
41
+ })
42
+ })
43
+
44
+ it('does not write an ERROR entry for a normal request', function () {
45
+ if (!isSSR()) return this.skip()
46
+ cy.writeFile(LOG_FILE, '')
47
+ cy.request('/').then(() => {
48
+ cy.readFile(LOG_FILE).then((log: string) => {
49
+ expect(log).not.to.include('ERROR')
50
+ })
51
+ })
52
+ })
53
+ })
54
+
55
+ describe('Observability hooks — server stability', () => {
56
+ it('pages render correctly when hooks are defined (hooks do not crash the server)', () => {
57
+ cy.visit('/observability-test')
58
+ cy.get('[data-cy=observability-heading]').should('contain', 'Observability Test')
59
+ })
60
+ })
61
+
62
+ describe('Private config browser enforcement', () => {
63
+ it('renders runtimeConfig.public values on the page', () => {
64
+ cy.visit('/observability-test')
65
+ cy.get('[data-cy=public-app-name]').should('contain', 'Kitchen Sink')
66
+ })
67
+
68
+ it('throws a Proxy error when .private is accessed in a browser context', () => {
69
+ cy.visit('/observability-test')
70
+ // After client-side hydration the component catches the Proxy error and
71
+ // renders it in [data-cy=private-proxy-error].
72
+ cy.get('[data-cy=private-proxy-error]').should(
73
+ 'contain',
74
+ 'runtimeConfig.private is not available in the browser',
75
+ )
76
+ })
77
+ })
@@ -0,0 +1,25 @@
1
+ component('page-observability-test', () => {
2
+ const { public: pub } = useRuntimeConfig()
3
+
4
+ // Verify that accessing .private in a browser context throws the Proxy error.
5
+ // The component render function runs client-side (inside the custom element upgrade),
6
+ // so typeof window !== 'undefined' is true here and the Proxy fires.
7
+ let privateError = ''
8
+ if (typeof window !== 'undefined') {
9
+ try {
10
+ const cfg = useRuntimeConfig()
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ void (cfg as any).private
13
+ } catch (e) {
14
+ privateError = (e as Error).message
15
+ }
16
+ }
17
+
18
+ return html`
19
+ <div>
20
+ <h1 data-cy="observability-heading">Observability Test</h1>
21
+ <div data-cy="public-app-name">${pub.appName as string}</div>
22
+ <div data-cy="private-proxy-error">${privateError}</div>
23
+ </div>
24
+ `
25
+ })
@@ -1,3 +1,8 @@
1
+ import { appendFileSync } from 'node:fs'
2
+ import type { RequestHookContext, ResponseHookContext, ErrorHookContext } from '@jasonshimmy/vite-plugin-cer-app/types'
3
+
4
+ const LOG_FILE = '/tmp/cer-hooks-test.log'
5
+
1
6
  // Kitchen sink configuration — mode is overridden by --mode CLI flag
2
7
  export default {
3
8
  ssg: { routes: 'auto', concurrency: 2 },
@@ -19,4 +24,13 @@ export default {
19
24
  defaultLocale: 'en',
20
25
  strategy: 'prefix_except_default',
21
26
  },
27
+ onRequest(ctx: RequestHookContext) {
28
+ try { appendFileSync(LOG_FILE, `REQUEST ${ctx.method} ${ctx.path}\n`) } catch { /* ignore */ }
29
+ },
30
+ onResponse(ctx: ResponseHookContext) {
31
+ try { appendFileSync(LOG_FILE, `RESPONSE ${ctx.statusCode} ${ctx.method} ${ctx.path} ${ctx.duration}ms\n`) } catch { /* ignore */ }
32
+ },
33
+ onError(_err: unknown, ctx: ErrorHookContext) {
34
+ try { appendFileSync(LOG_FILE, `ERROR ${ctx.type} ${ctx.path}\n`) } catch { /* ignore */ }
35
+ },
22
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.18.2",
3
+ "version": "0.19.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -304,4 +304,54 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
304
304
  // Fallback to global errorTag
305
305
  expect(src).toContain('?? errorTag')
306
306
  })
307
+
308
+ // ─── Observability hooks ─────────────────────────────────────────────────────
309
+
310
+ it('imports _hooks from virtual:cer-app-config', () => {
311
+ expect(src).toContain('_hooks')
312
+ expect(src).toContain('virtual:cer-app-config')
313
+ })
314
+
315
+ it('fires onRequest at the start of the handler with path, method, and req', () => {
316
+ expect(src).toContain('_hooks?.onRequest')
317
+ expect(src).toContain('_requestPath')
318
+ expect(src).toContain('_requestStart')
319
+ })
320
+
321
+ it('fires onError in the middleware catch with type: middleware', () => {
322
+ expect(src).toContain("type: 'middleware'")
323
+ // onError fires inside runServerMiddleware catch
324
+ const mwIdx = src.indexOf('runServerMiddleware')
325
+ const middlewareErrIdx = src.indexOf("type: 'middleware'", mwIdx)
326
+ expect(middlewareErrIdx).toBeGreaterThan(mwIdx)
327
+ })
328
+
329
+ it('fires onError in the loader catch with type: loader', () => {
330
+ expect(src).toContain("type: 'loader'")
331
+ })
332
+
333
+ it('fires onError in the render catch with type: render', () => {
334
+ expect(src).toContain("type: 'render'")
335
+ const catchIdx = src.indexOf('} catch (_renderErr) {')
336
+ const renderErrIdx = src.indexOf("type: 'render'", catchIdx)
337
+ expect(renderErrIdx).toBeGreaterThan(catchIdx)
338
+ })
339
+
340
+ it('fires onResponse after the success res.end()', () => {
341
+ expect(src).toContain('_hooks?.onResponse')
342
+ // onResponse must appear after DSD_POLYFILL_SCRIPT + fromBodyClose
343
+ const endIdx = src.indexOf('DSD_POLYFILL_SCRIPT + fromBodyClose')
344
+ const responseIdx = src.indexOf('_hooks?.onResponse', endIdx)
345
+ expect(responseIdx).toBeGreaterThan(endIdx)
346
+ })
347
+
348
+ it('fires onResponse in the render error catch so it fires on both success and error paths', () => {
349
+ const catchIdx = src.indexOf('} catch (_renderErr) {')
350
+ const responseInCatchIdx = src.indexOf('_hooks?.onResponse', catchIdx)
351
+ expect(responseInCatchIdx).toBeGreaterThan(catchIdx)
352
+ })
353
+
354
+ it('swallows exceptions thrown by onError so hooks cannot crash the handler', () => {
355
+ expect(src).toContain('/* hooks must not crash the handler */')
356
+ })
307
357
  })
@@ -1,9 +1,15 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest'
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2
2
  import { useRuntimeConfig, initRuntimeConfig, resolvePrivateConfig } from '../../runtime/composables/use-runtime-config.js'
3
3
 
4
4
  beforeEach(() => {
5
5
  // Reset global state between tests
6
6
  delete (globalThis as Record<string, unknown>).__cerRuntimeConfig
7
+ // Ensure window is not set (server context for most tests)
8
+ delete (globalThis as Record<string, unknown>).window
9
+ })
10
+
11
+ afterEach(() => {
12
+ delete (globalThis as Record<string, unknown>).window
7
13
  })
8
14
 
9
15
  describe('initRuntimeConfig', () => {
@@ -75,6 +81,39 @@ describe('useRuntimeConfig', () => {
75
81
  })
76
82
  })
77
83
 
84
+ // ─── Browser Proxy (private config enforcement) ───────────────────────────────
85
+
86
+ describe('useRuntimeConfig browser Proxy', () => {
87
+ it('throws a clear error when .private is accessed in a browser context', () => {
88
+ ;(globalThis as Record<string, unknown>).window = {}
89
+ initRuntimeConfig({ public: { apiBase: '/api' }, private: { secret: 'shh' } })
90
+ const config = useRuntimeConfig()
91
+ expect(() => config.private).toThrow('[cer-app] runtimeConfig.private is not available in the browser')
92
+ })
93
+
94
+ it('allows .public access in a browser context', () => {
95
+ ;(globalThis as Record<string, unknown>).window = {}
96
+ initRuntimeConfig({ public: { apiBase: '/api' }, private: { secret: 'shh' } })
97
+ const config = useRuntimeConfig()
98
+ expect(config.public).toEqual({ apiBase: '/api' })
99
+ })
100
+
101
+ it('does NOT throw when .private is accessed in a server (non-browser) context', () => {
102
+ // window is not set — server environment
103
+ initRuntimeConfig({ public: {}, private: { secret: 'shh' } })
104
+ const config = useRuntimeConfig()
105
+ expect(() => config.private).not.toThrow()
106
+ expect(config.private).toEqual({ secret: 'shh' })
107
+ })
108
+
109
+ it('error message instructs moving access to a loader or middleware', () => {
110
+ ;(globalThis as Record<string, unknown>).window = {}
111
+ initRuntimeConfig({ public: {} })
112
+ const config = useRuntimeConfig()
113
+ expect(() => config.private).toThrow('loader, middleware, or API handler')
114
+ })
115
+ })
116
+
78
117
  // ─── resolvePrivateConfig ─────────────────────────────────────────────────────
79
118
 
80
119
  describe('resolvePrivateConfig', () => {
@@ -11,7 +11,7 @@
11
11
  "@jasonshimmy/custom-elements-runtime": "^3.7.1"
12
12
  },
13
13
  "devDependencies": {
14
- "@jasonshimmy/vite-plugin-cer-app": "^0.18.1",
14
+ "@jasonshimmy/vite-plugin-cer-app": "^0.19.0",
15
15
  "typescript": "^5.9.3",
16
16
  "vite": "^8.0.3"
17
17
  }
@@ -12,7 +12,7 @@
12
12
  "@jasonshimmy/custom-elements-runtime": "^3.7.1"
13
13
  },
14
14
  "devDependencies": {
15
- "@jasonshimmy/vite-plugin-cer-app": "^0.18.1",
15
+ "@jasonshimmy/vite-plugin-cer-app": "^0.19.0",
16
16
  "typescript": "^5.9.3",
17
17
  "vite": "^8.0.3"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@jasonshimmy/custom-elements-runtime": "^3.7.1"
12
12
  },
13
13
  "devDependencies": {
14
- "@jasonshimmy/vite-plugin-cer-app": "^0.18.1",
14
+ "@jasonshimmy/vite-plugin-cer-app": "^0.19.0",
15
15
  "typescript": "^5.9.3",
16
16
  "vite": "^8.0.3"
17
17
  }
@@ -190,6 +190,19 @@ function generateAppConfigModule(config: ResolvedCerConfig, ssr = false): string
190
190
  // the authenticated user without duplicating config knowledge.
191
191
  const authSessionKey = config.auth?.sessionKey ?? (config.auth ? 'auth' : null)
192
192
  code += `\nexport const _authSessionKey = ${JSON.stringify(authSessionKey)}\n`
193
+ // Thread observability hooks by re-importing the user's cer.config.ts.
194
+ // Functions can't be JSON-serialised, so we import directly and re-export.
195
+ const configFilePath = join(config.root, 'cer.config.ts')
196
+ if (existsSync(configFilePath)) {
197
+ code += `\nimport _cerUserConfig from ${JSON.stringify(configFilePath)}\n`
198
+ code += `export const _hooks = {\n`
199
+ code += ` onError: _cerUserConfig.onError ?? null,\n`
200
+ code += ` onRequest: _cerUserConfig.onRequest ?? null,\n`
201
+ code += ` onResponse: _cerUserConfig.onResponse ?? null,\n`
202
+ code += `}\n`
203
+ } else {
204
+ code += `\nexport const _hooks = { onError: null, onRequest: null, onResponse: null }\n`
205
+ }
193
206
  }
194
207
 
195
208
  return code
@@ -2,6 +2,11 @@ import { useSession } from './use-session.js'
2
2
 
3
3
  // ─── Types ────────────────────────────────────────────────────────────────────
4
4
 
5
+ /**
6
+ * Normalised user profile stored in the auth session after a successful OAuth login.
7
+ * The `provider`, `id`, and at least one of `name`/`email` fields are populated by the built-in
8
+ * provider normalisers. Additional fields from `mapUser()` are merged in.
9
+ */
5
10
  export interface AuthUser {
6
11
  provider: string
7
12
  id: string
@@ -11,6 +16,7 @@ export interface AuthUser {
11
16
  [key: string]: unknown
12
17
  }
13
18
 
19
+ /** Return value of `useAuth()`. Exposes the current user and helpers for login/logout. */
14
20
  export interface AuthComposable {
15
21
  /** The currently authenticated user, or `null` if not logged in. */
16
22
  readonly user: AuthUser | null
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
2
 
3
+ /** Options forwarded to the `Set-Cookie` header when writing a cookie via `useCookie()`. */
3
4
  export interface CookieOptions {
4
5
  /** Cookie path. Defaults to '/' when setting/removing. */
5
6
  path?: string
@@ -12,6 +13,7 @@ export interface CookieOptions {
12
13
  sameSite?: 'Strict' | 'Lax' | 'None'
13
14
  }
14
15
 
16
+ /** Isomorphic cookie accessor returned by `useCookie()`. Reads the current value and provides `set`/`remove` methods. */
15
17
  export interface CookieRef {
16
18
  /** The current cookie value, or undefined if not set. */
17
19
  readonly value: string | undefined
@@ -72,6 +72,7 @@ export interface UseFetchReactiveReturn<T = unknown> {
72
72
  refresh(): Promise<void>
73
73
  }
74
74
 
75
+ /** Options for `useFetch()`. Controls caching, SSR behaviour, data transformation, and HTTP request details. */
75
76
  export interface UseFetchOptions<T = unknown> {
76
77
  /**
77
78
  * Unique cache key. Defaults to the full URL string (including query params).
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Describes the `<head>` tags to inject for a page or component.
3
+ * Pass to `useHead()`. All fields are optional; only set fields are emitted.
4
+ */
1
5
  export interface HeadInput {
2
6
  title?: string
3
7
  meta?: Array<Record<string, string>>