@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.
- package/CHANGELOG.md +5 -0
- package/commits.txt +2 -1
- package/dist/cli/create/templates/spa/package.json.tpl +1 -1
- package/dist/cli/create/templates/ssg/package.json.tpl +1 -1
- package/dist/cli/create/templates/ssr/package.json.tpl +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +14 -0
- package/dist/plugin/index.js.map +1 -1
- package/dist/runtime/composables/use-auth.d.ts +6 -0
- package/dist/runtime/composables/use-auth.d.ts.map +1 -1
- package/dist/runtime/composables/use-auth.js.map +1 -1
- package/dist/runtime/composables/use-cookie.d.ts +2 -0
- package/dist/runtime/composables/use-cookie.d.ts.map +1 -1
- package/dist/runtime/composables/use-cookie.js.map +1 -1
- package/dist/runtime/composables/use-fetch.d.ts +1 -0
- package/dist/runtime/composables/use-fetch.d.ts.map +1 -1
- package/dist/runtime/composables/use-fetch.js.map +1 -1
- package/dist/runtime/composables/use-head.d.ts +4 -0
- package/dist/runtime/composables/use-head.d.ts.map +1 -1
- package/dist/runtime/composables/use-head.js.map +1 -1
- package/dist/runtime/composables/use-locale.d.ts +1 -0
- package/dist/runtime/composables/use-locale.d.ts.map +1 -1
- package/dist/runtime/composables/use-locale.js.map +1 -1
- package/dist/runtime/composables/use-runtime-config.d.ts +3 -0
- package/dist/runtime/composables/use-runtime-config.d.ts.map +1 -1
- package/dist/runtime/composables/use-runtime-config.js +17 -4
- package/dist/runtime/composables/use-runtime-config.js.map +1 -1
- package/dist/runtime/composables/use-seo-meta.d.ts +5 -0
- package/dist/runtime/composables/use-seo-meta.d.ts.map +1 -1
- package/dist/runtime/composables/use-seo-meta.js.map +1 -1
- package/dist/runtime/composables/use-session.d.ts +5 -0
- package/dist/runtime/composables/use-session.d.ts.map +1 -1
- package/dist/runtime/composables/use-session.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +21 -1
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/dist/runtime/isr-handler.d.ts +2 -0
- package/dist/runtime/isr-handler.d.ts.map +1 -1
- package/dist/runtime/isr-handler.js.map +1 -1
- package/dist/types/api.d.ts +21 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/config.d.ts +120 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +19 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/middleware.d.ts +16 -0
- package/dist/types/middleware.d.ts.map +1 -1
- package/dist/types/page.d.ts +56 -0
- package/dist/types/page.d.ts.map +1 -1
- package/dist/types/plugin.d.ts +21 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/docs/authentication.md +18 -0
- package/docs/configuration.md +126 -1
- package/e2e/cypress/e2e/observability.cy.ts +77 -0
- package/e2e/kitchen-sink/app/pages/observability-test.ts +25 -0
- package/e2e/kitchen-sink/cer.config.ts +14 -0
- package/package.json +1 -1
- package/src/__tests__/plugin/entry-server-template.test.ts +50 -0
- package/src/__tests__/runtime/use-runtime-config.test.ts +40 -1
- package/src/cli/create/templates/spa/package.json.tpl +1 -1
- package/src/cli/create/templates/ssg/package.json.tpl +1 -1
- package/src/cli/create/templates/ssr/package.json.tpl +1 -1
- package/src/plugin/index.ts +13 -0
- package/src/runtime/composables/use-auth.ts +6 -0
- package/src/runtime/composables/use-cookie.ts +2 -0
- package/src/runtime/composables/use-fetch.ts +1 -0
- package/src/runtime/composables/use-head.ts +4 -0
- package/src/runtime/composables/use-locale.ts +1 -0
- package/src/runtime/composables/use-runtime-config.ts +23 -3
- package/src/runtime/composables/use-seo-meta.ts +5 -0
- package/src/runtime/composables/use-session.ts +5 -0
- package/src/runtime/entry-server-template.ts +21 -1
- package/src/runtime/isr-handler.ts +2 -0
- package/src/types/api.ts +21 -0
- package/src/types/config.ts +126 -1
- package/src/types/index.ts +1 -1
- package/src/types/middleware.ts +16 -0
- package/src/types/page.ts +58 -2
- package/src/types/plugin.ts +21 -0
- package/docs/plan-production-hardening.md +0 -1010
package/dist/types/page.d.ts
CHANGED
|
@@ -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
|
package/dist/types/page.d.ts.map
CHANGED
|
@@ -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"}
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -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"}
|
package/docs/authentication.md
CHANGED
|
@@ -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?)`
|
package/docs/configuration.md
CHANGED
|
@@ -415,7 +415,12 @@ export const loader = async () => {
|
|
|
415
415
|
}
|
|
416
416
|
```
|
|
417
417
|
|
|
418
|
-
> `useRuntimeConfig().private`
|
|
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
|
@@ -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', () => {
|
package/src/plugin/index.ts
CHANGED
|
@@ -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).
|