@kitr/storage 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/devtools-BTqcJ52N.js +143 -0
- package/dist/index.d.ts +202 -0
- package/dist/index.js +367 -0
- package/dist/kitr.d.ts +10 -0
- package/dist/kitr.js +46 -0
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +29 -0
- package/dist/react.d.ts +20 -0
- package/dist/react.js +40 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +14 -0
- package/docs/api.md +466 -0
- package/docs/index.md +808 -0
- package/docs/overview.md +68 -0
- package/package.json +88 -3
package/docs/index.md
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Storage
|
|
3
|
+
description: Save small values in the browser or in cookies with types, and read the same cookies on the server.
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Storage
|
|
8
|
+
|
|
9
|
+
Every app remembers a few things between visits: a collapsed sidebar, recent searches, the chosen language. `@kitr/storage` declares each value once, with a type and a default, and reads and writes it anywhere without touching `localStorage` or `document.cookie`. Bad, old or missing values fall back to the default, other tabs stay in sync, and server rendering never breaks.
|
|
10
|
+
|
|
11
|
+
> [!SUMMARY]
|
|
12
|
+
> Typed, validated values saved in the browser or in cookies, readable on the server too. Add it with `kitr add storage`; the overview is at [/packages/storage](/packages/storage).
|
|
13
|
+
|
|
14
|
+
It builds on [Web Storage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) and [HTTP cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies). Storage items run in the browser; cookies run on both sides.
|
|
15
|
+
|
|
16
|
+
> [!WARNING] Not for secrets
|
|
17
|
+
> Anything in Web Storage is readable by every script on the page. Use [Secure storage](/packages/secure-storage) for login tokens and other secrets.
|
|
18
|
+
|
|
19
|
+
This guide follows Acme's order dashboard at `app.acme.com`: Ada collapses the sidebar, searches for orders, and switches the language to German.
|
|
20
|
+
|
|
21
|
+
## When to use it
|
|
22
|
+
|
|
23
|
+
Use storage for **small values the user's device owns**: preferences, recent searches, a dismissed notice, a draft. They survive a reload, are read synchronously, and are not shared with other devices.
|
|
24
|
+
|
|
25
|
+
For values that live only while the page is open, use [State](/packages/state). For data the server owns, such as the orders themselves, use [Query](/packages/query), or [Offline](/packages/offline) to keep it on the device. For a value every server request shares, use [cache](/packages/cache).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
kitr add storage
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
<details>
|
|
34
|
+
<summary>Everything kitr add changes</summary>
|
|
35
|
+
|
|
36
|
+
- Installs `@kitr/storage` and adds its provider and package to `kitrrc.ts`.
|
|
37
|
+
- Writes `config/storage.ts` (see [Configure](#configure)).
|
|
38
|
+
- Writes `app/storage.ts` with three sample values: `sidebar`, `recentSearches` and `localeCookie`. A server-only project gets only `localeCookie`.
|
|
39
|
+
- Adds a `## kitr: storage` section to `AGENTS.md`.
|
|
40
|
+
|
|
41
|
+
It asks no questions and adds no environment variables or scripts. It does not overwrite `config/storage.ts` or `app/storage.ts` when they exist.
|
|
42
|
+
|
|
43
|
+
The `app`, `fullstack`, `saas`, `site`, `realtime`, `desktop` and `mobile` kits include it, and the [Desktop](/packages/desktop), [Tauri](/packages/tauri) and [Mobile](/packages/mobile) platforms require it.
|
|
44
|
+
|
|
45
|
+
`kitr remove storage` reverses all of it and deletes `config/storage.ts`. It leaves `app/storage.ts`, because you have probably edited it, and lists the files that still import `@kitr/storage`.
|
|
46
|
+
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
The generated `app/storage.ts` in a project with React:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// app/storage.ts
|
|
53
|
+
import { defineCookie, defineStorage } from '@kitr/storage'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every persisted value, in one file, typed. Components read them with
|
|
57
|
+
* `useStorage(item)` / `useCookie(item)` from @kitr/storage/react; plain code with `item.get()`.
|
|
58
|
+
*/
|
|
59
|
+
export const sidebar = defineStorage('sidebar', { default: { collapsed: false } })
|
|
60
|
+
|
|
61
|
+
export const recentSearches = defineStorage<string[]>('recent-searches', {
|
|
62
|
+
default: [],
|
|
63
|
+
area: 'session',
|
|
64
|
+
version: 1,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** Readable on the server too (`localeCookie.get(request.headers)`), so SSR picks the right locale. */
|
|
68
|
+
export const localeCookie = defineCookie('locale', { default: 'en', maxAge: 60 * 60 * 24 * 365 })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## Configure
|
|
73
|
+
|
|
74
|
+
`kitr add storage` writes this file. The prefix comes from the `name` in your `package.json`, without its scope:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// config/storage.ts
|
|
78
|
+
import { defineConfig } from '@kitr/storage'
|
|
79
|
+
|
|
80
|
+
export default defineConfig({
|
|
81
|
+
// Prefixes every storage key and cookie name so several apps on one origin never collide.
|
|
82
|
+
prefix: 'acme:',
|
|
83
|
+
cookie: { path: '/', sameSite: 'lax', secure: import.meta.env?.PROD ?? false },
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Option | Default | Description |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `prefix` | `''` | Added in front of every storage key and cookie name. `sidebar` is stored as `acme:sidebar`. |
|
|
90
|
+
| `cookie` | `{}` | Attributes merged into every cookie this app writes. An item's own options and the options passed to `set()` win over these. |
|
|
91
|
+
| `cookie.path` | `'/'` | The URL path a cookie is sent for. |
|
|
92
|
+
| `cookie.domain` | none | The domain a cookie is sent to. `.acme.com` shares it with every subdomain. |
|
|
93
|
+
| `cookie.maxAge` | none | Lifetime in seconds. Without `maxAge` or `expires`, a cookie lasts until the browser closes. |
|
|
94
|
+
| `cookie.expires` | none | An absolute expiry `Date`. |
|
|
95
|
+
| `cookie.secure` | `false` | Send only over HTTPS. Always on when `sameSite` is `'none'`. |
|
|
96
|
+
| `cookie.httpOnly` | `false` | Hide the cookie from browser scripts. Only cookies set by the server can be `httpOnly`. |
|
|
97
|
+
| `cookie.sameSite` | `'lax'` | `'lax'`, `'strict'` or `'none'`. |
|
|
98
|
+
|
|
99
|
+
`import.meta.env?.PROD` is Vite's production flag, so cookies are `Secure` in production builds and work over plain `http://localhost` in development.
|
|
100
|
+
|
|
101
|
+
> [!WARNING] Changing the prefix
|
|
102
|
+
> Changing `prefix` in a live app makes every saved value look missing, because the keys change. Users see the defaults again; nothing is migrated.
|
|
103
|
+
|
|
104
|
+
## Save your first value
|
|
105
|
+
|
|
106
|
+
Read and write a value from a component with `useStorage`. It returns the value, a setter and a remover, like `useState`:
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
// app/components/sidebar-toggle.tsx
|
|
110
|
+
import { useStorage } from '@kitr/storage/react'
|
|
111
|
+
import { sidebar } from '#app/storage'
|
|
112
|
+
|
|
113
|
+
export function SidebarToggle() {
|
|
114
|
+
const [state, setState] = useStorage(sidebar)
|
|
115
|
+
return (
|
|
116
|
+
<button type="button" onClick={() => setState((s) => ({ collapsed: !s.collapsed }))}>
|
|
117
|
+
{state.collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
118
|
+
</button>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Put `<SidebarToggle />` in your layout and click it. The label changes to "Expand sidebar". Reload the page: it still says "Expand sidebar". In the browser's devtools, under Application → Local Storage, you see one key:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
acme:sidebar {"v":1,"d":{"collapsed":true}}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each item is saved as a small envelope: `v` is the version, `d` the data, and `e` the expiry when the item has a `ttl`. `get()` unwraps it. The setter takes a value or a function of the current value; `remove` deletes the key so the item reads its default again.
|
|
130
|
+
|
|
131
|
+
## Read and write outside React
|
|
132
|
+
|
|
133
|
+
Every item has the same three methods outside components, so services, route loaders and event handlers use the same declaration:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// app/lib/orders/search.ts
|
|
137
|
+
import { recentSearches } from '#app/storage'
|
|
138
|
+
|
|
139
|
+
export function rememberSearch(term: string) {
|
|
140
|
+
recentSearches.set((current) => [term, ...current.filter((t) => t !== term)].slice(0, 5))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
rememberSearch('INV-1042')
|
|
144
|
+
rememberSearch('Grace')
|
|
145
|
+
recentSearches.get() // => ['Grace', 'INV-1042']
|
|
146
|
+
recentSearches.remove()
|
|
147
|
+
recentSearches.get() // => []
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`get()` is synchronous and returns the same object for the same stored string, so it is cheap in a render or a loop. `set()` notifies subscribers in this tab at once and other tabs through the browser's `storage` event.
|
|
151
|
+
|
|
152
|
+
Without React, subscribe. The call returns an unsubscribe function:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// app/lib/layout/body-class.ts
|
|
156
|
+
import { sidebar } from '#app/storage'
|
|
157
|
+
|
|
158
|
+
const stop = sidebar.subscribe((value) => {
|
|
159
|
+
document.body.classList.toggle('sidebar-collapsed', value.collapsed)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// later, when the layout unmounts
|
|
163
|
+
stop()
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Choose local or session storage
|
|
167
|
+
|
|
168
|
+
`area` picks where the value lives. `local` (the default) keeps it until cleared. `session` keeps it for the current tab only.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// app/storage.ts
|
|
172
|
+
import { defineStorage } from '@kitr/storage'
|
|
173
|
+
|
|
174
|
+
// Survives restarts: a preference.
|
|
175
|
+
export const ordersPageSize = defineStorage('orders-page-size', { default: 25 })
|
|
176
|
+
|
|
177
|
+
// Forgotten when the tab closes: the search history of this session.
|
|
178
|
+
export const recentSearches = defineStorage<string[]>('recent-searches', {
|
|
179
|
+
default: [],
|
|
180
|
+
area: 'session',
|
|
181
|
+
version: 1,
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
On desktop and mobile, `session` items live in memory and are gone when the app restarts, like a closed tab.
|
|
186
|
+
|
|
187
|
+
## Validate stored values with a schema
|
|
188
|
+
|
|
189
|
+
Anything in storage can be edited by the user, an old app version or an extension. Pass a [Standard Schema](https://standardschema.dev) such as [zod](https://zod.dev) or [valibot](https://valibot.dev) and every read is checked. A value that fails reads as the default:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// app/storage.ts
|
|
193
|
+
import { defineStorage } from '@kitr/storage'
|
|
194
|
+
import { z } from 'zod'
|
|
195
|
+
|
|
196
|
+
const orderFilters = z.object({
|
|
197
|
+
status: z.enum(['open', 'paid', 'shipped']).nullable(),
|
|
198
|
+
customer: z.string().max(80),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
export const savedFilters = defineStorage('order-filters', {
|
|
202
|
+
default: { status: null, customer: '' } as z.infer<typeof orderFilters>,
|
|
203
|
+
schema: orderFilters,
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// What happens with a tampered value
|
|
209
|
+
localStorage.setItem('acme:order-filters', '{"v":1,"d":{"status":"lost","customer":"Ada"}}')
|
|
210
|
+
savedFilters.get() // => { status: null, customer: '' }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The schema runs on reads, not writes, and must be synchronous: an async schema makes every read return the default.
|
|
214
|
+
|
|
215
|
+
## Change a value's shape with versions
|
|
216
|
+
|
|
217
|
+
When a stored value changes shape, old browsers still hold the old one. Bump `version` and give `migrate` a function that receives the old data and its saved version:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// app/storage.ts
|
|
221
|
+
import { defineStorage } from '@kitr/storage'
|
|
222
|
+
|
|
223
|
+
// Version 1 stored { collapsed: boolean }. Version 2 also remembers the width.
|
|
224
|
+
export const sidebar = defineStorage('sidebar', {
|
|
225
|
+
default: { collapsed: false, width: 280 },
|
|
226
|
+
version: 2,
|
|
227
|
+
migrate: (old, fromVersion) => {
|
|
228
|
+
if (fromVersion === 1) return { ...(old as { collapsed: boolean }), width: 280 }
|
|
229
|
+
return { collapsed: false, width: 280 }
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// Ada's browser still holds a version 1 value
|
|
236
|
+
localStorage.getItem('acme:sidebar') // => '{"v":1,"d":{"collapsed":true}}'
|
|
237
|
+
sidebar.get() // => { collapsed: true, width: 280 }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The migrated value is returned but not written back until the next `set()`. If `migrate` throws or is missing, a value with another version reads as the default. When both `migrate` and `schema` are set, the schema checks the migrated value.
|
|
241
|
+
|
|
242
|
+
> [!WARNING] Bump the version
|
|
243
|
+
> Don't change the shape of `default` without bumping `version`, or old values are read as the new type and `sidebar.get().width` is `undefined`.
|
|
244
|
+
|
|
245
|
+
## Expire a value after a time
|
|
246
|
+
|
|
247
|
+
`ttl` keeps a value for that many milliseconds after each `set()`, then treats it as missing. Use it for things that go stale, such as a dismissed notice that should return next week:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// app/storage.ts
|
|
251
|
+
import { defineStorage } from '@kitr/storage'
|
|
252
|
+
|
|
253
|
+
const sevenDays = 7 * 24 * 60 * 60 * 1000
|
|
254
|
+
|
|
255
|
+
export const invoiceTipDismissed = defineStorage('invoice-tip-dismissed', {
|
|
256
|
+
default: false,
|
|
257
|
+
ttl: sevenDays,
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
invoiceTipDismissed.set(true)
|
|
263
|
+
localStorage.getItem('acme:invoice-tip-dismissed')
|
|
264
|
+
// => '{"v":1,"d":true,"e":1731542400000}'
|
|
265
|
+
invoiceTipDismissed.get() // => true, until seven days from now; then false
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Every `set()` starts a new period. An expired value stays in storage until the next `set()` or `remove()`, but is never returned.
|
|
269
|
+
|
|
270
|
+
## Keep tabs in sync
|
|
271
|
+
|
|
272
|
+
When Ada collapses the sidebar in one tab, every other tab of `app.acme.com` updates too. `useStorage` listens to the browser's [`storage` event](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event), so every `local` item syncs with no code from you.
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
// app/components/sidebar.tsx
|
|
276
|
+
import { useStorage } from '@kitr/storage/react'
|
|
277
|
+
import type { ReactNode } from 'react'
|
|
278
|
+
import { sidebar } from '#app/storage'
|
|
279
|
+
|
|
280
|
+
export function Sidebar({ children }: { children: ReactNode }) {
|
|
281
|
+
const [{ collapsed }] = useStorage(sidebar)
|
|
282
|
+
return <aside className={collapsed ? 'w-14' : 'w-72'}>{children}</aside>
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`session` items don't sync, because each tab has its own session storage. Native drivers have one window, so there is nothing to sync.
|
|
287
|
+
|
|
288
|
+
## Render on the server without a hydration mismatch
|
|
289
|
+
|
|
290
|
+
On the server there is no `localStorage`: `get()` returns the default and `set()` does nothing, so the same module imports safely on both sides. `useStorage` renders the default on the server **and** on the first browser render, then switches to the stored value after hydration:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// app/routes.ts
|
|
294
|
+
r.page('/orders', { lazy: () => import('./pages/orders') })
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```tsx
|
|
298
|
+
// app/pages/orders.tsx
|
|
299
|
+
import { useStorage } from '@kitr/storage/react'
|
|
300
|
+
import { ordersPageSize } from '#app/storage'
|
|
301
|
+
|
|
302
|
+
export default function OrdersPage() {
|
|
303
|
+
const [pageSize, setPageSize] = useStorage(ordersPageSize)
|
|
304
|
+
return (
|
|
305
|
+
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
|
|
306
|
+
<option value={25}>25 per page</option>
|
|
307
|
+
<option value={50}>50 per page</option>
|
|
308
|
+
<option value={100}>100 per page</option>
|
|
309
|
+
</select>
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
If Ada saved 50, the server sends HTML with 25 selected, the browser hydrates with 25, and then shows 50. The markup always matches, so React never reports a hydration error.
|
|
315
|
+
|
|
316
|
+
> [!WARNING] Use the hook in rendered components
|
|
317
|
+
> Don't call `ordersPageSize.get()` in a server-rendered component: the server returns 25, the browser 50, and React reports a mismatch. If the first HTML must show the saved value, use a cookie.
|
|
318
|
+
|
|
319
|
+
## Handle missing, invalid and blocked storage
|
|
320
|
+
|
|
321
|
+
Storage items never throw on a read. Each case below returns the default:
|
|
322
|
+
|
|
323
|
+
| Situation | `get()` returns | `set()` does |
|
|
324
|
+
| --- | --- | --- |
|
|
325
|
+
| Nothing saved yet | the default | saves the value |
|
|
326
|
+
| Saved value isn't valid JSON | the default | overwrites it |
|
|
327
|
+
| Saved value fails `schema` | the default | saves the value |
|
|
328
|
+
| Saved under another `version`, no `migrate` | the default | saves with the new version |
|
|
329
|
+
| Older than its `ttl` | the default | saves with a new expiry |
|
|
330
|
+
| On the server | the default | nothing |
|
|
331
|
+
| Storage blocked (some private modes, sandboxed iframes) | the default | nothing |
|
|
332
|
+
| Storage full | see below | doesn't throw |
|
|
333
|
+
|
|
334
|
+
When the browser refuses a write because storage is full, `set()` catches the error and still notifies subscribers. `get()` returns the new value for the rest of the page's life if the key had none before; otherwise it keeps returning the last saved value.
|
|
335
|
+
|
|
336
|
+
## Declare a cookie
|
|
337
|
+
|
|
338
|
+
A cookie is the one value both browser and server can read, so it suits anything the server needs for the first page, such as Ada's language. Declare it with `defineCookie`, next to the storage items:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
// app/storage.ts
|
|
342
|
+
import { defineCookie } from '@kitr/storage'
|
|
343
|
+
|
|
344
|
+
export type Locale = 'en' | 'de' | 'fr'
|
|
345
|
+
|
|
346
|
+
export const localeCookie = defineCookie<Locale>('locale', {
|
|
347
|
+
default: 'en',
|
|
348
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
export const tablePrefs = defineCookie('table-prefs', {
|
|
352
|
+
default: { dense: false, columns: ['id', 'customer', 'total'] },
|
|
353
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
A string default is stored as plain text (`acme:locale=de`); any other default as URL-encoded JSON. Pass `codec` to choose yourself; `schema` works as on storage items. Every `CookieOptions` field (`path`, `domain`, `maxAge`, `expires`, `secure`, `httpOnly`, `sameSite`) can be set on the item and wins over `cookie` in `config/storage.ts`.
|
|
358
|
+
|
|
359
|
+
> [!WARNING] Keep cookies small
|
|
360
|
+
> The browser sends every cookie with every request, and caps one at about 4 KB. Put large or private values in storage items or on the server.
|
|
361
|
+
|
|
362
|
+
## Read and write cookies in a component
|
|
363
|
+
|
|
364
|
+
In a component, `useCookie` works like `useStorage`:
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
// app/components/locale-switch.tsx
|
|
368
|
+
import { useCookie } from '@kitr/storage/react'
|
|
369
|
+
import { type Locale, localeCookie } from '#app/storage'
|
|
370
|
+
|
|
371
|
+
export function LocaleSwitch() {
|
|
372
|
+
const [locale, setLocale] = useCookie(localeCookie)
|
|
373
|
+
return (
|
|
374
|
+
<select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}>
|
|
375
|
+
<option value="en">English</option>
|
|
376
|
+
<option value="de">Deutsch</option>
|
|
377
|
+
<option value="fr">Français</option>
|
|
378
|
+
</select>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Ada picks Deutsch. The browser now holds:
|
|
384
|
+
|
|
385
|
+
```text
|
|
386
|
+
acme:locale=de; Max-Age=31536000; Path=/; SameSite=Lax
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The next request to the server carries `Cookie: acme:locale=de`. Outside React, call the same methods on the item:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
// app/lib/settings/locale.ts
|
|
393
|
+
import { localeCookie } from '#app/storage'
|
|
394
|
+
|
|
395
|
+
localeCookie.set('fr')
|
|
396
|
+
localeCookie.get() // => 'fr'
|
|
397
|
+
localeCookie.remove()
|
|
398
|
+
localeCookie.get() // => 'en'
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
In the browser, `get()` with no argument reads `document.cookie`. `set()` always writes a script-readable cookie, even for `httpOnly: true` items, because browsers refuse script writes of `httpOnly`. Cookies don't fire events across tabs; another tab sees the new value on its next read.
|
|
402
|
+
|
|
403
|
+
If other code writes `document.cookie`, such as a vendor consent banner, call `notifyCookiesChanged()` so mounted `useCookie` hooks re-read:
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// app/lib/vendor-banner.ts
|
|
407
|
+
import { notifyCookiesChanged } from '@kitr/storage'
|
|
408
|
+
|
|
409
|
+
window.addEventListener('vendor:consent-saved', () => notifyCookiesChanged())
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Render cookies during server rendering
|
|
413
|
+
|
|
414
|
+
During server rendering `useCookie` needs the request's `Cookie` header. Wrap the tree once in `<CookieHeader>` in the root route. In a [TanStack Start](https://tanstack.com/start/latest) app, read the header in a server function and pass it from the loader:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
// app/routes.ts
|
|
418
|
+
import { head, readCookieHeader, RootDocument } from './document'
|
|
419
|
+
|
|
420
|
+
r.root({ head, loader: () => readCookieHeader(), component: RootDocument })
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
// app/document.tsx
|
|
425
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
426
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
427
|
+
import { getRouteApi, HeadContent, Outlet, Scripts } from '@kitr/router'
|
|
428
|
+
import { CookieHeader } from '@kitr/storage/react'
|
|
429
|
+
|
|
430
|
+
export const readCookieHeader = createServerFn({ method: 'GET' }).handler(() =>
|
|
431
|
+
getRequest().headers.get('cookie'),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
const root = getRouteApi('__root__')
|
|
435
|
+
|
|
436
|
+
export function RootDocument() {
|
|
437
|
+
const cookieHeader = root.useLoaderData()
|
|
438
|
+
return (
|
|
439
|
+
<html lang="en">
|
|
440
|
+
<head>
|
|
441
|
+
<HeadContent />
|
|
442
|
+
</head>
|
|
443
|
+
<body>
|
|
444
|
+
<CookieHeader value={cookieHeader}>
|
|
445
|
+
<Outlet />
|
|
446
|
+
</CookieHeader>
|
|
447
|
+
<Scripts />
|
|
448
|
+
</body>
|
|
449
|
+
</html>
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
With the wrapper, `<LocaleSwitch />` renders `Deutsch` in the first HTML for Ada. Without it, the server renders `English` and the browser corrects it after hydration, as a flash.
|
|
455
|
+
|
|
456
|
+
For one cookie in a loader, skip the wrapper: `get()` accepts a `Headers` object, a raw `Cookie` string, or anything with a `cookie` field:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
// app/pages/orders.tsx
|
|
460
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
461
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
462
|
+
import { localeCookie } from '#app/storage'
|
|
463
|
+
|
|
464
|
+
export const readLocale = createServerFn({ method: 'GET' }).handler(() =>
|
|
465
|
+
localeCookie.get(getRequest().headers),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
localeCookie.get('acme:locale=de; acme:table-prefs=%7B%7D') // => 'de'
|
|
469
|
+
localeCookie.get(null) // => 'en'
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Read and set cookies on the server
|
|
473
|
+
|
|
474
|
+
On the server, `cookies()` from `@kitr/storage/server` gives typed access to one request's cookies. Pass it the h3 event. Reads come from the `Cookie` header; writes append `Set-Cookie` to the response:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// server/controllers/settings-controller.ts
|
|
478
|
+
import type { HttpContext } from '@kitr/api'
|
|
479
|
+
import { cookies } from '@kitr/storage/server'
|
|
480
|
+
import { type Locale, localeCookie } from '#app/storage'
|
|
481
|
+
|
|
482
|
+
export default class SettingsController {
|
|
483
|
+
async show({ event }: HttpContext) {
|
|
484
|
+
const jar = cookies(event)
|
|
485
|
+
return { locale: jar.get(localeCookie) }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async update({ event, body }: HttpContext<{}, {}, { locale: Locale }>) {
|
|
489
|
+
const jar = cookies(event)
|
|
490
|
+
jar.set(localeCookie, body.locale)
|
|
491
|
+
return { locale: body.locale }
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`PUT /api/settings` with `{ "locale": "de" }` answers with this header:
|
|
497
|
+
|
|
498
|
+
```http
|
|
499
|
+
Set-Cookie: acme:locale=de; Max-Age=31536000; Path=/; SameSite=Lax
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`jar.remove(item)` appends a header that expires the cookie. `jar.header` is the raw `Cookie` header, which you can pass to `<CookieHeader>`.
|
|
503
|
+
|
|
504
|
+
With only Fetch primitives, such as a Cloudflare Worker, pass the `Request` and the response `Headers`:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
// server/edge/locale.ts
|
|
508
|
+
import { cookies } from '@kitr/storage/server'
|
|
509
|
+
import { localeCookie } from '#app/storage'
|
|
510
|
+
|
|
511
|
+
export function handleLocale(request: Request): Response {
|
|
512
|
+
const headers = new Headers({ 'content-type': 'application/json' })
|
|
513
|
+
const jar = cookies(request, headers)
|
|
514
|
+
const locale = jar.get(localeCookie)
|
|
515
|
+
if (locale === 'en') jar.set(localeCookie, 'de')
|
|
516
|
+
return new Response(JSON.stringify({ locale }), { headers })
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## Set an httpOnly cookie from the server
|
|
521
|
+
|
|
522
|
+
A cookie that scripts must never read, such as an opaque session id, is `httpOnly` and set only by the server. Declare it with `httpOnly: true`:
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
// server/auth/session-cookie.ts
|
|
526
|
+
import { defineCookie } from '@kitr/storage'
|
|
527
|
+
|
|
528
|
+
export const sessionCookie = defineCookie('sid', {
|
|
529
|
+
default: '',
|
|
530
|
+
httpOnly: true,
|
|
531
|
+
secure: true,
|
|
532
|
+
sameSite: 'strict',
|
|
533
|
+
maxAge: 60 * 60 * 8,
|
|
534
|
+
})
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
// server/controllers/sessions-controller.ts
|
|
539
|
+
import type { HttpContext } from '@kitr/api'
|
|
540
|
+
import { cookies } from '@kitr/storage/server'
|
|
541
|
+
import { sessionCookie } from '#server/auth/session-cookie'
|
|
542
|
+
|
|
543
|
+
export default class SessionsController {
|
|
544
|
+
async store({ event }: HttpContext) {
|
|
545
|
+
const sessionId = crypto.randomUUID()
|
|
546
|
+
cookies(event).set(sessionCookie, sessionId)
|
|
547
|
+
return { ok: true }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async destroy({ event }: HttpContext) {
|
|
551
|
+
cookies(event).remove(sessionCookie)
|
|
552
|
+
return { ok: true }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```http
|
|
558
|
+
Set-Cookie: acme:sid=3f2b...; Max-Age=28800; Path=/; Secure; HttpOnly; SameSite=Strict
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
Options passed to `set()` win over the item's, so `jar.set(sessionCookie, id, { maxAge: 60 * 60 * 24 * 30 })` writes a "remember me" cookie from the same declaration.
|
|
562
|
+
|
|
563
|
+
> [!WARNING] Cookies are not signed
|
|
564
|
+
> Anyone can edit their own cookies, so never trust one as proof of who someone is: store a random id and look it up on the server. For sign-in, use [Auth](/packages/auth).
|
|
565
|
+
|
|
566
|
+
## Share cookies across subdomains
|
|
567
|
+
|
|
568
|
+
To read one cookie on `acme.com`, `app.acme.com` and `billing.acme.com`, set `domain` to the parent domain, in config for every cookie or on one item:
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
// config/storage.ts
|
|
572
|
+
import { defineConfig } from '@kitr/storage'
|
|
573
|
+
|
|
574
|
+
export default defineConfig({
|
|
575
|
+
prefix: 'acme:',
|
|
576
|
+
cookie: {
|
|
577
|
+
path: '/',
|
|
578
|
+
domain: '.acme.com',
|
|
579
|
+
sameSite: 'lax',
|
|
580
|
+
secure: import.meta.env?.PROD ?? false,
|
|
581
|
+
},
|
|
582
|
+
})
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Every `Set-Cookie` now ends with `Domain=.acme.com; Path=/; ...`. A cookie can only be removed with the `domain` it was set with, so keep it in config.
|
|
586
|
+
|
|
587
|
+
## Build and parse cookie headers by hand
|
|
588
|
+
|
|
589
|
+
`parseCookies` and `serializeCookie` are what every cookie item uses. Use them for a cookie that isn't declared, such as one a third party set:
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
// server/lib/referrer.ts
|
|
593
|
+
import { parseCookies, serializeCookie } from '@kitr/storage'
|
|
594
|
+
|
|
595
|
+
parseCookies('acme:locale=de; utm_source=newsletter; broken')
|
|
596
|
+
// => { 'acme:locale': 'de', utm_source: 'newsletter' }
|
|
597
|
+
|
|
598
|
+
serializeCookie('utm_source', 'newsletter', { maxAge: 3600 })
|
|
599
|
+
// => 'utm_source=newsletter; Max-Age=3600; Path=/; SameSite=Lax'
|
|
600
|
+
|
|
601
|
+
serializeCookie('utm_source', null)
|
|
602
|
+
// => 'utm_source=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax'
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
`parseCookies` skips parts without `=`, trims spaces and URL-decodes values. `serializeCookie` URL-encodes the value and adds `Path=/` and `SameSite=Lax` plus the config defaults, but not the `prefix`; only items add that. An item can do the same: `localeCookie.serialize('de')` and `localeCookie.decode('de')`.
|
|
606
|
+
|
|
607
|
+
## Store values natively on desktop and mobile
|
|
608
|
+
|
|
609
|
+
Storage items use the platform's own store when a platform registers a driver, and Web Storage otherwise. You declare and read items the same way everywhere. The provider hydrates the native store before the first render, so `get()` stays synchronous.
|
|
610
|
+
|
|
611
|
+
| Where the app runs | Driver | Where `local` items are saved |
|
|
612
|
+
| --- | --- | --- |
|
|
613
|
+
| Browser | `web:local` | `localStorage` |
|
|
614
|
+
| Desktop (Electron) | `electron:ipc` | `kitr-storage.json` in Electron's `userData` folder |
|
|
615
|
+
| Tauri | `tauri:store` | `kitr.json` in the app data folder |
|
|
616
|
+
| Mobile, Capacitor | `capacitor:preferences` | Capacitor Preferences |
|
|
617
|
+
| Server, or storage blocked | none | nothing; items read their defaults |
|
|
618
|
+
|
|
619
|
+
The platform's provider (`@kitr/desktop/provider`, `@kitr/tauri/provider` or `@kitr/mobile/provider`) can be listed before or after `@kitr/storage/provider` in `kitrrc.ts`.
|
|
620
|
+
|
|
621
|
+
On native platforms `session` items use an in-memory driver. Native writes happen in the background; a failed write is logged as `[@kitr/storage] <driver>: <error>` and the in-memory value stays.
|
|
622
|
+
|
|
623
|
+
### Browser
|
|
624
|
+
|
|
625
|
+
Nothing to set up: `local` items use `localStorage` and `session` items `sessionStorage`, about 5 MB per origin. Inspect them in devtools under Application → Local Storage.
|
|
626
|
+
|
|
627
|
+
### Desktop with Electron
|
|
628
|
+
|
|
629
|
+
[Desktop](/packages/desktop) with Electron registers `electronStorageDriver()`, which sends writes to the main process over IPC. The desktop scaffold's `main.ts` serves them with one call:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
// platforms/app/main.ts
|
|
633
|
+
import { app, ipcMain } from 'electron'
|
|
634
|
+
import { handleKitrIpc } from '@kitr/desktop/main'
|
|
635
|
+
|
|
636
|
+
app.whenReady().then(() => {
|
|
637
|
+
handleKitrIpc(ipcMain, app)
|
|
638
|
+
// ... create the window
|
|
639
|
+
})
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Values land in `kitr-storage.json` in [`app.getPath('userData')`](https://www.electronjs.org/docs/latest/api/app#appgetpathname). Pass `{ storageFile }` as the third argument to choose another file.
|
|
643
|
+
|
|
644
|
+
### Desktop with Tauri
|
|
645
|
+
|
|
646
|
+
The [Tauri](/packages/tauri) provider registers `tauriStoreDriver()` on the [store plugin](https://v2.tauri.app/plugin/store/), writing `kitr.json` in the app data folder. `kitr add tauri` sets up the plugin and permission; your items don't change.
|
|
647
|
+
|
|
648
|
+
### Mobile with Capacitor
|
|
649
|
+
|
|
650
|
+
[Mobile](/packages/mobile) registers `preferencesDriver()` on [Capacitor Preferences](https://capacitorjs.com/docs/apis/preferences). Preferences lives in the app's own data, so saved values survive when iOS or Android clears a WebView's `localStorage`.
|
|
651
|
+
|
|
652
|
+
## Testing
|
|
653
|
+
|
|
654
|
+
Storage items are module-level values, so a test reads and writes them directly. In Vitest's Node environment there is no `localStorage`: give items an in-memory driver with `setStorageDriver(createMemoryStorageDriver())`. Cookies need no setup; pass a `Request` and a `Headers` object to `cookies()`.
|
|
655
|
+
|
|
656
|
+
```ts
|
|
657
|
+
// app/storage.test.ts
|
|
658
|
+
import { createTestApp } from '@kitr/testing'
|
|
659
|
+
import {
|
|
660
|
+
createMemoryStorageDriver,
|
|
661
|
+
getStorageDriver,
|
|
662
|
+
setStorageDriver,
|
|
663
|
+
setStoragePrefix,
|
|
664
|
+
} from '@kitr/storage'
|
|
665
|
+
import storageProvider from '@kitr/storage/provider'
|
|
666
|
+
import { cookies } from '@kitr/storage/server'
|
|
667
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
668
|
+
import { localeCookie, recentSearches, sidebar } from '#app/storage'
|
|
669
|
+
|
|
670
|
+
beforeEach(() => {
|
|
671
|
+
setStorageDriver(createMemoryStorageDriver())
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
afterEach(() => {
|
|
675
|
+
setStoragePrefix('')
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
describe('app/storage', () => {
|
|
679
|
+
it('remembers the sidebar under the configured prefix', async () => {
|
|
680
|
+
await createTestApp({
|
|
681
|
+
providers: [storageProvider],
|
|
682
|
+
config: { storage: { prefix: 'acme:' } },
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
expect(sidebar.get()).toEqual({ collapsed: false })
|
|
686
|
+
sidebar.set((s) => ({ collapsed: !s.collapsed }))
|
|
687
|
+
expect(sidebar.get()).toEqual({ collapsed: true })
|
|
688
|
+
expect(getStorageDriver('local')?.get('acme:sidebar')).toBe('{"v":1,"d":{"collapsed":true}}')
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('keeps recent searches in the session area', () => {
|
|
692
|
+
recentSearches.set(['INV-1042'])
|
|
693
|
+
expect(recentSearches.get()).toEqual(['INV-1042'])
|
|
694
|
+
expect(getStorageDriver('session')?.keys()).toEqual(['recent-searches'])
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('reads and sets the locale cookie on the server', () => {
|
|
698
|
+
const request = new Request('https://app.acme.com/orders', {
|
|
699
|
+
headers: { cookie: 'locale=de' },
|
|
700
|
+
})
|
|
701
|
+
const response = new Headers()
|
|
702
|
+
const jar = cookies(request, response)
|
|
703
|
+
|
|
704
|
+
expect(jar.get(localeCookie)).toBe('de')
|
|
705
|
+
jar.set(localeCookie, 'fr')
|
|
706
|
+
expect(response.get('set-cookie')).toBe('locale=fr; Max-Age=31536000; Path=/; SameSite=Lax')
|
|
707
|
+
})
|
|
708
|
+
})
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
The prefix and cookie defaults are module state too: reset them in `afterEach` with `setStoragePrefix('')` and `setCookieDefaults({})`, or later tests see `acme:` keys.
|
|
712
|
+
|
|
713
|
+
For components and cross-tab behaviour, use a DOM environment with `// @vitest-environment happy-dom` on the first line, clear `localStorage` between tests, and render with [Testing Library](https://testing-library.com/docs/react-testing-library/intro):
|
|
714
|
+
|
|
715
|
+
```tsx
|
|
716
|
+
// app/components/sidebar-toggle.test.tsx
|
|
717
|
+
// @vitest-environment happy-dom
|
|
718
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
719
|
+
import { afterEach, expect, it } from 'vitest'
|
|
720
|
+
import { SidebarToggle } from '#app/components/sidebar-toggle'
|
|
721
|
+
|
|
722
|
+
afterEach(() => localStorage.clear())
|
|
723
|
+
|
|
724
|
+
it('collapses and remembers it', () => {
|
|
725
|
+
render(<SidebarToggle />)
|
|
726
|
+
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
|
727
|
+
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeTruthy()
|
|
728
|
+
expect(localStorage.getItem('sidebar')).toBe('{"v":1,"d":{"collapsed":true}}')
|
|
729
|
+
})
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
To check the pre-hydration HTML, call `renderToString`: a `useStorage` component renders the default, and a `useCookie` component inside `<CookieHeader value="locale=de">` renders `de`.
|
|
733
|
+
|
|
734
|
+
## Extend
|
|
735
|
+
|
|
736
|
+
One interface plugs into storage: `StorageDriver`, the synchronous store behind `defineStorage` items on a platform. Its full type is in the [API reference](/packages/storage/api).
|
|
737
|
+
|
|
738
|
+
### Write a storage driver
|
|
739
|
+
|
|
740
|
+
| Member | Description |
|
|
741
|
+
| --- | --- |
|
|
742
|
+
| `name` | Shown in the devtools log. |
|
|
743
|
+
| `hydrate()` | Optional. Load every key into memory before the first render. The provider awaits it in `boot()`. |
|
|
744
|
+
| `get(key)` | The raw string, or `null`. Must answer synchronously. |
|
|
745
|
+
| `set(key, raw)` | Store a raw string. |
|
|
746
|
+
| `remove(key)` | Delete one key. |
|
|
747
|
+
| `keys()` | Every stored key, for the devtools panel. |
|
|
748
|
+
| `subscribe(listener)` | Optional. Call `listener(key)` when something outside this window changed a key (`null` for all keys); return an unsubscribe function. |
|
|
749
|
+
|
|
750
|
+
Most native stores are async. `createAsyncStorageDriver` wraps one: `load` fills an in-memory cache at boot, and `write` and `erase` run in the background, so reads stay synchronous. This driver uses [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) through [idb-keyval](https://github.com/jakearchibald/idb-keyval):
|
|
751
|
+
|
|
752
|
+
```ts
|
|
753
|
+
// app/providers/idb-storage.ts
|
|
754
|
+
import { defineProvider } from '@kitr/kitr/app'
|
|
755
|
+
import { createAsyncStorageDriver } from '@kitr/storage'
|
|
756
|
+
import { createStore, del, entries, set } from 'idb-keyval'
|
|
757
|
+
|
|
758
|
+
const store = createStore('acme', 'storage')
|
|
759
|
+
|
|
760
|
+
export const idbStorageDriver = createAsyncStorageDriver({
|
|
761
|
+
name: 'idb:acme',
|
|
762
|
+
load: async () => {
|
|
763
|
+
const out: Record<string, string> = {}
|
|
764
|
+
for (const [key, value] of await entries<string, string>(store)) out[key] = value
|
|
765
|
+
return out
|
|
766
|
+
},
|
|
767
|
+
write: (key, raw) => set(key, raw, store),
|
|
768
|
+
erase: (key) => del(key, store),
|
|
769
|
+
onError: (error) => console.warn('[acme] could not save to IndexedDB', error),
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
export default defineProvider({
|
|
773
|
+
name: 'idb-storage',
|
|
774
|
+
register(app) {
|
|
775
|
+
app.drivers.register('storage', idbStorageDriver, { platform: 'web' })
|
|
776
|
+
},
|
|
777
|
+
})
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
Register it in `kitrrc.ts`; its place in the list does not matter:
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
// kitrrc.ts
|
|
784
|
+
import { defineKitrConfig } from '@kitr/kitr/app'
|
|
785
|
+
|
|
786
|
+
export default defineKitrConfig({
|
|
787
|
+
providers: [
|
|
788
|
+
() => import('#app/providers/idb-storage'),
|
|
789
|
+
() => import('@kitr/storage/provider'),
|
|
790
|
+
// ...
|
|
791
|
+
],
|
|
792
|
+
// ...
|
|
793
|
+
})
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
When several drivers are registered, the highest `priority` wins; platform drivers default to `10`. To replace both areas directly, such as in a test, call `setStorageDriver(driver, { session: otherDriver })`.
|
|
797
|
+
|
|
798
|
+
## Devtools
|
|
799
|
+
|
|
800
|
+
With [Devtools](/packages/devtools) installed, the provider adds a **Storage** panel in development. It lists every key in local storage, session storage and `document.cookie`, with a **remove** button per row and **Clear localStorage** and **Clear sessionStorage** actions.
|
|
801
|
+
|
|
802
|
+
At boot the provider logs the active driver (`web:local`, a native driver's name, or `none (server)`) to the Log panel under `storage`. Write your own lines with `logStorage(app, 'label', data)`.
|
|
803
|
+
|
|
804
|
+
## Next steps
|
|
805
|
+
|
|
806
|
+
- [API reference](/packages/storage/api): every export with its signature.
|
|
807
|
+
- [Secure storage](/packages/secure-storage): tokens and secrets in the OS vault.
|
|
808
|
+
- [Offline](/packages/offline): keep loaded query data on the device.
|