@gallopsystems/agent-skills 1.0.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/README.md +137 -0
- package/package.json +26 -0
- package/plugins/doctl/.claude-plugin/plugin.json +8 -0
- package/plugins/doctl/skills/doctl/SKILL.md +93 -0
- package/plugins/kysely-postgres/.claude-plugin/plugin.json +8 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +1101 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +167 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +165 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +272 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +206 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/json-arrays.ts +398 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/mutations.ts +199 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/orderby-pagination.ts +117 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/relations.ts +176 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +146 -0
- package/plugins/linear/.claude-plugin/plugin.json +8 -0
- package/plugins/linear/skills/linear/SKILL.md +1040 -0
- package/plugins/linear/skills/linear/bin/linear.mjs +1228 -0
- package/plugins/linear/skills/linear/tech-stack.md +273 -0
- package/plugins/nitro-testing/.claude-plugin/plugin.json +8 -0
- package/plugins/nitro-testing/skills/nitro-testing/SKILL.md +497 -0
- package/plugins/nitro-testing/skills/nitro-testing/async-testing.md +270 -0
- package/plugins/nitro-testing/skills/nitro-testing/ci-setup.md +226 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/global-setup.ts +90 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/handler.test.ts +167 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/setup.ts +29 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/test-utils-index.ts +297 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/vitest.config.ts +42 -0
- package/plugins/nitro-testing/skills/nitro-testing/factories.md +278 -0
- package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +512 -0
- package/plugins/nitro-testing/skills/nitro-testing/test-utils.md +262 -0
- package/plugins/nitro-testing/skills/nitro-testing/transaction-rollback.md +183 -0
- package/plugins/nitro-testing/skills/nitro-testing/vitest-config.md +236 -0
- package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +8 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +260 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +228 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +174 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/deep-linking.md +190 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-middleware.ts +32 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-utils.ts +51 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/deep-link-page.vue +61 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/service-util.ts +63 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/sse-endpoint.ts +59 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/validation-endpoint.ts +38 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +178 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +243 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +162 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-services.md +238 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/sse.md +221 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +166 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +131 -0
- package/scripts/link-skills.mjs +252 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Server-Sent Events (SSE)
|
|
2
|
+
|
|
3
|
+
> **Example:** [sse-endpoint.ts](./examples/sse-endpoint.ts)
|
|
4
|
+
|
|
5
|
+
Real-time streaming without WebSockets. Good for long-running operations, AI streaming, job progress.
|
|
6
|
+
|
|
7
|
+
## Server-Side (Nitro)
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// server/api/stream/[id].get.ts
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
const { id } = getRouterParams(event);
|
|
13
|
+
|
|
14
|
+
// Create the event stream
|
|
15
|
+
const eventStream = createEventStream(event);
|
|
16
|
+
|
|
17
|
+
let done = false;
|
|
18
|
+
|
|
19
|
+
// Handle client disconnect
|
|
20
|
+
eventStream.onClosed(async () => {
|
|
21
|
+
console.log("Client disconnected");
|
|
22
|
+
done = true;
|
|
23
|
+
await eventStream.close();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Async loop to push events
|
|
27
|
+
(async () => {
|
|
28
|
+
while (!done) {
|
|
29
|
+
const data = await getNextChunk(id);
|
|
30
|
+
|
|
31
|
+
if (data) {
|
|
32
|
+
await eventStream.push(JSON.stringify(data));
|
|
33
|
+
|
|
34
|
+
if (data.type === "done" || data.type === "error") {
|
|
35
|
+
done = true;
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await eventStream.close();
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
return eventStream.send();
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Heartbeat Pattern
|
|
50
|
+
|
|
51
|
+
Keep connections alive:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
const heartbeatInterval = setInterval(async () => {
|
|
55
|
+
await eventStream.push(JSON.stringify({ type: "heartbeat" }));
|
|
56
|
+
}, 30000);
|
|
57
|
+
|
|
58
|
+
eventStream.onClosed(() => {
|
|
59
|
+
clearInterval(heartbeatInterval);
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Client-Side Option 1: VueUse (Recommended)
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { useEventSource } from "@vueuse/core";
|
|
67
|
+
|
|
68
|
+
const { status, data, error, close } = useEventSource(
|
|
69
|
+
`/api/stream/${sessionId}`,
|
|
70
|
+
[], // Event names (empty = default "message")
|
|
71
|
+
{
|
|
72
|
+
autoReconnect: {
|
|
73
|
+
retries: 3,
|
|
74
|
+
delay: 1000,
|
|
75
|
+
onFailed() {
|
|
76
|
+
console.error("Failed to reconnect");
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
watch(data, (newData) => {
|
|
83
|
+
if (newData) {
|
|
84
|
+
const parsed = JSON.parse(newData);
|
|
85
|
+
// Handle the event...
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
onUnmounted(close);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Client-Side Option 2: Custom Composable
|
|
93
|
+
|
|
94
|
+
For more control:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// composables/useSSE.ts
|
|
98
|
+
export function useSSE() {
|
|
99
|
+
const eventSource = ref<EventSource | null>(null);
|
|
100
|
+
const data = ref<any>(null);
|
|
101
|
+
const error = ref<string | null>(null);
|
|
102
|
+
const status = ref<"connecting" | "connected" | "closed">("connecting");
|
|
103
|
+
|
|
104
|
+
const connect = (url: string) => {
|
|
105
|
+
stop();
|
|
106
|
+
|
|
107
|
+
eventSource.value = new EventSource(url);
|
|
108
|
+
|
|
109
|
+
eventSource.value.onopen = () => {
|
|
110
|
+
status.value = "connected";
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
eventSource.value.onmessage = (event) => {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(event.data);
|
|
116
|
+
data.value = parsed;
|
|
117
|
+
|
|
118
|
+
if (parsed.type === "done" || parsed.type === "error") {
|
|
119
|
+
stop();
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error("Parse error:", e);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
eventSource.value.onerror = () => {
|
|
127
|
+
error.value = "Connection error";
|
|
128
|
+
status.value = "closed";
|
|
129
|
+
|
|
130
|
+
// Auto-reconnect
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
if (status.value === "closed") {
|
|
133
|
+
connect(url);
|
|
134
|
+
}
|
|
135
|
+
}, 2000);
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const stop = () => {
|
|
140
|
+
if (eventSource.value) {
|
|
141
|
+
eventSource.value.close();
|
|
142
|
+
eventSource.value = null;
|
|
143
|
+
}
|
|
144
|
+
status.value = "closed";
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
onUnmounted(stop);
|
|
148
|
+
|
|
149
|
+
return { connect, stop, data, error, status };
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Usage in Component
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
const { connect, stop, data, status } = useSSE();
|
|
157
|
+
|
|
158
|
+
const startAnalysis = async () => {
|
|
159
|
+
const { sessionId } = await $fetch("/api/analysis/start", { method: "POST" });
|
|
160
|
+
connect(`/api/analysis/${sessionId}/stream`);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
watch(data, (newData) => {
|
|
164
|
+
if (newData?.type === "chunk") {
|
|
165
|
+
output.value += newData.text;
|
|
166
|
+
} else if (newData?.type === "done") {
|
|
167
|
+
isComplete.value = true;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
onUnmounted(stop);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Position-Based Resumption
|
|
175
|
+
|
|
176
|
+
Resume from where client left off:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// Client tracks position
|
|
180
|
+
const position = ref(0);
|
|
181
|
+
|
|
182
|
+
eventSource.value.onmessage = (event) => {
|
|
183
|
+
position.value++;
|
|
184
|
+
// handle data...
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const reconnect = () => {
|
|
188
|
+
connect(`/api/stream/${id}?position=${position.value}`);
|
|
189
|
+
};
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// Server reads position
|
|
194
|
+
const { position } = await getValidatedQuery(event, schema);
|
|
195
|
+
const chunks = await getChunksFromPosition(id, position);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Fallback to Polling
|
|
199
|
+
|
|
200
|
+
When SSE isn't available:
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// Server returns non-SSE response
|
|
204
|
+
if (!redisAvailable) {
|
|
205
|
+
return { type: "pending", message: "Use polling" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Client detects and falls back
|
|
209
|
+
if (data.value?.type === "pending") {
|
|
210
|
+
stopSSE();
|
|
211
|
+
startPolling();
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Key Gotchas
|
|
216
|
+
|
|
217
|
+
1. **Always clean up** - Call `eventSource.close()` on unmount
|
|
218
|
+
2. **Parse JSON** - SSE data is always strings
|
|
219
|
+
3. **Handle reconnection** - Connections drop, plan for it
|
|
220
|
+
4. **Timeouts** - Long streams need heartbeats
|
|
221
|
+
5. **No binary data** - SSE is text-only, use base64 if needed
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# SSR + Client-side Patterns
|
|
2
|
+
|
|
3
|
+
## The Problem
|
|
4
|
+
|
|
5
|
+
`localStorage` and other browser APIs don't exist on the server. Accessing them during SSR causes errors or hydration mismatches.
|
|
6
|
+
|
|
7
|
+
## Solutions
|
|
8
|
+
|
|
9
|
+
### 1. `<ClientOnly>` Component
|
|
10
|
+
|
|
11
|
+
Wrap components that need browser APIs:
|
|
12
|
+
|
|
13
|
+
```vue
|
|
14
|
+
<ClientOnly>
|
|
15
|
+
<DataTable :value="items" />
|
|
16
|
+
|
|
17
|
+
<template #fallback>
|
|
18
|
+
<div>Loading table...</div>
|
|
19
|
+
</template>
|
|
20
|
+
</ClientOnly>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Use for:**
|
|
24
|
+
- Complex interactive components (DataTables, Maps, Charts)
|
|
25
|
+
- Components using DOM APIs
|
|
26
|
+
- Third-party components without SSR support
|
|
27
|
+
|
|
28
|
+
### 2. `import.meta.client` Guard
|
|
29
|
+
|
|
30
|
+
Check runtime environment before using browser APIs:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
watch(viewMode, (newMode) => {
|
|
34
|
+
if (import.meta.client) {
|
|
35
|
+
localStorage.setItem("view-mode", newMode);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const savePreference = (key: string, value: string) => {
|
|
40
|
+
if (import.meta.client) {
|
|
41
|
+
localStorage.setItem(key, value);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Also available: `import.meta.server` for server-only code.
|
|
47
|
+
|
|
48
|
+
### 3. `onMounted` for Client Initialization
|
|
49
|
+
|
|
50
|
+
Read from localStorage only after hydration:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
const viewMode = ref("table"); // Default for SSR
|
|
54
|
+
const isReady = ref(false);
|
|
55
|
+
|
|
56
|
+
onMounted(() => {
|
|
57
|
+
const saved = localStorage.getItem("view-mode");
|
|
58
|
+
if (saved === "table" || saved === "kanban") {
|
|
59
|
+
viewMode.value = saved;
|
|
60
|
+
}
|
|
61
|
+
isReady.value = true;
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Pattern for URL params + localStorage fallback:**
|
|
66
|
+
```typescript
|
|
67
|
+
onMounted(() => {
|
|
68
|
+
const queryTab = route.query.tab as string;
|
|
69
|
+
|
|
70
|
+
if (queryTab && validTabs.includes(queryTab)) {
|
|
71
|
+
activeTab.value = queryTab;
|
|
72
|
+
} else {
|
|
73
|
+
const savedTab = localStorage.getItem("last-tab");
|
|
74
|
+
if (savedTab && validTabs.includes(savedTab)) {
|
|
75
|
+
activeTab.value = savedTab;
|
|
76
|
+
router.replace({ query: { tab: savedTab } });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 4. VueUse `useLocalStorage` (SSR-Safe)
|
|
83
|
+
|
|
84
|
+
Automatically handles SSR - reads on client after hydration:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Returns default during SSR, actual value on client
|
|
88
|
+
const theme = useLocalStorage("theme", "light");
|
|
89
|
+
const settings = useLocalStorage("settings", { compact: false });
|
|
90
|
+
|
|
91
|
+
// Use normally - syncs automatically
|
|
92
|
+
theme.value = "dark";
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For delayed initialization to avoid hydration issues:
|
|
96
|
+
```typescript
|
|
97
|
+
const theme = useLocalStorage("theme", "light", {
|
|
98
|
+
initOnMounted: true, // Don't read until mounted
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## VueUse SSR Notes
|
|
103
|
+
|
|
104
|
+
With `@vueuse/nuxt`, these are auto-imported:
|
|
105
|
+
- `refDebounced` - Yes, auto-imported
|
|
106
|
+
- `useDebounceFn` - Yes
|
|
107
|
+
- `useLocalStorage` - Yes
|
|
108
|
+
- `useUrlSearchParams` - Yes
|
|
109
|
+
|
|
110
|
+
**Disabled by default** (conflict with Nuxt):
|
|
111
|
+
- `useRoute` - use Nuxt's version
|
|
112
|
+
- `useRouter` - use Nuxt's version
|
|
113
|
+
- `useFetch` - use Nuxt's version
|
|
114
|
+
- `useHead` - use Nuxt's version
|
|
115
|
+
|
|
116
|
+
## Hydration Mismatch Prevention
|
|
117
|
+
|
|
118
|
+
**Problem:** Server renders with default, client reads different value = mismatch.
|
|
119
|
+
|
|
120
|
+
**Solutions:**
|
|
121
|
+
|
|
122
|
+
1. **Don't render during SSR:**
|
|
123
|
+
```vue
|
|
124
|
+
<ClientOnly>
|
|
125
|
+
<span>{{ preference }}</span>
|
|
126
|
+
</ClientOnly>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
2. **Use a ready flag:**
|
|
130
|
+
```typescript
|
|
131
|
+
const preference = ref("default");
|
|
132
|
+
const ready = ref(false);
|
|
133
|
+
|
|
134
|
+
onMounted(() => {
|
|
135
|
+
preference.value = localStorage.getItem("pref") || "default";
|
|
136
|
+
ready.value = true;
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
```vue
|
|
140
|
+
<span v-if="ready">{{ preference }}</span>
|
|
141
|
+
<span v-else>Loading...</span>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
3. **Use `useLocalStorage` with matching initial:**
|
|
145
|
+
```typescript
|
|
146
|
+
const count = useLocalStorage("count", 0);
|
|
147
|
+
// Initial matches SSR, updates after hydration
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Summary Table
|
|
151
|
+
|
|
152
|
+
| Approach | When to Use | SSR-Safe |
|
|
153
|
+
|----------|-------------|----------|
|
|
154
|
+
| `<ClientOnly>` | Entire component needs browser | Yes |
|
|
155
|
+
| `import.meta.client` | Conditional browser API calls | Yes |
|
|
156
|
+
| `onMounted` | Initialize from localStorage | Yes |
|
|
157
|
+
| `useLocalStorage` | Reactive persistent state | Yes |
|
|
158
|
+
| Direct `localStorage` | Never at top level | No |
|
|
159
|
+
|
|
160
|
+
## Key Gotchas
|
|
161
|
+
|
|
162
|
+
1. **Never access `localStorage` at module top-level**
|
|
163
|
+
2. **`useLocalStorage` returns default during SSR**
|
|
164
|
+
3. **URL query params are SSR-safe** - can read via `useRoute()`
|
|
165
|
+
4. **Watch handlers run during SSR** - always guard with `import.meta.client`
|
|
166
|
+
5. **`onMounted` never runs on server** - safe for all browser APIs
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Validation Patterns
|
|
2
|
+
|
|
3
|
+
> **Example:** [validation-endpoint.ts](./examples/validation-endpoint.ts)
|
|
4
|
+
|
|
5
|
+
## Available Utilities (all auto-imported from h3)
|
|
6
|
+
|
|
7
|
+
| Raw | Validated |
|
|
8
|
+
|-----|-----------|
|
|
9
|
+
| `readBody(event)` | `readValidatedBody(event, validator)` |
|
|
10
|
+
| `getQuery(event)` | `getValidatedQuery(event, validator)` |
|
|
11
|
+
| `getRouterParams(event)` | `getValidatedRouterParams(event, validator)` |
|
|
12
|
+
|
|
13
|
+
Note: It's `getRouterParams` (plural), not `getRouterParam`.
|
|
14
|
+
|
|
15
|
+
## Pattern 1: Direct Schema (h3 v2+ with Standard Schema)
|
|
16
|
+
|
|
17
|
+
h3 v2+ supports Standard Schema, meaning you can pass Zod schemas directly:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
const querySchema = z.object({
|
|
21
|
+
search: z.string().min(1),
|
|
22
|
+
page: z.coerce.number().default(1),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Pass schema directly (recommended)
|
|
26
|
+
const query = await getValidatedQuery(event, querySchema);
|
|
27
|
+
|
|
28
|
+
// Also works for body and params
|
|
29
|
+
const body = await readValidatedBody(event, bodySchema);
|
|
30
|
+
const params = await getValidatedRouterParams(event, paramsSchema);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Pros:** Simplest syntax, cleaner code
|
|
34
|
+
**Cons:** ZodError thrown directly - not user-friendly
|
|
35
|
+
|
|
36
|
+
## Pattern 2: Manual Validator Function
|
|
37
|
+
|
|
38
|
+
For custom validation logic:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
const query = await getValidatedQuery(event, (data) => querySchema.parse(data));
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Pattern 3: safeParse for Better Errors
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { fromZodError } from "zod-validation-error";
|
|
48
|
+
|
|
49
|
+
const rawQuery = getQuery(event);
|
|
50
|
+
const result = querySchema.safeParse(rawQuery);
|
|
51
|
+
|
|
52
|
+
if (!result.success) {
|
|
53
|
+
console.error("Validation error:", result.error); // Dev log
|
|
54
|
+
const userError = fromZodError(result.error); // User-friendly
|
|
55
|
+
throw createError({
|
|
56
|
+
statusCode: 400,
|
|
57
|
+
statusMessage: "Bad Request",
|
|
58
|
+
message: userError.message,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result.data;
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Common Zod Patterns
|
|
66
|
+
|
|
67
|
+
### Query Parameters
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const querySchema = z.object({
|
|
71
|
+
// Optional string
|
|
72
|
+
search: z.string().optional(),
|
|
73
|
+
|
|
74
|
+
// Coerce to number (query params are strings)
|
|
75
|
+
page: z.coerce.number().default(1),
|
|
76
|
+
limit: z.coerce.number().max(100).default(20),
|
|
77
|
+
|
|
78
|
+
// Boolean from string
|
|
79
|
+
active: z.enum(["true", "false"]).transform(v => v === "true").optional(),
|
|
80
|
+
|
|
81
|
+
// Enum
|
|
82
|
+
status: z.enum(["pending", "active", "closed"]).optional(),
|
|
83
|
+
|
|
84
|
+
// Array from comma-separated
|
|
85
|
+
tags: z.string().transform(s => s.split(",")).optional(),
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Request Body
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const createUserSchema = z.object({
|
|
93
|
+
email: z.string().email(),
|
|
94
|
+
name: z.string().min(1).max(100),
|
|
95
|
+
role: z.enum(["admin", "user"]).default("user"),
|
|
96
|
+
metadata: z.record(z.string(), z.any()).optional(),
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Path Parameters
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
const paramsSchema = z.object({
|
|
104
|
+
id: z.coerce.number().positive(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// In /api/users/[id].get.ts
|
|
108
|
+
const { id } = await getValidatedRouterParams(event, paramsSchema);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Type Inference from Schemas
|
|
112
|
+
|
|
113
|
+
Export schemas for client-side type reuse:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// types/api.ts
|
|
117
|
+
import { z } from "zod";
|
|
118
|
+
|
|
119
|
+
export const CreateUserSchema = z.object({
|
|
120
|
+
email: z.string().email(),
|
|
121
|
+
name: z.string().min(1),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
|
125
|
+
|
|
126
|
+
// Client usage
|
|
127
|
+
import type { CreateUserInput } from "~/types/api";
|
|
128
|
+
const body: CreateUserInput = { email: "test@example.com", name: "Test" };
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Note:** Nitro auto-generates response types, but NOT input types from Zod schemas.
|