@lastshotlabs/snapshot 0.1.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 +1598 -0
- package/dist/cli.js +4529 -0
- package/dist/index.cjs +1135 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +396 -0
- package/dist/index.d.ts +396 -0
- package/dist/index.js +1106 -0
- package/dist/index.js.map +1 -0
- package/dist/vite.cjs +1186 -0
- package/dist/vite.d.cts +18 -0
- package/dist/vite.d.ts +18 -0
- package/dist/vite.js +1174 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,1598 @@
|
|
|
1
|
+
# @lastshotlabs/snapshot
|
|
2
|
+
|
|
3
|
+
React frontend framework for [bunshot](https://github.com/lastshotlabs/bunshot)-powered backends.
|
|
4
|
+
|
|
5
|
+
Provides auth, API client, WebSocket, routing guards, and theme — all wired via a single factory call.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @lastshotlabs/snapshot
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Peer dependencies** (install separately):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add react react-dom @tanstack/react-router @tanstack/react-query jotai @unhead/react
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Scaffolding
|
|
24
|
+
|
|
25
|
+
The fastest way to start is with the scaffold CLI — it generates a complete Vite + TanStack Router + shadcn app pre-wired to snapshot.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bunx @lastshotlabs/snapshot init "My App"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**With a custom output directory:**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bunx @lastshotlabs/snapshot init "My App" my-app-dir
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Skip all prompts and accept defaults:**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bunx @lastshotlabs/snapshot init "My App" --yes
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Prompts
|
|
44
|
+
|
|
45
|
+
| Prompt | Options | Default |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Project name | free text | — |
|
|
48
|
+
| Package name | free text | derived from project name |
|
|
49
|
+
| Security profile | `hardened` · `prototype` | `hardened` |
|
|
50
|
+
| Layout | `minimal` · `top-nav` · `sidebar` | `top-nav` |
|
|
51
|
+
| Theme | `default` · `dark` · `minimal` · `vibrant` | `default` |
|
|
52
|
+
| Auth pages | yes · no | yes |
|
|
53
|
+
| MFA pages | yes · no (shown if auth pages: yes) | no |
|
|
54
|
+
| Passkey pages | yes · no (shown if auth pages: yes) | no |
|
|
55
|
+
| shadcn components | multi-select | recommended set |
|
|
56
|
+
| WebSocket support | yes · no | yes |
|
|
57
|
+
| Git init | yes · no | yes |
|
|
58
|
+
|
|
59
|
+
### What gets generated
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
my-app/
|
|
63
|
+
src/
|
|
64
|
+
routes/
|
|
65
|
+
__root.tsx
|
|
66
|
+
_authenticated.tsx
|
|
67
|
+
_authenticated/index.tsx
|
|
68
|
+
_authenticated/mfa-setup.tsx ← (if MFA pages: yes)
|
|
69
|
+
_authenticated/passkey.tsx ← (if passkey pages: yes)
|
|
70
|
+
_authenticated/settings/
|
|
71
|
+
index.tsx ← (if auth pages: yes)
|
|
72
|
+
password.tsx ← (if auth pages: yes)
|
|
73
|
+
sessions.tsx ← (if auth pages: yes)
|
|
74
|
+
delete-account.tsx ← (if auth pages: yes)
|
|
75
|
+
email-otp.tsx ← (if auth pages + mfa pages: yes)
|
|
76
|
+
_guest.tsx
|
|
77
|
+
_guest/auth/ ← login, register, forgot-password,
|
|
78
|
+
reset-password, verify-email,
|
|
79
|
+
oauth/callback (if auth pages: yes)
|
|
80
|
+
mfa-verify (if MFA pages: yes)
|
|
81
|
+
pages/
|
|
82
|
+
auth/ ← LoginPage, RegisterPage, ForgotPasswordPage,
|
|
83
|
+
ResetPasswordPage, VerifyEmailPage,
|
|
84
|
+
OAuthCallbackPage (if auth pages: yes)
|
|
85
|
+
MfaVerifyPage, MfaSetupPage (if MFA pages: yes)
|
|
86
|
+
PasskeyManagePage.tsx ← (if passkey pages: yes)
|
|
87
|
+
settings/
|
|
88
|
+
SettingsPage.tsx ← (if auth pages: yes)
|
|
89
|
+
SettingsPasswordPage.tsx ← (if auth pages: yes)
|
|
90
|
+
SettingsSessionsPage.tsx ← (if auth pages: yes)
|
|
91
|
+
SettingsDeleteAccountPage.tsx ← (if auth pages: yes)
|
|
92
|
+
SettingsEmailOtpPage.tsx ← (if auth pages + mfa pages: yes)
|
|
93
|
+
components/
|
|
94
|
+
layout/ ← RootLayout, AuthLayout, shared components (layout-specific)
|
|
95
|
+
ui/ ← shadcn components
|
|
96
|
+
shared/
|
|
97
|
+
api/ ← plain async functions (populated by snapshot sync)
|
|
98
|
+
hooks/ ← custom hooks (your code)
|
|
99
|
+
api/ ← generated TanStack Query hooks (snapshot sync)
|
|
100
|
+
lib/
|
|
101
|
+
snapshot.ts ← createSnapshot() call, all hooks exported
|
|
102
|
+
router.ts
|
|
103
|
+
utils.ts
|
|
104
|
+
store/ui.ts
|
|
105
|
+
styles/globals.css ← theme-specific CSS variables
|
|
106
|
+
types/api.ts ← generated types (snapshot sync)
|
|
107
|
+
main.tsx
|
|
108
|
+
public/
|
|
109
|
+
vite.svg
|
|
110
|
+
vite.config.ts
|
|
111
|
+
tsconfig.json ← project references root
|
|
112
|
+
tsconfig.app.json ← app compiler options + path aliases
|
|
113
|
+
tsconfig.node.json ← vite.config.ts compiler options
|
|
114
|
+
snapshot.config.json ← sync output directories (edit to customise)
|
|
115
|
+
components.json
|
|
116
|
+
package.json
|
|
117
|
+
index.html
|
|
118
|
+
.env
|
|
119
|
+
.gitignore ← includes routeTree.gen.ts
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
> **Note:** `routeTree.gen.ts` is auto-generated by TanStack Router on the first `bun dev` run. TypeScript will show an error for it until you start the dev server once.
|
|
123
|
+
|
|
124
|
+
### Layouts
|
|
125
|
+
|
|
126
|
+
- **Minimal** — bare `div` wrapper, no navigation
|
|
127
|
+
- **Top nav** — header with app name, theme toggle, sign in/out
|
|
128
|
+
- **Sidebar** — collapsible sidebar (mobile overlay + desktop fixed), top bar with hamburger
|
|
129
|
+
|
|
130
|
+
### Themes
|
|
131
|
+
|
|
132
|
+
All themes include both `:root` (light) and `.dark` variable sets — dark mode always works regardless of theme.
|
|
133
|
+
|
|
134
|
+
- **Default** — shadcn neutral palette, light mode default
|
|
135
|
+
- **Dark** — same palette, dark mode default (seeds `localStorage` on first visit to prevent FOUC)
|
|
136
|
+
- **Minimal** — reduced border radius, muted/low-contrast palette
|
|
137
|
+
- **Vibrant** — saturated violet/indigo palette, higher contrast
|
|
138
|
+
|
|
139
|
+
### After scaffolding
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
cd my-app
|
|
143
|
+
|
|
144
|
+
# Fill in .env:
|
|
145
|
+
# VITE_API_URL — your bunshot backend URL
|
|
146
|
+
# VITE_WS_URL — your WebSocket URL (if WS enabled)
|
|
147
|
+
|
|
148
|
+
bun dev # start the dev server (also generates routeTree.gen.ts)
|
|
149
|
+
bun run sync # generate src/api/, src/hooks/api/, src/types/api.ts from your backend
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`snapshot.config.json` is pre-generated with the default output paths. Edit it if you need to rename directories or point sync at a different backend.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Quick Start
|
|
157
|
+
|
|
158
|
+
### 1. Create the snapshot instance
|
|
159
|
+
|
|
160
|
+
> **Note:** The examples below use `@lib/snapshot` — a path alias pointing to `src/lib/snapshot.ts`. All aliases are configured in `tsconfig.app.json` and `vite.config.ts` in the generated scaffold. Available aliases: `@` → `src`, `@lib`, `@components`, `@hooks`, `@api`, `@store`, `@styles`, `@types`. All hooks and primitives flow through `@lib/snapshot`, not through direct package imports.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// src/lib/snapshot.ts
|
|
164
|
+
import { createSnapshot } from '@lastshotlabs/snapshot'
|
|
165
|
+
|
|
166
|
+
export const snapshot = createSnapshot({
|
|
167
|
+
apiUrl: import.meta.env.VITE_API_URL,
|
|
168
|
+
loginPath: '/login',
|
|
169
|
+
homePath: '/dashboard',
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
export const {
|
|
173
|
+
// Core auth
|
|
174
|
+
useUser,
|
|
175
|
+
useLogin,
|
|
176
|
+
useLogout,
|
|
177
|
+
useRegister,
|
|
178
|
+
useForgotPassword,
|
|
179
|
+
// Account management
|
|
180
|
+
useSetPassword,
|
|
181
|
+
useDeleteAccount,
|
|
182
|
+
useCancelDeletion,
|
|
183
|
+
useRefreshToken,
|
|
184
|
+
useSessions,
|
|
185
|
+
useRevokeSession,
|
|
186
|
+
useResetPassword,
|
|
187
|
+
useVerifyEmail,
|
|
188
|
+
useResendVerification,
|
|
189
|
+
// OAuth
|
|
190
|
+
getOAuthUrl,
|
|
191
|
+
getLinkUrl,
|
|
192
|
+
useOAuthExchange,
|
|
193
|
+
useOAuthUnlink,
|
|
194
|
+
// MFA (opt-in — only needed if your bunshot backend has MFA enabled)
|
|
195
|
+
useMfaVerify,
|
|
196
|
+
useMfaSetup,
|
|
197
|
+
useMfaVerifySetup,
|
|
198
|
+
useMfaDisable,
|
|
199
|
+
useMfaRecoveryCodes,
|
|
200
|
+
useMfaResend,
|
|
201
|
+
useMfaMethods,
|
|
202
|
+
usePendingMfaChallenge,
|
|
203
|
+
isMfaChallenge,
|
|
204
|
+
// WebAuthn (opt-in — only needed if bunshot has webauthn MFA enabled)
|
|
205
|
+
useWebAuthnRegisterOptions,
|
|
206
|
+
useWebAuthnRegister,
|
|
207
|
+
useWebAuthnCredentials,
|
|
208
|
+
useWebAuthnRemoveCredential,
|
|
209
|
+
useWebAuthnDisable,
|
|
210
|
+
// Passkey login (opt-in — only when allowPasswordlessLogin: true on server)
|
|
211
|
+
usePasskeyLoginOptions,
|
|
212
|
+
usePasskeyLogin,
|
|
213
|
+
// WebSocket
|
|
214
|
+
useSocket,
|
|
215
|
+
useRoom,
|
|
216
|
+
useRoomEvent,
|
|
217
|
+
useWebSocketManager,
|
|
218
|
+
// UI / routing
|
|
219
|
+
useTheme,
|
|
220
|
+
protectedBeforeLoad,
|
|
221
|
+
guestBeforeLoad,
|
|
222
|
+
QueryProvider,
|
|
223
|
+
// Primitives
|
|
224
|
+
api,
|
|
225
|
+
queryClient,
|
|
226
|
+
tokenStorage,
|
|
227
|
+
} = snapshot
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### 2. Set up the router
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// src/lib/router.ts
|
|
234
|
+
import { createRouter } from '@tanstack/react-router'
|
|
235
|
+
import { routeTree } from '../routeTree.gen'
|
|
236
|
+
import { snapshot } from './snapshot'
|
|
237
|
+
|
|
238
|
+
export const router = createRouter({
|
|
239
|
+
routeTree,
|
|
240
|
+
context: { queryClient: snapshot.queryClient },
|
|
241
|
+
defaultPreload: 'intent',
|
|
242
|
+
defaultPreloadStaleTime: 0,
|
|
243
|
+
scrollRestoration: true,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
declare module '@tanstack/react-router' {
|
|
247
|
+
interface Register {
|
|
248
|
+
router: typeof router
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### 3. Wire up providers in main.tsx
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
// src/main.tsx
|
|
257
|
+
import { StrictMode } from 'react'
|
|
258
|
+
import { createRoot } from 'react-dom/client'
|
|
259
|
+
import { RouterProvider } from '@tanstack/react-router'
|
|
260
|
+
import { QueryProvider } from '@lib/snapshot'
|
|
261
|
+
import { router } from '@lib/router'
|
|
262
|
+
|
|
263
|
+
createRoot(document.getElementById('root')!).render(
|
|
264
|
+
<StrictMode>
|
|
265
|
+
<QueryProvider>
|
|
266
|
+
<RouterProvider router={router} />
|
|
267
|
+
</QueryProvider>
|
|
268
|
+
</StrictMode>
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### 4. Set up the root route (manual setup only)
|
|
273
|
+
|
|
274
|
+
> **Using the scaffold?** `__root.tsx`, `_authenticated.tsx`, `_guest.tsx`, and all layout components are pre-generated. Skip to step 5.
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
// src/routes/__root.tsx
|
|
280
|
+
import { createRootRouteWithContext } from '@tanstack/react-router'
|
|
281
|
+
import { HeadProvider } from '@unhead/react'
|
|
282
|
+
import { Outlet } from '@tanstack/react-router'
|
|
283
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
284
|
+
|
|
285
|
+
function RootDocument() {
|
|
286
|
+
return <Outlet />
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
|
290
|
+
component: () => (
|
|
291
|
+
<HeadProvider>
|
|
292
|
+
<RootDocument />
|
|
293
|
+
</HeadProvider>
|
|
294
|
+
),
|
|
295
|
+
})
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### 5. Generate typed API hooks
|
|
299
|
+
|
|
300
|
+
Once your bunshot backend is running, sync its schema into your app:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
bun run sync
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
This generates plain async functions in `src/api/`, TanStack Query hooks in `src/hooks/api/`, and updates `src/types/api.ts`. See [API Sync](#api-sync) for details.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Configuration
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
createSnapshot({
|
|
314
|
+
// Required
|
|
315
|
+
apiUrl: 'https://api.example.com',
|
|
316
|
+
|
|
317
|
+
// Auth mode — default: 'cookie' (recommended for browser apps)
|
|
318
|
+
auth: 'cookie', // 'cookie' | 'token'
|
|
319
|
+
|
|
320
|
+
// Static API credential — not a user session token.
|
|
321
|
+
// Do not use in browser deployments. Emits a runtime warning in browser contexts.
|
|
322
|
+
bearerToken: 'my-api-key',
|
|
323
|
+
|
|
324
|
+
// Redirect paths — dev error thrown if missing when a guarded route fires
|
|
325
|
+
loginPath: '/login',
|
|
326
|
+
homePath: '/dashboard',
|
|
327
|
+
forbiddenPath: '/403',
|
|
328
|
+
mfaPath: '/auth/mfa-verify', // redirect when login returns MFA challenge
|
|
329
|
+
mfaSetupPath: '/mfa-setup', // redirect when backend requires MFA setup (403)
|
|
330
|
+
|
|
331
|
+
// Callbacks — fire alongside redirects (analytics, state cleanup, etc.)
|
|
332
|
+
onUnauthenticated: () => console.log('not logged in'),
|
|
333
|
+
onForbidden: () => console.log('access denied'),
|
|
334
|
+
|
|
335
|
+
// Token storage
|
|
336
|
+
tokenStorage: 'sessionStorage', // 'sessionStorage' | 'memory' | 'localStorage' — default: 'sessionStorage' (token mode only)
|
|
337
|
+
tokenKey: 'x-user-token', // default: 'x-user-token'
|
|
338
|
+
|
|
339
|
+
// TanStack Query defaults
|
|
340
|
+
staleTime: 5 * 60 * 1000, // default: 5 minutes
|
|
341
|
+
gcTime: 10 * 60 * 1000, // default: 10 minutes
|
|
342
|
+
retry: 1, // default: 1
|
|
343
|
+
|
|
344
|
+
// WebSocket — entire block optional; WS is disabled if omitted
|
|
345
|
+
ws: {
|
|
346
|
+
url: 'wss://api.example.com/ws',
|
|
347
|
+
|
|
348
|
+
autoReconnect: true, // default: true
|
|
349
|
+
reconnectOnLogin: true, // default: true — reconnects after login succeeds
|
|
350
|
+
reconnectOnFocus: true, // default: true — reconnects when tab regains focus
|
|
351
|
+
maxReconnectAttempts: Infinity, // default: Infinity
|
|
352
|
+
reconnectBaseDelay: 1000, // default: 1000ms
|
|
353
|
+
reconnectMaxDelay: 30000, // default: 30000ms
|
|
354
|
+
|
|
355
|
+
onConnected: () => {},
|
|
356
|
+
onDisconnected: () => {},
|
|
357
|
+
onReconnecting: (attempt) => console.log(`Reconnect attempt ${attempt}`),
|
|
358
|
+
onReconnectFailed: () => console.log('Gave up reconnecting'),
|
|
359
|
+
},
|
|
360
|
+
})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Security Model
|
|
366
|
+
|
|
367
|
+
Snapshot is the hardened browser client for Bunshot-backed apps. The security contract between snapshot and Bunshot is normative — not optional guidance.
|
|
368
|
+
|
|
369
|
+
### Default: cookie auth
|
|
370
|
+
|
|
371
|
+
Cookie auth is the default. Browser apps use `HttpOnly` session cookies managed by Bunshot. Tokens are never exposed to JavaScript.
|
|
372
|
+
|
|
373
|
+
The Bunshot browser contract requires:
|
|
374
|
+
|
|
375
|
+
**Session cookie:**
|
|
376
|
+
- `HttpOnly=true` — not readable by JS
|
|
377
|
+
- `Secure=true` in production
|
|
378
|
+
- `SameSite=Lax` minimum
|
|
379
|
+
- `Path=/`
|
|
380
|
+
- No broad `Domain` unless subdomain sharing is intentional
|
|
381
|
+
|
|
382
|
+
**CSRF cookie:**
|
|
383
|
+
- `HttpOnly=false` — must be readable by JS (snapshot reads it to send `x-csrf-token` header)
|
|
384
|
+
- `Secure=true` in production
|
|
385
|
+
- `SameSite=Lax`
|
|
386
|
+
- `Path=/`
|
|
387
|
+
- Rotated on login and logout
|
|
388
|
+
|
|
389
|
+
**CORS:**
|
|
390
|
+
- `Access-Control-Allow-Credentials: true`
|
|
391
|
+
- Exact-match origin allowlist — never `*`
|
|
392
|
+
- `Vary: Origin` on responses with dynamic `Access-Control-Allow-Origin`
|
|
393
|
+
- Allowed headers include `x-csrf-token`
|
|
394
|
+
|
|
395
|
+
**OAuth:**
|
|
396
|
+
- Bunshot validates `state` and completes the provider exchange server-side
|
|
397
|
+
- Session cookie is established during the server-side callback
|
|
398
|
+
- Browser callback page receives only success/error status — no provider code or intermediate exchange code
|
|
399
|
+
- Redirect allowlist (`allowedRedirectUrls`) is required and must be non-empty; unset or empty fails closed
|
|
400
|
+
|
|
401
|
+
**WebSocket:**
|
|
402
|
+
- Auth uses cookies, not query params (query params appear in server logs)
|
|
403
|
+
- CSRF protection is `Origin` header validation on upgrade — exact-match against an allowlist
|
|
404
|
+
- Missing or mismatched Origin is rejected
|
|
405
|
+
|
|
406
|
+
**Transport:**
|
|
407
|
+
- `https:` and `wss:` required in production
|
|
408
|
+
- localhost is the only exception for local development
|
|
409
|
+
|
|
410
|
+
### Token mode (explicit opt-in)
|
|
411
|
+
|
|
412
|
+
Token mode is available for non-browser clients or unusual browser cases. Set `auth: 'token'` explicitly. It is not the recommended Bunshot web deployment model.
|
|
413
|
+
|
|
414
|
+
- Default storage is `'sessionStorage'` (tab-scoped, not shared across tabs)
|
|
415
|
+
- `'memory'` is available as a stricter opt-in (state lost on page reload)
|
|
416
|
+
- Auth state is not shared across tabs in either storage mode
|
|
417
|
+
|
|
418
|
+
### Scaffold security profiles
|
|
419
|
+
|
|
420
|
+
The scaffold CLI offers two profiles:
|
|
421
|
+
|
|
422
|
+
**`hardened` (default):** Production-safe defaults. No static credentials in env. In-memory MFA challenge. Passive OAuth callback. No `useOAuthExchange` in exports.
|
|
423
|
+
|
|
424
|
+
**`prototype`:** Local dev ergonomics. Includes `VITE_BEARER_TOKEN` (with warning). Uses legacy OAuth exchange. Includes a startup guard that throws if the app runs on a non-localhost origin unless `VITE_ALLOW_PROTOTYPE_DEPLOYMENT=true` is set.
|
|
425
|
+
|
|
426
|
+
> Prototype mode is for local development only. The startup guard is a safety net, not a deployment strategy.
|
|
427
|
+
|
|
428
|
+
### `bearerToken`
|
|
429
|
+
|
|
430
|
+
`bearerToken` in `createSnapshot` config is a static API credential — not a user session token. It is intended for machine-to-machine or API gateway auth, not browser user sessions. Using it in a browser context emits a runtime warning in all environments. It is not included in hardened scaffold output.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Auth
|
|
435
|
+
|
|
436
|
+
### Reading the current user
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
import { useUser } from '@lib/snapshot'
|
|
440
|
+
|
|
441
|
+
function ProfileBadge() {
|
|
442
|
+
const { user, isLoading, isError } = useUser()
|
|
443
|
+
|
|
444
|
+
if (isLoading) return <Spinner />
|
|
445
|
+
if (!user) return null
|
|
446
|
+
return <span>{user.email}</span>
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`useUser` returns `null` (not an error) when the user is not logged in. It caches the `/auth/me` response via TanStack Query.
|
|
451
|
+
|
|
452
|
+
### Login
|
|
453
|
+
|
|
454
|
+
```tsx
|
|
455
|
+
import { useLogin } from '@lib/snapshot'
|
|
456
|
+
|
|
457
|
+
function LoginForm() {
|
|
458
|
+
const login = useLogin()
|
|
459
|
+
|
|
460
|
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
461
|
+
e.preventDefault()
|
|
462
|
+
const data = new FormData(e.currentTarget)
|
|
463
|
+
login.mutate(
|
|
464
|
+
{ email: data.get('email') as string, password: data.get('password') as string },
|
|
465
|
+
{
|
|
466
|
+
onSuccess: (user) => console.log('logged in as', user.email),
|
|
467
|
+
onError: (err) => console.error(err.status, err.body),
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<form onSubmit={handleSubmit}>
|
|
474
|
+
<input name="email" type="email" />
|
|
475
|
+
<input name="password" type="password" />
|
|
476
|
+
<button disabled={login.isPending}>Login</button>
|
|
477
|
+
{login.isError && <p>{login.error.message}</p>}
|
|
478
|
+
</form>
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Logout
|
|
484
|
+
|
|
485
|
+
```tsx
|
|
486
|
+
import { useLogout } from '@lib/snapshot'
|
|
487
|
+
|
|
488
|
+
function LogoutButton() {
|
|
489
|
+
const logout = useLogout()
|
|
490
|
+
return <button onClick={() => logout.mutate()}>Logout</button>
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Logout clears the stored token and the entire query cache — no stale user data remains.
|
|
495
|
+
|
|
496
|
+
### Register
|
|
497
|
+
|
|
498
|
+
```tsx
|
|
499
|
+
import { useRegister } from '@lib/snapshot'
|
|
500
|
+
|
|
501
|
+
const register = useRegister()
|
|
502
|
+
register.mutate({ email, password })
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Forgot Password
|
|
506
|
+
|
|
507
|
+
```tsx
|
|
508
|
+
import { useForgotPassword } from '@lib/snapshot'
|
|
509
|
+
|
|
510
|
+
const forgotPassword = useForgotPassword()
|
|
511
|
+
forgotPassword.mutate({ email })
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
All auth hooks return TanStack Query mutation results. Use `onSuccess`, `onError`, `onSettled` natively.
|
|
515
|
+
|
|
516
|
+
### Cookie-based auth
|
|
517
|
+
|
|
518
|
+
Cookie auth is the default and recommended mode for browser apps. Tokens are never exposed to JavaScript, eliminating XSS token theft.
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
export const snapshot = createSnapshot({
|
|
522
|
+
apiUrl: import.meta.env.VITE_API_URL,
|
|
523
|
+
loginPath: '/login',
|
|
524
|
+
homePath: '/dashboard',
|
|
525
|
+
// auth: 'cookie' is the default — no need to set it explicitly
|
|
526
|
+
})
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
When cookie mode is active:
|
|
530
|
+
|
|
531
|
+
- All requests include `credentials: 'include'` so the browser sends the auth cookie automatically
|
|
532
|
+
- Mutating requests (POST, PUT, PATCH, DELETE) attach the `x-csrf-token` header, read from the `csrf_token` cookie set by bunshot
|
|
533
|
+
- Token storage becomes a no-op — `tokenStorage.get()` returns `null`, `set()` and `clear()` do nothing
|
|
534
|
+
- Login and register responses no longer extract a token from the response body
|
|
535
|
+
- The `bearerToken`, `tokenStorage`, and `tokenKey` config options are ignored
|
|
536
|
+
|
|
537
|
+
> **CORS requirement:** When using cookie auth cross-origin, the bunshot backend must set `Access-Control-Allow-Credentials: true`, `Vary: Origin`, and use an exact-match `Access-Control-Allow-Origin` allowlist (never `*`).
|
|
538
|
+
|
|
539
|
+
### Token-based auth (explicit opt-in)
|
|
540
|
+
|
|
541
|
+
Token mode is available for non-browser clients or unusual browser cases where cookie auth is not appropriate. It is not the recommended Bunshot web deployment model.
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
export const snapshot = createSnapshot({
|
|
545
|
+
apiUrl: import.meta.env.VITE_API_URL,
|
|
546
|
+
auth: 'token',
|
|
547
|
+
loginPath: '/login',
|
|
548
|
+
homePath: '/dashboard',
|
|
549
|
+
})
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Token mode behavior:
|
|
553
|
+
|
|
554
|
+
- The access token is stored client-side and sent as `x-user-token` on every request
|
|
555
|
+
- Default storage is `'sessionStorage'` (tab-scoped — survives page refresh, cleared on tab close, does not share across tabs)
|
|
556
|
+
- `'memory'` is available as a stricter opt-in (state lost on page reload, does not share across tabs)
|
|
557
|
+
- `'localStorage'` is available but not recommended for auth tokens
|
|
558
|
+
|
|
559
|
+
> Token mode is tab-scoped by default. A user logged in on one tab will not be authenticated in a new tab opened from the same browser.
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
563
|
+
## MFA (Multi-Factor Authentication)
|
|
564
|
+
|
|
565
|
+
MFA is fully opt-in. If your bunshot backend has MFA configured, snapshot provides hooks to handle every step of the flow. Apps that don't use MFA see zero changes.
|
|
566
|
+
|
|
567
|
+
### Login with MFA
|
|
568
|
+
|
|
569
|
+
When a user with MFA enabled logs in, `useLogin` returns an `MfaChallenge` instead of an `AuthUser`. Use `isMfaChallenge` to distinguish:
|
|
570
|
+
|
|
571
|
+
```tsx
|
|
572
|
+
import { useLogin, isMfaChallenge } from '@lib/snapshot'
|
|
573
|
+
|
|
574
|
+
function LoginPage() {
|
|
575
|
+
const login = useLogin()
|
|
576
|
+
|
|
577
|
+
// If mfaPath is configured, useLogin auto-redirects on MFA challenge.
|
|
578
|
+
// The challenge is stored in memory — read it on the MFA page with usePendingMfaChallenge().
|
|
579
|
+
// For manual handling:
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (login.data && isMfaChallenge(login.data)) {
|
|
582
|
+
// login.data.mfaMethods — ['totp', 'emailOtp', etc.]
|
|
583
|
+
// mfaToken is stored internally — use usePendingMfaChallenge() on the MFA page
|
|
584
|
+
}
|
|
585
|
+
}, [login.data])
|
|
586
|
+
|
|
587
|
+
// ... form
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
If `mfaPath` is set in `createSnapshot` config, the redirect happens automatically — no manual handling needed.
|
|
592
|
+
|
|
593
|
+
### Verifying MFA during login
|
|
594
|
+
|
|
595
|
+
The MFA challenge is held in memory by the snapshot instance after `useLogin` redirects. Read it on the MFA page with `usePendingMfaChallenge`:
|
|
596
|
+
|
|
597
|
+
```tsx
|
|
598
|
+
import { useMfaVerify, useMfaResend, usePendingMfaChallenge } from '@lib/snapshot'
|
|
599
|
+
import { Link } from '@tanstack/react-router'
|
|
600
|
+
|
|
601
|
+
function MfaVerifyPage() {
|
|
602
|
+
const pendingChallenge = usePendingMfaChallenge()
|
|
603
|
+
const verify = useMfaVerify()
|
|
604
|
+
const resend = useMfaResend()
|
|
605
|
+
|
|
606
|
+
// Challenge is gone if the user navigated here directly or refreshed the page
|
|
607
|
+
if (!pendingChallenge) {
|
|
608
|
+
return <p>Session expired. <Link to="/auth/login">Sign in again.</Link></p>
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
612
|
+
e.preventDefault()
|
|
613
|
+
const code = new FormData(e.currentTarget).get('code') as string
|
|
614
|
+
verify.mutate({ code }) // mfaToken is read internally from the pending challenge
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
<form onSubmit={handleSubmit}>
|
|
619
|
+
<input name="code" inputMode="numeric" maxLength={6} />
|
|
620
|
+
<button disabled={verify.isPending}>Verify</button>
|
|
621
|
+
{verify.isError && <p>{verify.error.message}</p>}
|
|
622
|
+
<button type="button" onClick={() => resend.mutate({ mfaToken: pendingChallenge.mfaToken })}>
|
|
623
|
+
Resend email code
|
|
624
|
+
</button>
|
|
625
|
+
</form>
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
`useMfaVerify` completes the login — it stores the session (cookie mode) or token (token mode), fetches `/auth/me`, updates the auth cache, clears the pending challenge, and navigates to `homePath`.
|
|
631
|
+
|
|
632
|
+
The pending challenge is automatically cleared on successful verify, logout, and auth reset. If the user refreshes mid-flow, `usePendingMfaChallenge()` returns `null` — show an expired message and link back to login.
|
|
633
|
+
|
|
634
|
+
### Setting up MFA
|
|
635
|
+
|
|
636
|
+
```tsx
|
|
637
|
+
import { useMfaSetup, useMfaVerifySetup } from '@lib/snapshot'
|
|
638
|
+
|
|
639
|
+
function MfaSetupPage() {
|
|
640
|
+
const setup = useMfaSetup()
|
|
641
|
+
const verifySetup = useMfaVerifySetup()
|
|
642
|
+
|
|
643
|
+
// Step 1: Generate TOTP secret
|
|
644
|
+
// setup.mutate() → { secret, uri }
|
|
645
|
+
|
|
646
|
+
// Step 2: User scans QR code, enters code
|
|
647
|
+
// verifySetup.mutate({ code }) → { message, recoveryCodes }
|
|
648
|
+
|
|
649
|
+
// Step 3: Display recovery codes
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### Disabling MFA
|
|
654
|
+
|
|
655
|
+
```tsx
|
|
656
|
+
import { useMfaDisable } from '@lib/snapshot'
|
|
657
|
+
|
|
658
|
+
const disable = useMfaDisable()
|
|
659
|
+
disable.mutate({ code: '123456' }) // requires current TOTP code
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Recovery codes
|
|
663
|
+
|
|
664
|
+
```tsx
|
|
665
|
+
import { useMfaRecoveryCodes } from '@lib/snapshot'
|
|
666
|
+
|
|
667
|
+
const regenerate = useMfaRecoveryCodes()
|
|
668
|
+
regenerate.mutate({ code: '123456' }) // requires TOTP code
|
|
669
|
+
// regenerate.data.recoveryCodes — new codes (old ones invalidated)
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Email OTP
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
import { useMfaEmailOtpEnable, useMfaEmailOtpVerifySetup, useMfaEmailOtpDisable } from '@lib/snapshot'
|
|
676
|
+
|
|
677
|
+
// Enable: sends verification code to user's email
|
|
678
|
+
const enable = useMfaEmailOtpEnable()
|
|
679
|
+
enable.mutate() // → { message, setupToken }
|
|
680
|
+
|
|
681
|
+
// Verify: confirm with the code from email
|
|
682
|
+
const verifySetup = useMfaEmailOtpVerifySetup()
|
|
683
|
+
verifySetup.mutate({ setupToken: enable.data.setupToken, code: '123456' })
|
|
684
|
+
|
|
685
|
+
// Disable
|
|
686
|
+
const disable = useMfaEmailOtpDisable()
|
|
687
|
+
disable.mutate({ code: '123456' }) // TOTP code if TOTP enabled, or { password } if only method
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### Checking enabled MFA methods
|
|
691
|
+
|
|
692
|
+
```tsx
|
|
693
|
+
import { useMfaMethods } from '@lib/snapshot'
|
|
694
|
+
|
|
695
|
+
function SecuritySettings() {
|
|
696
|
+
const { methods, isLoading } = useMfaMethods()
|
|
697
|
+
// methods: ['totp', 'emailOtp'] | null
|
|
698
|
+
}
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### MFA setup required (forced enrollment)
|
|
702
|
+
|
|
703
|
+
When bunshot is configured with `mfa.required: true`, authenticated users without MFA receive a `403` with code `MFA_SETUP_REQUIRED` on any API call. If `mfaSetupPath` is set in `createSnapshot`, snapshot automatically redirects to that page.
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
createSnapshot({
|
|
707
|
+
apiUrl: import.meta.env.VITE_API_URL,
|
|
708
|
+
mfaSetupPath: '/mfa-setup', // auto-redirect on MFA_SETUP_REQUIRED
|
|
709
|
+
})
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
---
|
|
713
|
+
|
|
714
|
+
## Account Management
|
|
715
|
+
|
|
716
|
+
```tsx
|
|
717
|
+
import {
|
|
718
|
+
useSetPassword, useDeleteAccount, useCancelDeletion, useRefreshToken,
|
|
719
|
+
useSessions, useRevokeSession,
|
|
720
|
+
useResetPassword, useVerifyEmail, useResendVerification,
|
|
721
|
+
} from '@lib/snapshot'
|
|
722
|
+
|
|
723
|
+
// Set or change password
|
|
724
|
+
const setPassword = useSetPassword()
|
|
725
|
+
setPassword.mutate({ password: 'new-pass' })
|
|
726
|
+
setPassword.mutate({ password: 'new-pass', currentPassword: 'old-pass' })
|
|
727
|
+
|
|
728
|
+
// Delete account — clears token, flushes query cache, navigates to loginPath
|
|
729
|
+
const deleteAccount = useDeleteAccount()
|
|
730
|
+
deleteAccount.mutate() // OAuth-only accounts (no password)
|
|
731
|
+
deleteAccount.mutate({ password: '…' }) // credential accounts
|
|
732
|
+
|
|
733
|
+
// Cancel a queued deletion (within the grace period configured on the backend)
|
|
734
|
+
const cancelDeletion = useCancelDeletion()
|
|
735
|
+
cancelDeletion.mutate()
|
|
736
|
+
|
|
737
|
+
// Manually refresh the access token
|
|
738
|
+
const refresh = useRefreshToken()
|
|
739
|
+
refresh.mutate() // uses cookie or stored refresh token
|
|
740
|
+
refresh.mutate({ refreshToken: '…' }) // explicit token
|
|
741
|
+
|
|
742
|
+
// List active sessions
|
|
743
|
+
const { sessions, isLoading } = useSessions()
|
|
744
|
+
// sessions: Session[] — { sessionId, createdAt, lastActiveAt, expiresAt, ipAddress?, userAgent?, isActive }
|
|
745
|
+
|
|
746
|
+
// Revoke a session (sign out of another device)
|
|
747
|
+
const revokeSession = useRevokeSession()
|
|
748
|
+
revokeSession.mutate(session.sessionId)
|
|
749
|
+
|
|
750
|
+
// Password reset flow (token from email link)
|
|
751
|
+
const resetPassword = useResetPassword()
|
|
752
|
+
resetPassword.mutate({ token, password })
|
|
753
|
+
|
|
754
|
+
// Email verification flow (token from email link)
|
|
755
|
+
const verifyEmail = useVerifyEmail()
|
|
756
|
+
verifyEmail.mutate({ token })
|
|
757
|
+
|
|
758
|
+
const resendVerification = useResendVerification()
|
|
759
|
+
resendVerification.mutate({ email })
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
---
|
|
763
|
+
|
|
764
|
+
## OAuth
|
|
765
|
+
|
|
766
|
+
OAuth initiation is a simple redirect — no hook needed. Call `getOAuthUrl` and navigate:
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
import { getOAuthUrl, getLinkUrl } from '@lib/snapshot'
|
|
770
|
+
|
|
771
|
+
// Redirect to OAuth provider sign-in
|
|
772
|
+
window.location.href = getOAuthUrl('google') // → {apiUrl}/auth/google
|
|
773
|
+
|
|
774
|
+
// Link an additional OAuth provider to an existing account
|
|
775
|
+
window.location.href = getLinkUrl('github') // → {apiUrl}/auth/github/link
|
|
776
|
+
|
|
777
|
+
// Supported providers: 'google' | 'apple' | 'microsoft' | 'github'
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
After the OAuth flow completes, the provider redirects back to your callback page. In the default (hardened) browser flow, Bunshot establishes the session cookie server-side during the OAuth callback and redirects back with only a success or error indicator — no code exchange is needed in the browser:
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
// Hardened OAuth callback — passive, no exchange step
|
|
784
|
+
import { useEffect } from 'react'
|
|
785
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
786
|
+
import { useUser, queryClient } from '@lib/snapshot'
|
|
787
|
+
|
|
788
|
+
function OAuthCallbackPage() {
|
|
789
|
+
const { error } = Route.useSearch() // only { success?, error? } in search params
|
|
790
|
+
const { user } = useUser()
|
|
791
|
+
const navigate = useNavigate()
|
|
792
|
+
|
|
793
|
+
useEffect(() => {
|
|
794
|
+
if (!error) queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
|
795
|
+
}, [])
|
|
796
|
+
|
|
797
|
+
useEffect(() => {
|
|
798
|
+
if (user) navigate({ to: '/' })
|
|
799
|
+
}, [user])
|
|
800
|
+
|
|
801
|
+
if (error) return <p>Sign in failed: {error}</p>
|
|
802
|
+
return <p>Signing in...</p>
|
|
803
|
+
}
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
The scaffolded `OAuthCallbackPage` is generated this way automatically. No `useOAuthExchange` call — the session is already established by the time the browser lands on this page.
|
|
807
|
+
|
|
808
|
+
**Legacy exchange (prototype scaffold only):**
|
|
809
|
+
|
|
810
|
+
`useOAuthExchange` is available for compatibility with non-browser or prototype flows where Bunshot's one-time code pattern is used client-side:
|
|
811
|
+
|
|
812
|
+
```ts
|
|
813
|
+
// @deprecated — use the hardened cookie flow above for browser apps
|
|
814
|
+
import { useOAuthExchange } from '@lib/snapshot'
|
|
815
|
+
|
|
816
|
+
const exchange = useOAuthExchange()
|
|
817
|
+
exchange.mutate({ code })
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
> `useOAuthExchange` will be removed in the next major version. It is not included in hardened scaffold output.
|
|
821
|
+
|
|
822
|
+
Unlink a connected provider:
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
import { useOAuthUnlink } from '@lib/snapshot'
|
|
826
|
+
|
|
827
|
+
const unlink = useOAuthUnlink()
|
|
828
|
+
unlink.mutate('google') // invalidates /auth/me cache on success
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## WebAuthn
|
|
834
|
+
|
|
835
|
+
WebAuthn registration requires `@simplewebauthn/browser` on the client side to call the browser's credential APIs. snapshot provides the hooks; you wire them to the browser API.
|
|
836
|
+
|
|
837
|
+
```ts
|
|
838
|
+
import {
|
|
839
|
+
useWebAuthnRegisterOptions, useWebAuthnRegister,
|
|
840
|
+
useWebAuthnCredentials, useWebAuthnRemoveCredential, useWebAuthnDisable,
|
|
841
|
+
} from '@lib/snapshot'
|
|
842
|
+
import { startRegistration } from '@simplewebauthn/browser'
|
|
843
|
+
|
|
844
|
+
// Registration flow
|
|
845
|
+
function useRegisterSecurityKey(name?: string) {
|
|
846
|
+
const getOptions = useWebAuthnRegisterOptions()
|
|
847
|
+
const register = useWebAuthnRegister()
|
|
848
|
+
|
|
849
|
+
async function registerKey() {
|
|
850
|
+
// Step 1: get challenge from server
|
|
851
|
+
const { options, registrationToken } = await getOptions.mutateAsync()
|
|
852
|
+
|
|
853
|
+
// Step 2: browser prompts user to tap security key / use Touch ID
|
|
854
|
+
const attestationResponse = await startRegistration(options)
|
|
855
|
+
|
|
856
|
+
// Step 3: send result back to server
|
|
857
|
+
register.mutate({ registrationToken, attestationResponse, name })
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return { registerKey, isPending: getOptions.isPending || register.isPending }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// List registered credentials
|
|
864
|
+
const { credentials, isLoading } = useWebAuthnCredentials()
|
|
865
|
+
// credentials: { credentialId, name?, createdAt, transports? }[]
|
|
866
|
+
|
|
867
|
+
// Remove a specific credential
|
|
868
|
+
const remove = useWebAuthnRemoveCredential()
|
|
869
|
+
remove.mutate(credentialId)
|
|
870
|
+
|
|
871
|
+
// Disable WebAuthn entirely
|
|
872
|
+
const disable = useWebAuthnDisable()
|
|
873
|
+
disable.mutate()
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
## Passkey Login
|
|
879
|
+
|
|
880
|
+
Passkeys (Windows Hello, Face ID, Touch ID) as a **passwordless first-factor** — no password, no MFA prompt. Requires bunshot `mfa.webauthn.allowPasswordlessLogin: true` on the server.
|
|
881
|
+
|
|
882
|
+
```ts
|
|
883
|
+
import {
|
|
884
|
+
usePasskeyLoginOptions, usePasskeyLogin,
|
|
885
|
+
isMfaChallenge,
|
|
886
|
+
} from '@lib/snapshot'
|
|
887
|
+
import { startAuthentication } from '@simplewebauthn/browser'
|
|
888
|
+
|
|
889
|
+
function usePasskeySignIn() {
|
|
890
|
+
const getOptions = usePasskeyLoginOptions()
|
|
891
|
+
const login = usePasskeyLogin()
|
|
892
|
+
|
|
893
|
+
async function signInWithPasskey(email?: string) {
|
|
894
|
+
// Step 1 — get challenge (enumeration-safe: safe to pass unknown email)
|
|
895
|
+
const { options, passkeyToken } = await getOptions.mutateAsync({ email })
|
|
896
|
+
|
|
897
|
+
// Step 2 — OS prompt (Windows Hello / Face ID / Touch ID)
|
|
898
|
+
// Throws NotAllowedError if user cancels — catch it and fall back to password
|
|
899
|
+
const assertionResponse = await startAuthentication(options)
|
|
900
|
+
|
|
901
|
+
// Step 3 — verify server-side; hook stores token + navigates on success
|
|
902
|
+
const result = await login.mutateAsync({ passkeyToken, assertionResponse })
|
|
903
|
+
|
|
904
|
+
// isMfaChallenge only when server has passkeyMfaBypass: false
|
|
905
|
+
if (isMfaChallenge(result)) {
|
|
906
|
+
// redirect to MFA page with result.mfaToken
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
signInWithPasskey,
|
|
912
|
+
isPending: getOptions.isPending || login.isPending,
|
|
913
|
+
error: login.error,
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
#### Handling cancellation and retries
|
|
919
|
+
|
|
920
|
+
```ts
|
|
921
|
+
async function handlePasskeyLogin(email?: string) {
|
|
922
|
+
// Check browser support first — hide button if unsupported
|
|
923
|
+
if (!window.PublicKeyCredential) return
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
const { options, passkeyToken } = await getOptions.mutateAsync({ email })
|
|
927
|
+
const assertionResponse = await startAuthentication(options)
|
|
928
|
+
await login.mutateAsync({ passkeyToken, assertionResponse })
|
|
929
|
+
} catch (err: any) {
|
|
930
|
+
if (err.name === 'NotAllowedError') {
|
|
931
|
+
// User cancelled the OS prompt — not an error, just fall back to password
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
// Network error or token expiry (410 / challenge-not-found) — retry once with fresh challenge
|
|
935
|
+
if (err.status === 410 || err.name === 'NetworkError') {
|
|
936
|
+
const { options: freshOptions, passkeyToken: freshToken } = await getOptions.mutateAsync({ email })
|
|
937
|
+
const assertionResponse = await startAuthentication(freshOptions)
|
|
938
|
+
await login.mutateAsync({ passkeyToken: freshToken, assertionResponse })
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
// 401 authentication failure — surface to user, do not retry
|
|
942
|
+
throw err
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
`usePasskeyLogin` stores the session token and navigates to `homePath` on success, identical to `useLogin`.
|
|
948
|
+
|
|
949
|
+
---
|
|
950
|
+
|
|
951
|
+
## Route Guards
|
|
952
|
+
|
|
953
|
+
Assign `protectedBeforeLoad` and `guestBeforeLoad` in your route files:
|
|
954
|
+
|
|
955
|
+
```ts
|
|
956
|
+
// src/routes/dashboard.tsx — authenticated users only
|
|
957
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
958
|
+
import { protectedBeforeLoad } from '@lib/snapshot'
|
|
959
|
+
|
|
960
|
+
export const Route = createFileRoute('/dashboard')({
|
|
961
|
+
beforeLoad: protectedBeforeLoad,
|
|
962
|
+
component: DashboardPage,
|
|
963
|
+
})
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
```ts
|
|
967
|
+
// src/routes/login.tsx — redirect to home if already logged in
|
|
968
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
969
|
+
import { guestBeforeLoad } from '@lib/snapshot'
|
|
970
|
+
|
|
971
|
+
export const Route = createFileRoute('/login')({
|
|
972
|
+
beforeLoad: guestBeforeLoad,
|
|
973
|
+
component: LoginPage,
|
|
974
|
+
})
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
Both guards fetch `/auth/me` via the router context's `queryClient` (configured in step 2). TanStack Query serves from cache if the result is fresh.
|
|
978
|
+
|
|
979
|
+
---
|
|
980
|
+
|
|
981
|
+
## API Client
|
|
982
|
+
|
|
983
|
+
The `api` primitive gives direct access to the HTTP client — useful outside React (Jotai atoms, event handlers, utilities):
|
|
984
|
+
|
|
985
|
+
```ts
|
|
986
|
+
import { api } from '@lib/snapshot'
|
|
987
|
+
|
|
988
|
+
// Typed response
|
|
989
|
+
const user = await api.get<User>('/users/123')
|
|
990
|
+
|
|
991
|
+
// With body
|
|
992
|
+
const post = await api.post<Post>('/posts', { title: 'Hello', body: '...' })
|
|
993
|
+
|
|
994
|
+
// With custom headers
|
|
995
|
+
const data = await api.get<Data>('/protected', {
|
|
996
|
+
headers: { 'x-custom-header': 'value' },
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
// With abort signal
|
|
1000
|
+
const controller = new AbortController()
|
|
1001
|
+
const data = await api.get<Data>('/slow-endpoint', { signal: controller.signal })
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
Available methods: `get`, `post`, `put`, `patch`, `delete` — all return `Promise<T>`.
|
|
1005
|
+
|
|
1006
|
+
### Error handling
|
|
1007
|
+
|
|
1008
|
+
Non-2xx responses throw `ApiError`:
|
|
1009
|
+
|
|
1010
|
+
```ts
|
|
1011
|
+
import { ApiError } from '@lastshotlabs/snapshot'
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
await api.post('/posts', body)
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
if (err instanceof ApiError) {
|
|
1017
|
+
console.log(err.status) // HTTP status code
|
|
1018
|
+
console.log(err.body) // parsed JSON response body
|
|
1019
|
+
console.log(err.message) // "HTTP 422"
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
In TanStack Query mutations, errors are typed automatically when you annotate the mutation:
|
|
1025
|
+
|
|
1026
|
+
```ts
|
|
1027
|
+
const mutation = useMutation<Post, ApiError, CreatePostBody>({
|
|
1028
|
+
mutationFn: (body) => api.post('/posts', body),
|
|
1029
|
+
})
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
> `ApiError` is the one thing imported directly from the package. Everything else (`api`, `useUser`, etc.) comes from `@lib/snapshot`.
|
|
1033
|
+
|
|
1034
|
+
---
|
|
1035
|
+
|
|
1036
|
+
## WebSocket
|
|
1037
|
+
|
|
1038
|
+
### Basic usage
|
|
1039
|
+
|
|
1040
|
+
```tsx
|
|
1041
|
+
import { useSocket } from '@lib/snapshot'
|
|
1042
|
+
|
|
1043
|
+
function StatusIndicator() {
|
|
1044
|
+
const socket = useSocket()
|
|
1045
|
+
return <span>{socket.isConnected ? 'Live' : 'Offline'}</span>
|
|
1046
|
+
}
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
`useSocket()` returns a `SocketHook` with:
|
|
1050
|
+
- `isConnected: boolean`
|
|
1051
|
+
- `send(type, payload)` — send a message to the server
|
|
1052
|
+
- `on(event, handler)` / `off(event, handler)` — raw event listeners
|
|
1053
|
+
- `subscribe(room)` / `unsubscribe(room)` — available but prefer `useRoom` / `useRoomEvent`, which handle cleanup and auto-resubscription on reconnect
|
|
1054
|
+
- `reconnect()` — manual reconnect trigger
|
|
1055
|
+
|
|
1056
|
+
If `ws` is not configured in `createSnapshot`, `useSocket()` is a no-op: `isConnected` is always `false` and all methods are safe to call (they do nothing).
|
|
1057
|
+
|
|
1058
|
+
### Typed events
|
|
1059
|
+
|
|
1060
|
+
```ts
|
|
1061
|
+
// src/types/ws.ts
|
|
1062
|
+
export interface WebSocketEvents {
|
|
1063
|
+
'chat:message': { roomId: string; content: string; author: string }
|
|
1064
|
+
'presence:update': { roomId: string; members: string[] }
|
|
1065
|
+
'notification': { id: string; text: string }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/lib/snapshot.ts
|
|
1069
|
+
export const snapshot = createSnapshot<WebSocketEvents>({ ... })
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
With the type parameter, `useSocket<WebSocketEvents>()` is fully typed.
|
|
1073
|
+
|
|
1074
|
+
### Room hooks
|
|
1075
|
+
|
|
1076
|
+
```tsx
|
|
1077
|
+
import { useRoom, useRoomEvent } from '@lib/snapshot'
|
|
1078
|
+
|
|
1079
|
+
function ChatRoom({ roomId }: { roomId: string }) {
|
|
1080
|
+
const { isSubscribed } = useRoom(`chat:${roomId}`)
|
|
1081
|
+
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
1082
|
+
|
|
1083
|
+
useRoomEvent(`chat:${roomId}`, 'chat:message', (msg) => {
|
|
1084
|
+
setMessages(prev => [...prev, msg])
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
if (!isSubscribed) return <Spinner />
|
|
1088
|
+
return <MessageList messages={messages} />
|
|
1089
|
+
}
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
`useRoom` subscribes on mount and unsubscribes on unmount. The WebSocket manager automatically re-subscribes to all rooms after reconnect — no manual handling needed.
|
|
1093
|
+
|
|
1094
|
+
`useRoomEvent` is scoped — the handler only fires when the event name matches AND the message was received from the specified room. Events from other rooms with the same name are ignored.
|
|
1095
|
+
|
|
1096
|
+
### Building custom hooks
|
|
1097
|
+
|
|
1098
|
+
Use `useWebSocketManager` for direct access to the `WebSocketManager` instance:
|
|
1099
|
+
|
|
1100
|
+
```ts
|
|
1101
|
+
import { useWebSocketManager } from '@lib/snapshot'
|
|
1102
|
+
import { useState, useEffect } from 'react'
|
|
1103
|
+
|
|
1104
|
+
export function usePresence(roomId: string) {
|
|
1105
|
+
const manager = useWebSocketManager()
|
|
1106
|
+
const [members, setMembers] = useState<string[]>([])
|
|
1107
|
+
|
|
1108
|
+
useEffect(() => {
|
|
1109
|
+
if (!manager) return
|
|
1110
|
+
manager.subscribe(`presence:${roomId}`)
|
|
1111
|
+
const handler = (data: { roomId: string; members: string[] }) => {
|
|
1112
|
+
if (data.roomId === roomId) setMembers(data.members)
|
|
1113
|
+
}
|
|
1114
|
+
manager.on('presence:update', handler)
|
|
1115
|
+
return () => {
|
|
1116
|
+
manager.unsubscribe(`presence:${roomId}`)
|
|
1117
|
+
manager.off('presence:update', handler)
|
|
1118
|
+
}
|
|
1119
|
+
}, [roomId, manager])
|
|
1120
|
+
|
|
1121
|
+
return members
|
|
1122
|
+
}
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
### WebSocket auth
|
|
1126
|
+
|
|
1127
|
+
The browser sends the auth cookie automatically on the WebSocket upgrade request — no token in query params (which appear in server logs). After login, snapshot automatically reconnects the WebSocket so the new connection carries the authenticated cookie (when `reconnectOnLogin: true`, which is the default).
|
|
1128
|
+
|
|
1129
|
+
---
|
|
1130
|
+
|
|
1131
|
+
## Theme
|
|
1132
|
+
|
|
1133
|
+
```tsx
|
|
1134
|
+
import { useTheme } from '@lib/snapshot'
|
|
1135
|
+
|
|
1136
|
+
function ThemeToggle() {
|
|
1137
|
+
const { theme, toggle } = useTheme()
|
|
1138
|
+
return (
|
|
1139
|
+
<button onClick={toggle}>
|
|
1140
|
+
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
|
1141
|
+
</button>
|
|
1142
|
+
)
|
|
1143
|
+
}
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
`useTheme` returns:
|
|
1147
|
+
- `theme: 'light' | 'dark'`
|
|
1148
|
+
- `toggle()` — switches between light and dark
|
|
1149
|
+
- `set(t: 'light' | 'dark')` — set explicitly
|
|
1150
|
+
|
|
1151
|
+
Theme is persisted in `localStorage` under the key `snapshot-theme`. The `dark` class is automatically applied to `document.documentElement` (compatible with Tailwind v4's `dark:` variant).
|
|
1152
|
+
|
|
1153
|
+
On first load, the theme defaults to the user's OS preference (`prefers-color-scheme`).
|
|
1154
|
+
|
|
1155
|
+
---
|
|
1156
|
+
|
|
1157
|
+
## Token Storage
|
|
1158
|
+
|
|
1159
|
+
Access the token storage directly for custom auth flows:
|
|
1160
|
+
|
|
1161
|
+
```ts
|
|
1162
|
+
import { tokenStorage } from '@lib/snapshot'
|
|
1163
|
+
|
|
1164
|
+
tokenStorage.get() // returns string | null
|
|
1165
|
+
tokenStorage.set('token') // stores a token
|
|
1166
|
+
tokenStorage.clear() // removes the token
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
### Building custom auth hooks
|
|
1170
|
+
|
|
1171
|
+
```ts
|
|
1172
|
+
import { api, tokenStorage } from '@lib/snapshot'
|
|
1173
|
+
import { useMutation } from '@tanstack/react-query'
|
|
1174
|
+
|
|
1175
|
+
export function useImpersonate() {
|
|
1176
|
+
return useMutation({
|
|
1177
|
+
mutationFn: (userId: string) =>
|
|
1178
|
+
api.post<{ token: string }>('/admin/impersonate', { userId }),
|
|
1179
|
+
onSuccess: ({ token }) => tokenStorage.set(token),
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
---
|
|
1185
|
+
|
|
1186
|
+
## Composition Patterns
|
|
1187
|
+
|
|
1188
|
+
All hooks and primitives returned by `createSnapshot` are designed for composition. Apps build domain hooks from them — no reimplementing, no copying package internals.
|
|
1189
|
+
|
|
1190
|
+
### Custom API calls with Jotai
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
// src/store/products.ts
|
|
1194
|
+
import { atom } from 'jotai'
|
|
1195
|
+
import { api } from '@lib/snapshot'
|
|
1196
|
+
import type { Product } from '@/types/api'
|
|
1197
|
+
|
|
1198
|
+
const selectedIdAtom = atom<string | null>(null)
|
|
1199
|
+
|
|
1200
|
+
// Works outside React — no hooks required
|
|
1201
|
+
export const selectedProductAtom = atom(async (get) => {
|
|
1202
|
+
const id = get(selectedIdAtom)
|
|
1203
|
+
if (!id) return null
|
|
1204
|
+
return api.get<Product>(`/products/${id}`)
|
|
1205
|
+
})
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
### Custom query hooks
|
|
1209
|
+
|
|
1210
|
+
```ts
|
|
1211
|
+
// src/hooks/useProducts.ts
|
|
1212
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
1213
|
+
import { api } from '@lib/snapshot'
|
|
1214
|
+
import type { Product, CreateProductBody } from '@/types/api'
|
|
1215
|
+
|
|
1216
|
+
export function useProducts() {
|
|
1217
|
+
return useQuery({
|
|
1218
|
+
queryKey: ['products'],
|
|
1219
|
+
queryFn: () => api.get<Product[]>('/products'),
|
|
1220
|
+
})
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function useCreateProduct() {
|
|
1224
|
+
const queryClient = useQueryClient()
|
|
1225
|
+
return useMutation({
|
|
1226
|
+
mutationFn: (body: CreateProductBody) => api.post<Product>('/products', body),
|
|
1227
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
|
|
1228
|
+
})
|
|
1229
|
+
}
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
---
|
|
1233
|
+
|
|
1234
|
+
## Instance Shape
|
|
1235
|
+
|
|
1236
|
+
`createSnapshot` returns a `SnapshotInstance<TWSEvents>` with:
|
|
1237
|
+
|
|
1238
|
+
| Property | Type | Description |
|
|
1239
|
+
|---|---|---|
|
|
1240
|
+
| `useUser` | Hook | Current auth user, loading, error state |
|
|
1241
|
+
| `useLogin` | Hook | Login mutation |
|
|
1242
|
+
| `useLogout` | Hook | Logout mutation |
|
|
1243
|
+
| `useRegister` | Hook | Register mutation |
|
|
1244
|
+
| `useForgotPassword` | Hook | Forgot password mutation |
|
|
1245
|
+
| `useSocket` | Hook | WebSocket connection and messaging |
|
|
1246
|
+
| `useRoom` | Hook | Subscribe to a named room |
|
|
1247
|
+
| `useRoomEvent` | Hook | Listen to events in a named room |
|
|
1248
|
+
| `useTheme` | Hook | Light/dark theme toggle |
|
|
1249
|
+
| `useMfaVerify` | Hook | Complete MFA login with code |
|
|
1250
|
+
| `useMfaSetup` | Hook | Generate TOTP secret + QR URI |
|
|
1251
|
+
| `useMfaVerifySetup` | Hook | Confirm TOTP setup, get recovery codes |
|
|
1252
|
+
| `useMfaDisable` | Hook | Disable MFA (requires TOTP code) |
|
|
1253
|
+
| `useMfaRecoveryCodes` | Hook | Regenerate recovery codes |
|
|
1254
|
+
| `useMfaEmailOtpEnable` | Hook | Initiate email OTP setup |
|
|
1255
|
+
| `useMfaEmailOtpVerifySetup` | Hook | Confirm email OTP setup |
|
|
1256
|
+
| `useMfaEmailOtpDisable` | Hook | Disable email OTP method |
|
|
1257
|
+
| `useMfaResend` | Hook | Resend email OTP code during login |
|
|
1258
|
+
| `useMfaMethods` | Hook | Query enabled MFA methods |
|
|
1259
|
+
| `isMfaChallenge` | Utility | Type guard for `LoginResult` |
|
|
1260
|
+
| `usePendingMfaChallenge` | Hook | Read the in-memory MFA challenge on the MFA verify page |
|
|
1261
|
+
| `useSetPassword` | Hook | Set or change account password |
|
|
1262
|
+
| `useDeleteAccount` | Hook | Delete account with full session teardown |
|
|
1263
|
+
| `useCancelDeletion` | Hook | Cancel a queued account deletion |
|
|
1264
|
+
| `useRefreshToken` | Hook | Exchange refresh token for new access token |
|
|
1265
|
+
| `useSessions` | Hook | List active sessions for current user |
|
|
1266
|
+
| `useRevokeSession` | Hook | Revoke a session by ID |
|
|
1267
|
+
| `useResetPassword` | Hook | Reset password using email token |
|
|
1268
|
+
| `useVerifyEmail` | Hook | Verify email address using token |
|
|
1269
|
+
| `useResendVerification` | Hook | Resend email verification link |
|
|
1270
|
+
| `getOAuthUrl` | Utility | Get OAuth provider redirect URL |
|
|
1271
|
+
| `getLinkUrl` | Utility | Get OAuth account-linking redirect URL |
|
|
1272
|
+
| `useOAuthExchange` | Hook | **Legacy.** Exchange OAuth one-time code for session. Not used in hardened browser flow. Removed in next major version. |
|
|
1273
|
+
| `useOAuthUnlink` | Hook | Unlink an OAuth provider |
|
|
1274
|
+
| `useWebAuthnRegisterOptions` | Hook | Get WebAuthn registration challenge |
|
|
1275
|
+
| `useWebAuthnRegister` | Hook | Complete WebAuthn credential registration |
|
|
1276
|
+
| `useWebAuthnCredentials` | Hook | List registered WebAuthn credentials |
|
|
1277
|
+
| `useWebAuthnRemoveCredential` | Hook | Remove a WebAuthn credential |
|
|
1278
|
+
| `useWebAuthnDisable` | Hook | Disable WebAuthn MFA method |
|
|
1279
|
+
| `usePasskeyLoginOptions` | Hook | Get passkey login challenge options (passkeyPages: true) |
|
|
1280
|
+
| `usePasskeyLogin` | Hook | Complete passwordless passkey login (passkeyPages: true) |
|
|
1281
|
+
| `api` | Primitive | `ApiClient` — typed HTTP methods |
|
|
1282
|
+
| `tokenStorage` | Primitive | Read/write/clear auth token |
|
|
1283
|
+
| `queryClient` | Primitive | Stable `QueryClient` singleton |
|
|
1284
|
+
| `useWebSocketManager` | Hook | Raw `WebSocketManager` instance |
|
|
1285
|
+
| `protectedBeforeLoad` | Loader | Redirect unauthenticated users |
|
|
1286
|
+
| `guestBeforeLoad` | Loader | Redirect authenticated users |
|
|
1287
|
+
| `QueryProvider` | Component | Pre-bound `QueryClientProvider` |
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
## Peer Dependencies
|
|
1292
|
+
|
|
1293
|
+
| Package | Required Version |
|
|
1294
|
+
|---|---|
|
|
1295
|
+
| `react` | `>=19.0.0` |
|
|
1296
|
+
| `react-dom` | `>=19.0.0` |
|
|
1297
|
+
| `@tanstack/react-router` | `>=1.0.0` |
|
|
1298
|
+
| `@tanstack/react-query` | `>=5.0.0` |
|
|
1299
|
+
| `jotai` | `>=2.0.0` |
|
|
1300
|
+
| `@unhead/react` | `>=2.0.0` |
|
|
1301
|
+
| `vite` | `>=5.0.0` · optional — only required for the Vite plugin |
|
|
1302
|
+
| `zod` | `^3.0.0` · optional — only required for `--zod` flag |
|
|
1303
|
+
|
|
1304
|
+
---
|
|
1305
|
+
|
|
1306
|
+
## API Sync
|
|
1307
|
+
|
|
1308
|
+
Generate fully-typed TanStack Query hooks directly from your bunshot backend's OpenAPI schema:
|
|
1309
|
+
|
|
1310
|
+
```bash
|
|
1311
|
+
bun run sync # reads VITE_API_URL from .env
|
|
1312
|
+
bunx snapshot sync --api http://localhost:3000 # explicit URL
|
|
1313
|
+
bunx snapshot sync --file ./openapi.json # local file
|
|
1314
|
+
bunx snapshot sync --watch # re-run automatically on schema change
|
|
1315
|
+
bunx snapshot sync --zod # also generate Zod validators
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
Run it any time your backend routes or types change. It only touches generated files and is safe to re-run as many times as needed.
|
|
1319
|
+
|
|
1320
|
+
### URL resolution
|
|
1321
|
+
|
|
1322
|
+
The command resolves the API URL in this order:
|
|
1323
|
+
|
|
1324
|
+
1. `--api <url>` flag
|
|
1325
|
+
2. `VITE_API_URL` environment variable (bun loads `.env` automatically for `bun run sync`)
|
|
1326
|
+
3. `VITE_API_URL` in the `.env` file in the current directory
|
|
1327
|
+
|
|
1328
|
+
The schema is fetched from `{apiUrl}/openapi.json`. Use `--file` to skip the network and read directly from a local file.
|
|
1329
|
+
|
|
1330
|
+
### What gets generated
|
|
1331
|
+
|
|
1332
|
+
**`src/types/api.ts`** — TypeScript types for every schema in `components.schemas`:
|
|
1333
|
+
|
|
1334
|
+
```ts
|
|
1335
|
+
// Generated by bunx snapshot sync. Do not edit manually.
|
|
1336
|
+
|
|
1337
|
+
export interface User {
|
|
1338
|
+
id: string
|
|
1339
|
+
email: string
|
|
1340
|
+
createdAt: string
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
export type UserRole = 'admin' | 'member' | 'guest'
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
**`src/api/{tag}.ts`** — one file per OpenAPI tag containing plain async functions. No React dependencies — callable anywhere (Jotai atoms, event handlers, utilities).
|
|
1347
|
+
|
|
1348
|
+
```ts
|
|
1349
|
+
// Generated by bunx snapshot sync. Do not edit manually.
|
|
1350
|
+
|
|
1351
|
+
import { api } from '@lib/snapshot'
|
|
1352
|
+
import type { User, CreateUserBody } from '../types/api'
|
|
1353
|
+
|
|
1354
|
+
/** List all users */
|
|
1355
|
+
export const listUsers = (): Promise<User[]> =>
|
|
1356
|
+
api.get<User[]>('/users')
|
|
1357
|
+
|
|
1358
|
+
/** Get user by ID */
|
|
1359
|
+
export const getUser = (id: string): Promise<User> =>
|
|
1360
|
+
api.get<User>(`/users/${id}`)
|
|
1361
|
+
|
|
1362
|
+
/** Create a user */
|
|
1363
|
+
export const createUser = (body: CreateUserBody): Promise<User> =>
|
|
1364
|
+
api.post<User>('/users', body)
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
**`src/hooks/api/{tag}.ts`** — one file per OpenAPI tag containing TanStack Query hooks. Imports plain functions from `../../api/{tag}`.
|
|
1368
|
+
|
|
1369
|
+
```ts
|
|
1370
|
+
// Generated by bunx snapshot sync. Do not edit manually.
|
|
1371
|
+
|
|
1372
|
+
import { useQuery, useMutation, useQueryClient, type UseQueryOptions, type UseMutationOptions, type QueryKey } from '@tanstack/react-query'
|
|
1373
|
+
import { ApiError } from '@lastshotlabs/snapshot'
|
|
1374
|
+
import { listUsers, getUser, createUser } from '../../api/users'
|
|
1375
|
+
import type { User, CreateUserBody } from '../../types/api'
|
|
1376
|
+
|
|
1377
|
+
/** List all users */
|
|
1378
|
+
export function useListUsersQuery(
|
|
1379
|
+
options?: Omit<UseQueryOptions<User[], ApiError>, 'queryKey' | 'queryFn'>
|
|
1380
|
+
) {
|
|
1381
|
+
return useQuery({
|
|
1382
|
+
queryKey: ['users'],
|
|
1383
|
+
queryFn: listUsers,
|
|
1384
|
+
...options,
|
|
1385
|
+
})
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** Get user by ID */
|
|
1389
|
+
export function useGetUserQuery(
|
|
1390
|
+
params: { id: string },
|
|
1391
|
+
options?: Omit<UseQueryOptions<User, ApiError>, 'queryKey' | 'queryFn'>
|
|
1392
|
+
) {
|
|
1393
|
+
return useQuery({
|
|
1394
|
+
queryKey: ['users', params.id],
|
|
1395
|
+
queryFn: () => getUser(params.id),
|
|
1396
|
+
...options,
|
|
1397
|
+
})
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** Create a user */
|
|
1401
|
+
export function useCreateUserMutation(
|
|
1402
|
+
options?: UseMutationOptions<User, ApiError, CreateUserBody> & { invalidateKeys?: QueryKey[] }
|
|
1403
|
+
) {
|
|
1404
|
+
const { invalidateKeys, ...mutationOptions } = options ?? {}
|
|
1405
|
+
const queryClient = useQueryClient()
|
|
1406
|
+
return useMutation({
|
|
1407
|
+
mutationFn: createUser,
|
|
1408
|
+
...mutationOptions,
|
|
1409
|
+
onSuccess: (...args) => {
|
|
1410
|
+
invalidateKeys?.forEach((key) => queryClient.invalidateQueries({ queryKey: key }))
|
|
1411
|
+
mutationOptions.onSuccess?.(...args)
|
|
1412
|
+
},
|
|
1413
|
+
})
|
|
1414
|
+
}
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
GET operations become `useQuery` hooks. All other methods become `useMutation` hooks. Operations with an `operationId` get clean names (`listUsers` → `useListUsersQuery`). Operations without one fall back to method + path segments. Deprecated operations have `/** @deprecated */` added to both exports.
|
|
1418
|
+
|
|
1419
|
+
For mutations that include path parameters (e.g. `PUT /users/{id}`), the mutation variables combine path params and body into a single object:
|
|
1420
|
+
|
|
1421
|
+
```ts
|
|
1422
|
+
// Generated for PUT /users/{id}
|
|
1423
|
+
export const updateUser = (id: string, body: UpdateUserBody): Promise<User> =>
|
|
1424
|
+
api.put<User>(`/users/${id}`, body)
|
|
1425
|
+
|
|
1426
|
+
export function useUpdateUserMutation(
|
|
1427
|
+
options?: UseMutationOptions<User, ApiError, { id: string; body: UpdateUserBody }> & { invalidateKeys?: QueryKey[] }
|
|
1428
|
+
) {
|
|
1429
|
+
const { invalidateKeys, ...mutationOptions } = options ?? {}
|
|
1430
|
+
const queryClient = useQueryClient()
|
|
1431
|
+
return useMutation({
|
|
1432
|
+
mutationFn: (vars) => updateUser(vars.id, vars.body),
|
|
1433
|
+
...mutationOptions,
|
|
1434
|
+
onSuccess: (...args) => {
|
|
1435
|
+
invalidateKeys?.forEach((key) => queryClient.invalidateQueries({ queryKey: key }))
|
|
1436
|
+
mutationOptions.onSuccess?.(...args)
|
|
1437
|
+
},
|
|
1438
|
+
})
|
|
1439
|
+
}
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
Usage:
|
|
1443
|
+
|
|
1444
|
+
```ts
|
|
1445
|
+
const update = useUpdateUserMutation()
|
|
1446
|
+
update.mutate({ id: user.id, body: { email: 'new@example.com' } })
|
|
1447
|
+
```
|
|
1448
|
+
|
|
1449
|
+
### Mutation hook options
|
|
1450
|
+
|
|
1451
|
+
Generated mutation hooks accept the full `UseMutationOptions` type (no restrictions), plus an `invalidateKeys` extension:
|
|
1452
|
+
|
|
1453
|
+
**Override `mutationFn` to inject context** (e.g. an `accountId` from a parent component or store):
|
|
1454
|
+
|
|
1455
|
+
```ts
|
|
1456
|
+
const { accountId } = useAccount()
|
|
1457
|
+
|
|
1458
|
+
const create = useCreateUserMutation({
|
|
1459
|
+
mutationFn: (body) => createUser({ ...body, accountId }),
|
|
1460
|
+
})
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
**Auto-invalidate queries on success:**
|
|
1464
|
+
|
|
1465
|
+
```ts
|
|
1466
|
+
const create = useCreateUserMutation({
|
|
1467
|
+
invalidateKeys: [['users'], ['stats']],
|
|
1468
|
+
})
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
**Both together:**
|
|
1472
|
+
|
|
1473
|
+
```ts
|
|
1474
|
+
const create = useCreateUserMutation({
|
|
1475
|
+
mutationFn: (body) => createUser({ ...body, accountId }),
|
|
1476
|
+
invalidateKeys: [['users', accountId]],
|
|
1477
|
+
onSuccess: () => toast.success('User created'),
|
|
1478
|
+
})
|
|
1479
|
+
```
|
|
1480
|
+
|
|
1481
|
+
`invalidateKeys` runs before `onSuccess` — by the time your callback fires, the relevant queries are already invalidated.
|
|
1482
|
+
|
|
1483
|
+
### Paginated endpoints
|
|
1484
|
+
|
|
1485
|
+
When an endpoint returns a bunshot pagination envelope (`{ data: T[], total: number, page: number, perPage: number }`), sync detects it automatically and generates a paginated hook:
|
|
1486
|
+
|
|
1487
|
+
```ts
|
|
1488
|
+
// Generated for GET /users (paginated response)
|
|
1489
|
+
export const listUsers = (page = 1, perPage = 20): Promise<PaginatedResponse<User>> =>
|
|
1490
|
+
api.get<PaginatedResponse<User>>(`/users?page=${page}&perPage=${perPage}`)
|
|
1491
|
+
|
|
1492
|
+
export function useListUsersQuery(
|
|
1493
|
+
params: { page?: number; perPage?: number } = {},
|
|
1494
|
+
options?: Omit<UseQueryOptions<PaginatedResponse<User>, ApiError>, 'queryKey' | 'queryFn'>
|
|
1495
|
+
) {
|
|
1496
|
+
return useQuery({
|
|
1497
|
+
queryKey: ['users', params.page ?? 1, params.perPage ?? 20],
|
|
1498
|
+
queryFn: () => listUsers(params.page ?? 1, params.perPage ?? 20),
|
|
1499
|
+
...options,
|
|
1500
|
+
})
|
|
1501
|
+
}
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
The `page` and `perPage` values are included in the `queryKey` so TanStack Query caches each page separately. `PaginatedResponse<T>` is exported from `src/types/api.ts`.
|
|
1505
|
+
|
|
1506
|
+
### Zod form schemas (`--zod`)
|
|
1507
|
+
|
|
1508
|
+
Pass `--zod` to also generate Zod validators for mutation request bodies. These are placed alongside each mutation hook and are ready to use with `react-hook-form` or any Zod-compatible form library:
|
|
1509
|
+
|
|
1510
|
+
```ts
|
|
1511
|
+
/** Zod schema for useCreateUserMutation form validation */
|
|
1512
|
+
export const createUserSchema = z.object({
|
|
1513
|
+
email: z.string(),
|
|
1514
|
+
role: z.enum(['admin', 'member']),
|
|
1515
|
+
})
|
|
1516
|
+
export type CreateUserInput = z.infer<typeof createUserSchema>
|
|
1517
|
+
```
|
|
1518
|
+
|
|
1519
|
+
Usage with `react-hook-form`:
|
|
1520
|
+
|
|
1521
|
+
```ts
|
|
1522
|
+
import { createUserSchema, type CreateUserInput } from '@api/users'
|
|
1523
|
+
import { useForm } from 'react-hook-form'
|
|
1524
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
1525
|
+
|
|
1526
|
+
const form = useForm<CreateUserInput>({ resolver: zodResolver(createUserSchema) })
|
|
1527
|
+
```
|
|
1528
|
+
|
|
1529
|
+
`zod` must be installed in your project (`bun add zod`). It is an optional peer dependency of snapshot.
|
|
1530
|
+
|
|
1531
|
+
### Watch mode (`--watch` / `-w`)
|
|
1532
|
+
|
|
1533
|
+
Keep hooks in sync while developing — sync re-runs automatically whenever the schema changes:
|
|
1534
|
+
|
|
1535
|
+
```bash
|
|
1536
|
+
bunx snapshot sync --watch # polls API every 3s
|
|
1537
|
+
bunx snapshot sync --file ./openapi.json --watch # polls file every 1s
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
Ctrl+C stops the watcher cleanly. On each change, all affected hook files and `src/types/api.ts` are regenerated.
|
|
1541
|
+
|
|
1542
|
+
### Vite plugin
|
|
1543
|
+
|
|
1544
|
+
Run sync automatically as part of the Vite dev server lifecycle — no manual `bun run sync` needed on startup:
|
|
1545
|
+
|
|
1546
|
+
```ts
|
|
1547
|
+
// vite.config.ts
|
|
1548
|
+
import { snapshotSync } from '@lastshotlabs/snapshot/vite'
|
|
1549
|
+
|
|
1550
|
+
export default defineConfig({
|
|
1551
|
+
plugins: [
|
|
1552
|
+
snapshotSync({ file: 'openapi.json' }), // file mode: re-runs on file change in dev
|
|
1553
|
+
// snapshotSync({ apiUrl: 'http://localhost:3000' }), // API mode: runs once on start
|
|
1554
|
+
],
|
|
1555
|
+
})
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
The plugin runs `snapshot sync` on `buildStart` (both `vite dev` and `vite build`). In dev mode with `file` option, it also watches the schema file via Vite's watcher and re-generates on changes. Dropping the schema file into your project for the first time also triggers sync automatically — no dev server restart needed.
|
|
1559
|
+
|
|
1560
|
+
> **Note:** API URL polling in dev mode is not supported by the Vite plugin. For live schema updates from a running API, use `bunx snapshot sync --watch` in a separate terminal instead.
|
|
1561
|
+
|
|
1562
|
+
`vite` must be installed in your project. It is an optional peer dependency of snapshot.
|
|
1563
|
+
|
|
1564
|
+
### Using plain functions in Jotai atoms
|
|
1565
|
+
|
|
1566
|
+
Plain functions live in `src/api/` with no React dependencies — import them directly anywhere:
|
|
1567
|
+
|
|
1568
|
+
```ts
|
|
1569
|
+
// src/store/users.ts
|
|
1570
|
+
import { atom } from 'jotai'
|
|
1571
|
+
import { getUser } from '@api/users'
|
|
1572
|
+
|
|
1573
|
+
const selectedIdAtom = atom<string | null>(null)
|
|
1574
|
+
|
|
1575
|
+
export const selectedUserAtom = atom(async (get) => {
|
|
1576
|
+
const id = get(selectedIdAtom)
|
|
1577
|
+
return id ? getUser(id) : null
|
|
1578
|
+
})
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
---
|
|
1582
|
+
|
|
1583
|
+
## Build
|
|
1584
|
+
|
|
1585
|
+
```bash
|
|
1586
|
+
bun run build # tsup → dist/
|
|
1587
|
+
bun run typecheck # tsc --noEmit
|
|
1588
|
+
bun run dev # tsup --watch
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
Output:
|
|
1592
|
+
- `dist/index.mjs` — library ESM
|
|
1593
|
+
- `dist/index.cjs` — library CommonJS
|
|
1594
|
+
- `dist/index.d.ts` — TypeScript declarations
|
|
1595
|
+
- `dist/cli.mjs` — self-contained CLI executable (bundled, no runtime deps)
|
|
1596
|
+
- `dist/vite.js` — Vite plugin ESM
|
|
1597
|
+
- `dist/vite.cjs` — Vite plugin CommonJS
|
|
1598
|
+
- `dist/vite.d.ts` — Vite plugin TypeScript declarations
|