@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,190 @@
|
|
|
1
|
+
# Deep Linking (URL Params Sync)
|
|
2
|
+
|
|
3
|
+
> **Example:** [deep-link-page.vue](./examples/deep-link-page.vue)
|
|
4
|
+
|
|
5
|
+
Make filters bookmarkable/shareable by syncing them with URL query params.
|
|
6
|
+
|
|
7
|
+
## Pattern 1: `useRouteQuery` (Recommended)
|
|
8
|
+
|
|
9
|
+
From `@vueuse/router` (install: `npm install @vueuse/router`):
|
|
10
|
+
|
|
11
|
+
> **Important:** Pass Nuxt's route/router composables explicitly.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { useRouteQuery } from "@vueuse/router";
|
|
15
|
+
|
|
16
|
+
// Get Nuxt composables
|
|
17
|
+
const route = useRoute();
|
|
18
|
+
const router = useRouter();
|
|
19
|
+
|
|
20
|
+
// Each filter synced with URL
|
|
21
|
+
const search = useRouteQuery("search", "", { route, router });
|
|
22
|
+
const status = useRouteQuery("status", "all", { route, router });
|
|
23
|
+
const page = useRouteQuery("page", "1", {
|
|
24
|
+
route, router,
|
|
25
|
+
transform: Number, // Parse as number
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Debounce search to avoid URL thrashing
|
|
29
|
+
const debouncedSearch = refDebounced(search, 300);
|
|
30
|
+
|
|
31
|
+
// Build query for useFetch - exclude empty/default values
|
|
32
|
+
const queryParams = computed(() => ({
|
|
33
|
+
...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
|
|
34
|
+
...(status.value !== "all" ? { status: status.value } : {}),
|
|
35
|
+
offset: (page.value - 1) * 20,
|
|
36
|
+
limit: 20,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Auto-refetches when params change
|
|
40
|
+
const { data, status: fetchStatus } = await useFetch("/api/items", {
|
|
41
|
+
query: queryParams,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Reset pagination when filters change
|
|
45
|
+
watch([debouncedSearch, status], () => {
|
|
46
|
+
page.value = 1;
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Result:** URL like `/items?search=hello&status=active&page=2` loads with filters pre-applied.
|
|
51
|
+
|
|
52
|
+
## Pattern 2: Manual with useRoute/useRouter
|
|
53
|
+
|
|
54
|
+
For more control:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const route = useRoute();
|
|
58
|
+
const router = useRouter();
|
|
59
|
+
|
|
60
|
+
// Initialize from URL (works during SSR)
|
|
61
|
+
const search = ref((route.query.search as string) || "");
|
|
62
|
+
const category = ref((route.query.category as string) || "");
|
|
63
|
+
|
|
64
|
+
// Debounced URL update
|
|
65
|
+
const updateUrl = useDebounceFn(() => {
|
|
66
|
+
router.push({
|
|
67
|
+
query: {
|
|
68
|
+
...route.query, // Preserve other params
|
|
69
|
+
...(search.value ? { search: search.value } : {}),
|
|
70
|
+
...(category.value ? { category: category.value } : {}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}, 300);
|
|
74
|
+
|
|
75
|
+
watch([search, category], updateUrl);
|
|
76
|
+
|
|
77
|
+
// useFetch with same refs
|
|
78
|
+
const { data } = await useFetch("/api/items", {
|
|
79
|
+
query: computed(() => ({
|
|
80
|
+
...(search.value ? { search: search.value } : {}),
|
|
81
|
+
...(category.value ? { category: category.value } : {}),
|
|
82
|
+
})),
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Pattern 3: `useUrlSearchParams` (Low-Level)
|
|
87
|
+
|
|
88
|
+
Direct URL manipulation via VueUse:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const urlParams = useUrlSearchParams("history");
|
|
92
|
+
|
|
93
|
+
// Read
|
|
94
|
+
const search = urlParams["search"] as string || "";
|
|
95
|
+
|
|
96
|
+
// Write
|
|
97
|
+
urlParams["search"] = "new value";
|
|
98
|
+
|
|
99
|
+
// Delete (must use delete, not null)
|
|
100
|
+
delete urlParams["search"];
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Best for:** Complex serialization, arrays, nested objects.
|
|
104
|
+
|
|
105
|
+
## Array Values in URL
|
|
106
|
+
|
|
107
|
+
For multi-select filters:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// Serialize array to comma-separated
|
|
111
|
+
const selectedStatuses = useRouteQuery("status", "", {
|
|
112
|
+
route, router,
|
|
113
|
+
transform: (val) => val ? val.split(",") : [],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Manual approach
|
|
117
|
+
const statuses = ref<string[]>([]);
|
|
118
|
+
watch(statuses, (val) => {
|
|
119
|
+
urlParams["status"] = val.length ? val.join(",") : undefined;
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Complete Example
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { useRouteQuery } from "@vueuse/router";
|
|
127
|
+
|
|
128
|
+
const route = useRoute();
|
|
129
|
+
const router = useRouter();
|
|
130
|
+
|
|
131
|
+
// All filters bound to URL
|
|
132
|
+
const search = useRouteQuery("q", "", { route, router });
|
|
133
|
+
const status = useRouteQuery("status", "all", { route, router });
|
|
134
|
+
const sortBy = useRouteQuery("sort", "created_at", { route, router });
|
|
135
|
+
const sortOrder = useRouteQuery("order", "desc", { route, router });
|
|
136
|
+
const page = useRouteQuery("page", "1", { route, router, transform: Number });
|
|
137
|
+
const perPage = useRouteQuery("limit", "20", { route, router, transform: Number });
|
|
138
|
+
|
|
139
|
+
// Debounce search
|
|
140
|
+
const debouncedSearch = refDebounced(search, 300);
|
|
141
|
+
|
|
142
|
+
// Query params for API
|
|
143
|
+
const queryParams = computed(() => ({
|
|
144
|
+
...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
|
|
145
|
+
...(status.value !== "all" ? { status: status.value } : {}),
|
|
146
|
+
sort_by: sortBy.value,
|
|
147
|
+
sort_order: sortOrder.value,
|
|
148
|
+
offset: (page.value - 1) * perPage.value,
|
|
149
|
+
limit: perPage.value,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
// Fetch with reactive params
|
|
153
|
+
const { data, refresh } = await useFetch("/api/items", {
|
|
154
|
+
query: queryParams,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Reset pagination on filter change
|
|
158
|
+
watch([debouncedSearch, status, sortBy, sortOrder], () => {
|
|
159
|
+
page.value = 1;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Pagination helpers
|
|
163
|
+
const totalPages = computed(() =>
|
|
164
|
+
Math.ceil((data.value?.total || 0) / perPage.value)
|
|
165
|
+
);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## SSR Considerations
|
|
169
|
+
|
|
170
|
+
1. **`useRouteQuery` is SSR-safe** - reads from route during SSR
|
|
171
|
+
2. **`useRoute()` works during SSR** - can initialize from URL
|
|
172
|
+
3. **`useUrlSearchParams` is client-only** - guard with `import.meta.client`
|
|
173
|
+
4. **`router.push()` works during SSR** - but avoid at top-level setup
|
|
174
|
+
|
|
175
|
+
## Which Pattern?
|
|
176
|
+
|
|
177
|
+
| Pattern | Best For | SSR-Safe |
|
|
178
|
+
|---------|----------|----------|
|
|
179
|
+
| `useRouteQuery` | Most cases - bidirectional sync | Yes |
|
|
180
|
+
| `useRoute` + `router.push` | Custom serialization | Yes |
|
|
181
|
+
| `useUrlSearchParams` | Direct param manipulation | No |
|
|
182
|
+
|
|
183
|
+
## Key Gotchas
|
|
184
|
+
|
|
185
|
+
1. **Debounce search inputs** - otherwise URL updates every keystroke
|
|
186
|
+
2. **Reset pagination on filter change** - avoid empty page 5
|
|
187
|
+
3. **Exclude default values** - cleaner URLs
|
|
188
|
+
4. **Use `transform` for numbers** - URL params are strings
|
|
189
|
+
5. **Arrays need serialization** - comma-separated or custom
|
|
190
|
+
6. **Pass Nuxt composables** - VueUse router utils need route/router
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// server/middleware/auth.ts
|
|
2
|
+
// Server middleware for protecting API routes
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
// Skip auth for public routes
|
|
6
|
+
const publicPaths = ["/api/auth", "/api/_auth", "/api/public"];
|
|
7
|
+
if (publicPaths.some((path) => event.path.startsWith(path))) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Require auth for all /api/* routes
|
|
12
|
+
if (event.path.startsWith("/api")) {
|
|
13
|
+
const session = await getUserSession(event);
|
|
14
|
+
|
|
15
|
+
if (!session?.user) {
|
|
16
|
+
throw createError({
|
|
17
|
+
statusCode: 401,
|
|
18
|
+
statusMessage: "Unauthorized",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Role-based restrictions
|
|
23
|
+
if (event.path.startsWith("/api/admin")) {
|
|
24
|
+
if (session.user.role !== "admin") {
|
|
25
|
+
throw createError({
|
|
26
|
+
statusCode: 403,
|
|
27
|
+
statusMessage: "Forbidden - Admin access required",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// server/utils/auth.ts
|
|
2
|
+
// Reusable auth helpers (auto-imported in /server)
|
|
3
|
+
import type { H3Event } from "h3";
|
|
4
|
+
|
|
5
|
+
// Type for user in session
|
|
6
|
+
export interface SessionUser {
|
|
7
|
+
id: number;
|
|
8
|
+
email: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: "admin" | "user";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get authenticated user or throw 401
|
|
15
|
+
*/
|
|
16
|
+
export async function getAuthenticatedUser(event: H3Event): Promise<SessionUser> {
|
|
17
|
+
const session = await getUserSession(event);
|
|
18
|
+
if (!session?.user) {
|
|
19
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
20
|
+
}
|
|
21
|
+
return session.user as SessionUser;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Require specific role(s) or throw 403
|
|
26
|
+
*/
|
|
27
|
+
export async function requireRole(
|
|
28
|
+
event: H3Event,
|
|
29
|
+
allowedRoles: SessionUser["role"][]
|
|
30
|
+
): Promise<SessionUser> {
|
|
31
|
+
const user = await getAuthenticatedUser(event);
|
|
32
|
+
if (!allowedRoles.includes(user.role)) {
|
|
33
|
+
throw createError({
|
|
34
|
+
statusCode: 403,
|
|
35
|
+
statusMessage: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return user;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Shorthand for requiring admin role
|
|
43
|
+
*/
|
|
44
|
+
export async function requireAdmin(event: H3Event): Promise<SessionUser> {
|
|
45
|
+
return requireRole(event, ["admin"]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Usage in API handlers:
|
|
49
|
+
// const user = await getAuthenticatedUser(event);
|
|
50
|
+
// const admin = await requireAdmin(event);
|
|
51
|
+
// const manager = await requireRole(event, ["admin", "manager"]);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Deep Linking: URL params → Filter state → useFetch query
|
|
3
|
+
// URL: /items?search=hello&status=active&page=2
|
|
4
|
+
|
|
5
|
+
import { useRouteQuery } from "@vueuse/router";
|
|
6
|
+
|
|
7
|
+
// Get Nuxt composables (required for @vueuse/router)
|
|
8
|
+
const route = useRoute();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
|
|
11
|
+
// Each param synced with URL - pass route/router explicitly
|
|
12
|
+
const search = useRouteQuery("search", "", { route, router });
|
|
13
|
+
const status = useRouteQuery("status", "all", { route, router });
|
|
14
|
+
const page = useRouteQuery("page", "1", {
|
|
15
|
+
route,
|
|
16
|
+
router,
|
|
17
|
+
transform: Number,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Debounce search to avoid thrashing
|
|
21
|
+
const debouncedSearch = refDebounced(search, 300);
|
|
22
|
+
|
|
23
|
+
// Build query - exclude empty/default values for clean URLs
|
|
24
|
+
const queryParams = computed(() => ({
|
|
25
|
+
...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
|
|
26
|
+
...(status.value !== "all" ? { status: status.value } : {}),
|
|
27
|
+
offset: (page.value - 1) * 20,
|
|
28
|
+
limit: 20,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// useFetch with reactive query - auto-refetches on change
|
|
32
|
+
const { data, status: fetchStatus } = await useFetch("/api/items", {
|
|
33
|
+
query: queryParams,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Reset pagination when filters change
|
|
37
|
+
watch([debouncedSearch, status], () => {
|
|
38
|
+
page.value = 1;
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div>
|
|
44
|
+
<h1>Items</h1>
|
|
45
|
+
|
|
46
|
+
<div class="filters">
|
|
47
|
+
<input v-model="search" placeholder="Search..." />
|
|
48
|
+
<select v-model="status">
|
|
49
|
+
<option value="all">All</option>
|
|
50
|
+
<option value="active">Active</option>
|
|
51
|
+
<option value="inactive">Inactive</option>
|
|
52
|
+
</select>
|
|
53
|
+
<input v-model.number="page" type="number" min="1" />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div v-if="fetchStatus === 'pending'">Loading...</div>
|
|
57
|
+
<div v-else-if="data">
|
|
58
|
+
<pre>{{ data }}</pre>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// server/utils/stripe.ts
|
|
2
|
+
// Server-side service integration pattern using Stripe
|
|
3
|
+
|
|
4
|
+
import Stripe from "stripe";
|
|
5
|
+
|
|
6
|
+
// Initialize at module level with runtime config
|
|
7
|
+
const config = useRuntimeConfig();
|
|
8
|
+
const stripe = new Stripe(config.stripe.secretKey);
|
|
9
|
+
|
|
10
|
+
// Define typed methods
|
|
11
|
+
interface CreatePaymentIntentOptions {
|
|
12
|
+
amount: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
metadata?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function createPaymentIntent(options: CreatePaymentIntentOptions) {
|
|
18
|
+
try {
|
|
19
|
+
return await stripe.paymentIntents.create({
|
|
20
|
+
amount: options.amount,
|
|
21
|
+
currency: options.currency,
|
|
22
|
+
metadata: options.metadata,
|
|
23
|
+
});
|
|
24
|
+
} catch (error: any) {
|
|
25
|
+
// Transform SDK errors to HTTP errors
|
|
26
|
+
throw createError({
|
|
27
|
+
statusCode: error.statusCode || 500,
|
|
28
|
+
message: `Stripe error: ${error.message}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getCustomer(customerId: string) {
|
|
34
|
+
try {
|
|
35
|
+
return await stripe.customers.retrieve(customerId);
|
|
36
|
+
} catch (error: any) {
|
|
37
|
+
if (error.code === "resource_missing") {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
throw createError({
|
|
41
|
+
statusCode: error.statusCode || 500,
|
|
42
|
+
message: `Stripe error: ${error.message}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function createCustomer(email: string, name: string) {
|
|
48
|
+
return await stripe.customers.create({ email, name });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Export as use*() function
|
|
52
|
+
export function useStripe() {
|
|
53
|
+
return {
|
|
54
|
+
createPaymentIntent,
|
|
55
|
+
getCustomer,
|
|
56
|
+
createCustomer,
|
|
57
|
+
client: stripe, // Expose for advanced usage
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Usage in API handler:
|
|
62
|
+
// const { createPaymentIntent } = useStripe();
|
|
63
|
+
// const intent = await createPaymentIntent({ amount: 1000, currency: "usd" });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// server/api/stream/[id].get.ts
|
|
2
|
+
// Server-Sent Events endpoint for real-time streaming
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const { id } = getRouterParams(event);
|
|
6
|
+
|
|
7
|
+
// Create the event stream
|
|
8
|
+
const eventStream = createEventStream(event);
|
|
9
|
+
|
|
10
|
+
let done = false;
|
|
11
|
+
|
|
12
|
+
// Handle client disconnect
|
|
13
|
+
eventStream.onClosed(async () => {
|
|
14
|
+
console.log(`Stream ${id}: Client disconnected`);
|
|
15
|
+
done = true;
|
|
16
|
+
await eventStream.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Heartbeat to keep connection alive
|
|
20
|
+
const heartbeat = setInterval(async () => {
|
|
21
|
+
if (!done) {
|
|
22
|
+
await eventStream.push(JSON.stringify({ type: "heartbeat" }));
|
|
23
|
+
}
|
|
24
|
+
}, 30000);
|
|
25
|
+
|
|
26
|
+
// Stream data
|
|
27
|
+
(async () => {
|
|
28
|
+
try {
|
|
29
|
+
while (!done) {
|
|
30
|
+
// Get next chunk of data (your logic here)
|
|
31
|
+
const data = await getNextChunk(id);
|
|
32
|
+
|
|
33
|
+
if (data) {
|
|
34
|
+
await eventStream.push(JSON.stringify(data));
|
|
35
|
+
|
|
36
|
+
if (data.type === "done" || data.type === "error") {
|
|
37
|
+
done = true;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
// No data yet, wait before checking again
|
|
41
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
clearInterval(heartbeat);
|
|
46
|
+
await eventStream.close();
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
return eventStream.send();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Helper - replace with your data source
|
|
54
|
+
async function getNextChunk(id: string) {
|
|
55
|
+
// Example: fetch from Redis, database, or queue
|
|
56
|
+
// return { type: "chunk", text: "Hello" }
|
|
57
|
+
// return { type: "done" }
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// server/api/validation/users.get.ts
|
|
2
|
+
// API endpoint with Zod query validation
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const querySchema = z.object({
|
|
6
|
+
search: z.string().min(1).optional(),
|
|
7
|
+
page: z.coerce.number().int().positive().default(1),
|
|
8
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
9
|
+
status: z.enum(["active", "inactive", "all"]).default("all"),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
// h3 v2+ - pass schema directly (Standard Schema)
|
|
14
|
+
const query = await getValidatedQuery(event, querySchema);
|
|
15
|
+
|
|
16
|
+
// query is fully typed: { search?: string, page: number, limit: number, status: "active" | "inactive" | "all" }
|
|
17
|
+
|
|
18
|
+
// Use the validated params
|
|
19
|
+
const offset = (query.page - 1) * query.limit;
|
|
20
|
+
|
|
21
|
+
// Example: Build database query (pseudo-code)
|
|
22
|
+
// const users = await db.selectFrom("user")
|
|
23
|
+
// .where((eb) => {
|
|
24
|
+
// const filters = [];
|
|
25
|
+
// if (query.search) filters.push(eb("name", "ilike", `%${query.search}%`));
|
|
26
|
+
// if (query.status !== "all") filters.push(eb("status", "=", query.status));
|
|
27
|
+
// return eb.and(filters);
|
|
28
|
+
// })
|
|
29
|
+
// .limit(query.limit)
|
|
30
|
+
// .offset(offset)
|
|
31
|
+
// .execute();
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
message: "Query validated successfully",
|
|
35
|
+
query,
|
|
36
|
+
pagination: { page: query.page, limit: query.limit, offset },
|
|
37
|
+
};
|
|
38
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Fetch Patterns
|
|
2
|
+
|
|
3
|
+
## The Three Methods
|
|
4
|
+
|
|
5
|
+
| Method | SSR | When to Use |
|
|
6
|
+
|--------|-----|-------------|
|
|
7
|
+
| `useFetch` | Yes | Default for page data loading |
|
|
8
|
+
| `$fetch` | No | Event handlers (onClick, onSubmit) |
|
|
9
|
+
| `useAsyncData` + `$fetch` | Yes | Custom cache keys, combining fetches |
|
|
10
|
+
|
|
11
|
+
## useFetch (Default Choice)
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// Basic - types inferred from Nitro
|
|
15
|
+
const { data, status, refresh, error } = await useFetch("/api/users");
|
|
16
|
+
|
|
17
|
+
// With reactive query params - auto-refetches on change
|
|
18
|
+
const search = ref("");
|
|
19
|
+
const page = ref(1);
|
|
20
|
+
const { data } = await useFetch("/api/users", {
|
|
21
|
+
query: { search, page }, // Reactive refs
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Computed query for conditional params
|
|
25
|
+
const queryParams = computed(() => ({
|
|
26
|
+
...(search.value ? { search: search.value } : {}),
|
|
27
|
+
offset: page.value * 20,
|
|
28
|
+
}));
|
|
29
|
+
const { data } = await useFetch("/api/users", {
|
|
30
|
+
query: queryParams,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Dynamic URL with getter function
|
|
34
|
+
const userId = ref("123");
|
|
35
|
+
const { data } = await useFetch(() => `/api/users/${userId.value}`);
|
|
36
|
+
|
|
37
|
+
// Transform response before caching
|
|
38
|
+
const { data } = await useFetch("/api/users", {
|
|
39
|
+
transform: (response) => response.users.map(u => u.name),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Reduce SSR payload size
|
|
43
|
+
const { data } = await useFetch("/api/users", {
|
|
44
|
+
pick: ["id", "name"], // Only these fields
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### New Options (Nuxt 3.14+)
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
const { data } = await useFetch("/api/data", {
|
|
52
|
+
// Retry on failure
|
|
53
|
+
retry: 3,
|
|
54
|
+
retryDelay: 1000,
|
|
55
|
+
|
|
56
|
+
// Request deduplication
|
|
57
|
+
dedupe: "cancel", // Cancel previous (default)
|
|
58
|
+
// dedupe: "defer", // Wait for existing
|
|
59
|
+
|
|
60
|
+
// Built-in debounce
|
|
61
|
+
delay: 300, // Wait before making request
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Debounced Search Pattern
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const search = ref("");
|
|
69
|
+
const debouncedSearch = refDebounced(search, 300); // Auto-imported
|
|
70
|
+
|
|
71
|
+
const { data } = await useFetch("/api/search", {
|
|
72
|
+
query: computed(() => ({
|
|
73
|
+
...(debouncedSearch.value ? { q: debouncedSearch.value } : {}),
|
|
74
|
+
})),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Reset pagination when filters change
|
|
78
|
+
watch([debouncedSearch, categoryFilter], () => {
|
|
79
|
+
page.value = 0;
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## useAsyncData + $fetch
|
|
84
|
+
|
|
85
|
+
Use when you need:
|
|
86
|
+
1. Custom cache key
|
|
87
|
+
2. Combine multiple fetches
|
|
88
|
+
3. Non-HTTP async operations
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// Custom cache key
|
|
92
|
+
const { data } = await useAsyncData("my-key", () =>
|
|
93
|
+
$fetch("/api/users")
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Combining fetches
|
|
97
|
+
const { data } = await useAsyncData("combined", async () => {
|
|
98
|
+
const [users, roles] = await Promise.all([
|
|
99
|
+
$fetch("/api/users"),
|
|
100
|
+
$fetch("/api/roles"),
|
|
101
|
+
]);
|
|
102
|
+
return { users, roles };
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## $fetch (Client-Only)
|
|
107
|
+
|
|
108
|
+
Only use in event handlers - never at component top level:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
const handleSubmit = async () => {
|
|
112
|
+
const result = await $fetch("/api/users", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
body: { name: "Test" },
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleDelete = async (id: number) => {
|
|
119
|
+
await $fetch(`/api/users/${id}`, { method: "DELETE" });
|
|
120
|
+
refresh(); // Refresh useFetch data
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Type Inference
|
|
125
|
+
|
|
126
|
+
Template literals preserve type inference (fixed late 2024):
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const userId = "123"; // Type is "123" (literal)
|
|
130
|
+
const result = await $fetch(`/api/users/${userId}`);
|
|
131
|
+
// result typed from handler return type
|
|
132
|
+
|
|
133
|
+
// Generic string loses precision
|
|
134
|
+
const userId: string = "123"; // Type is string
|
|
135
|
+
const result = await $fetch(`/api/users/${userId}`);
|
|
136
|
+
// result is union of all matching routes
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Static route shadowing a dynamic sibling
|
|
140
|
+
|
|
141
|
+
Template-literal inference normally resolves a dynamic route fine. But adding a
|
|
142
|
+
**static** route under an existing `[param]` directory makes a template-literal
|
|
143
|
+
`$fetch` to the dynamic sibling ambiguous. E.g. adding
|
|
144
|
+
`server/api/invoices/summary.get.ts` next to `server/api/invoices/[id].get.ts`:
|
|
145
|
+
the typed-route map now matches both `/api/invoices/:id` and
|
|
146
|
+
`/api/invoices/summary`, so inference can't tell which `$fetch(\`/api/invoices/${id}\`)`
|
|
147
|
+
means — and typecheck fails on the existing callers.
|
|
148
|
+
|
|
149
|
+
Disambiguate by casting the URL to the specific route id (NOT the response
|
|
150
|
+
type — that still defeats response inference):
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// Ambiguous after adding the static `/api/invoices/summary` route:
|
|
154
|
+
const invoice = await $fetch(`/api/invoices/${id}`);
|
|
155
|
+
|
|
156
|
+
// RIGHT - name the route id; the response stays inferred
|
|
157
|
+
const invoice = await $fetch(`/api/invoices/${id}` as "/api/invoices/:id");
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Expect this side effect whenever you add a static endpoint beside a `[param]`
|
|
161
|
+
one — you'll need to touch the existing dynamic-route callers.
|
|
162
|
+
|
|
163
|
+
**Never add manual types:**
|
|
164
|
+
```typescript
|
|
165
|
+
// WRONG - defeats inference
|
|
166
|
+
const result = await $fetch<User>("/api/users/123");
|
|
167
|
+
|
|
168
|
+
// RIGHT - let Nitro infer
|
|
169
|
+
const result = await $fetch("/api/users/123");
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Common Mistakes
|
|
173
|
+
|
|
174
|
+
1. **Using `$fetch` in `onMounted`** - Use `useFetch` instead
|
|
175
|
+
2. **Manual watchers for refetch** - Query refs are auto-watched
|
|
176
|
+
3. **Adding type params** - Types are inferred from Nitro
|
|
177
|
+
4. **Using `watch` option for dynamic URLs** - Use getter function
|
|
178
|
+
5. **Passing null/undefined in query** - Filter them out first
|