@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @kitr/storage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Storage.** Save small values in the browser or in cookies with types, and read the same cookies on the server.
4
+
5
+ ```sh
6
+ pnpm kitr add storage
7
+ ```
8
+
9
+ `kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/storage`.
10
+
11
+ Built on localStorage, cookies.
12
+
13
+ Read the guide at https://kitr.dev/packages/storage.
14
+
15
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
@@ -0,0 +1,143 @@
1
+ import { getStorageDriver, listStorage, parseCookies, removeStorageKey, serializeCookie } from "./index.js";
2
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ // src/devtools.tsx
5
+ /** The Storage panel: localStorage, sessionStorage and cookies with edit and remove buttons. */
6
+ function panels(ui) {
7
+ const { Button, Empty, preview, Row, Section, Toolbar } = ui;
8
+ function pretty(raw) {
9
+ if (raw === null) return "";
10
+ try {
11
+ return JSON.stringify(JSON.parse(raw), null, 2);
12
+ } catch {
13
+ return raw;
14
+ }
15
+ }
16
+ function writeCookie(key, value) {
17
+ document.cookie = serializeCookie(key, value);
18
+ }
19
+ function sources() {
20
+ const area = (name, title) => {
21
+ const rows = listStorage(name);
22
+ return {
23
+ title,
24
+ rows,
25
+ save: (key, value) => getStorageDriver(name)?.set(key, value),
26
+ remove: (key) => removeStorageKey(name, key),
27
+ clear: () => {
28
+ for (const { key } of rows) removeStorageKey(name, key);
29
+ }
30
+ };
31
+ };
32
+ const cookies = typeof document === "undefined" ? {} : parseCookies(document.cookie);
33
+ return [
34
+ area("local", "localStorage"),
35
+ area("session", "sessionStorage"),
36
+ {
37
+ title: "cookies",
38
+ rows: Object.entries(cookies).map(([key, raw]) => ({
39
+ key,
40
+ raw
41
+ })),
42
+ save: writeCookie,
43
+ remove: (key) => writeCookie(key, null),
44
+ clear: () => {
45
+ for (const key of Object.keys(cookies)) writeCookie(key, null);
46
+ }
47
+ }
48
+ ];
49
+ }
50
+ function StoragePanel({ app }) {
51
+ const hub = app.container.use("devtools");
52
+ useSyncExternalStore(hub.subscribe, () => hub.version, () => 0);
53
+ const [, tick] = useState(0);
54
+ const refresh = useCallback(() => tick((n) => n + 1), []);
55
+ useEffect(() => {
56
+ window.addEventListener("storage", refresh);
57
+ return () => window.removeEventListener("storage", refresh);
58
+ }, [refresh]);
59
+ return /* @__PURE__ */ jsx("div", {
60
+ className: "kitr-dt-stack",
61
+ children: sources().map((source) => /* @__PURE__ */ jsx(Section, {
62
+ title: source.title,
63
+ count: source.rows.length,
64
+ children: source.rows.length === 0 ? /* @__PURE__ */ jsx(Empty, { children: "Empty" }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Toolbar, { children: /* @__PURE__ */ jsx(Button, {
65
+ danger: true,
66
+ onClick: () => {
67
+ source.clear();
68
+ refresh();
69
+ },
70
+ children: "Clear"
71
+ }) }), source.rows.map((row) => /* @__PURE__ */ jsx(ValueRow, {
72
+ item: row,
73
+ onSave: (value) => {
74
+ source.save(row.key, value);
75
+ refresh();
76
+ },
77
+ onRemove: () => {
78
+ source.remove(row.key);
79
+ refresh();
80
+ }
81
+ }, row.key))] })
82
+ }, source.title))
83
+ });
84
+ }
85
+ function ValueRow({ item, onSave, onRemove }) {
86
+ const [draft, setDraft] = useState(() => pretty(item.raw));
87
+ useEffect(() => setDraft(pretty(item.raw)), [item.raw]);
88
+ return /* @__PURE__ */ jsxs(Row, {
89
+ head: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
90
+ className: "kitr-dt-label kitr-dt-key",
91
+ children: item.key
92
+ }), /* @__PURE__ */ jsx("span", {
93
+ className: "kitr-dt-preview",
94
+ children: preview(item.raw ?? "")
95
+ })] }),
96
+ children: [/* @__PURE__ */ jsx("textarea", {
97
+ className: "kitr-dt-input",
98
+ "aria-label": `Value of ${item.key}`,
99
+ rows: 4,
100
+ value: draft,
101
+ onChange: (e) => setDraft(e.target.value)
102
+ }), /* @__PURE__ */ jsxs(Toolbar, { children: [/* @__PURE__ */ jsx(Button, {
103
+ onClick: () => onSave(draft),
104
+ children: "Save"
105
+ }), /* @__PURE__ */ jsx(Button, {
106
+ danger: true,
107
+ onClick: onRemove,
108
+ children: "Remove"
109
+ })] })]
110
+ });
111
+ }
112
+ return [{
113
+ id: "storage",
114
+ label: "Storage",
115
+ group: "data",
116
+ badge: () => {
117
+ const total = listStorage("local").length + listStorage("session").length;
118
+ return total ? {
119
+ value: total,
120
+ tone: "muted"
121
+ } : null;
122
+ },
123
+ render: (app) => /* @__PURE__ */ jsx(StoragePanel, { app }),
124
+ actions: [{
125
+ id: "clear-local",
126
+ label: "Clear local",
127
+ hint: "localStorage",
128
+ danger: true,
129
+ run: () => {
130
+ for (const { key } of listStorage("local")) removeStorageKey("local", key);
131
+ }
132
+ }, {
133
+ id: "clear-session",
134
+ label: "Clear session",
135
+ hint: "sessionStorage",
136
+ danger: true,
137
+ run: () => {
138
+ for (const { key } of listStorage("session")) removeStorageKey("session", key);
139
+ }
140
+ }]
141
+ }];
142
+ }
143
+ export { panels };
@@ -0,0 +1,202 @@
1
+ import { KitrApp, StandardSchemaV1 } from "@kitr/core";
2
+ /** Options for `config/storage.ts`: a key prefix and cookie defaults. */
3
+ export interface StorageConfig {
4
+ /** Prefix for every storage key and cookie name, e.g. `myapp:`. Default none. */
5
+ prefix?: string;
6
+ /** Defaults merged into every cookie. */
7
+ cookie?: Omit<CookieOptions, 'name'>;
8
+ }
9
+ /**
10
+ * What a platform supplies to back `defineStorage` items: Capacitor Preferences on mobile,
11
+ * the Tauri store or an Electron JSON file on desktop, Web Storage in the browser.
12
+ * Reads are synchronous from a cache `hydrate()` fills at boot; writes go through in the background.
13
+ */
14
+ export interface StorageDriver {
15
+ /** Shown in the devtools log, e.g. `web:local` or `capacitor:preferences`. */
16
+ name: string;
17
+ /** Load every stored key into memory before the first render. */
18
+ hydrate?(): Promise<void>;
19
+ /** The raw string stored under `key`, or null. Must answer synchronously. */
20
+ get(key: string): string | null;
21
+ /** Store a raw string. May persist in the background. */
22
+ set(key: string, raw: string): void;
23
+ /** Delete one key. */
24
+ remove(key: string): void;
25
+ /** Every stored key, for the devtools panel. */
26
+ keys(): string[];
27
+ /** Changes made elsewhere (another tab, the native side). */
28
+ subscribe?(listener: (key: string | null) => void): () => void;
29
+ }
30
+ declare module '@kitr/core' {
31
+ interface ConfigRegistry {
32
+ storage: StorageConfig;
33
+ }
34
+ interface DriverTypes {
35
+ storage: StorageDriver;
36
+ }
37
+ }
38
+ /** Type-checks `config/storage.ts`. Returns the object unchanged. */
39
+ export declare const defineConfig: <const C extends StorageConfig>(config: C) => C;
40
+ /** Turns a value into the string that is stored and back. Pass one as `codec` to change the format. */
41
+ export interface Codec<T> {
42
+ /** Value to stored string. */
43
+ encode(value: T): string;
44
+ /** Stored string to value. Throwing makes the item fall back to its default. */
45
+ decode(raw: string): T;
46
+ }
47
+ /** The default codec for objects: `JSON.stringify` and `JSON.parse`. */
48
+ export declare const jsonCodec: <T>() => Codec<T>;
49
+ /** Stores strings as they are, with no quotes. The default for cookies whose default is a string. */
50
+ export declare const stringCodec: Codec<string>;
51
+ /** `local` keeps a value until it is cleared; `session` keeps it for the current tab. */
52
+ export type StorageArea = 'local' | 'session';
53
+ /** Options for `defineStorage`: the default, the area, and how stored values are checked. */
54
+ export interface StorageItemOptions<T> {
55
+ /** Returned when the key is missing, unparsable, invalid or on the server. */
56
+ default: T;
57
+ /** Where it is stored. Default `local`. */
58
+ area?: StorageArea;
59
+ /** Validate what comes back from storage (Standard Schema: zod, valibot...). Invalid → default. */
60
+ schema?: StandardSchemaV1<unknown, T>;
61
+ /** Format of the stored envelope. Default: JSON. */
62
+ codec?: Codec<T>;
63
+ /** Bump when the shape changes; stored values with another version are migrated or dropped. */
64
+ version?: number;
65
+ /** Converts a value stored under an older `version`. Without it, old values are dropped. */
66
+ migrate?: (raw: unknown, fromVersion: number) => T;
67
+ /** Keep this many ms, then treat as missing. Stored beside the value. */
68
+ ttl?: number;
69
+ }
70
+ /** A typed value in local or session storage, returned by `defineStorage`. */
71
+ export interface StorageItem<T> {
72
+ /** The name it was defined with, without the config prefix. */
73
+ readonly key: string;
74
+ /** Which area it lives in. */
75
+ readonly area: StorageArea;
76
+ /** The stored value, or the default when it is missing, invalid, expired or on the server. */
77
+ get(): T;
78
+ /** Store a value, or compute it from the current one. A no-op on the server. */
79
+ set(value: T | ((current: T) => T)): void;
80
+ /** Delete the stored value so `get()` returns the default. */
81
+ remove(): void;
82
+ /** Fires on set/remove here and in other tabs (`storage` event). */
83
+ subscribe(listener: (value: T) => void): () => void;
84
+ /** The default, for server snapshots. */
85
+ readonly default: T;
86
+ }
87
+ /** Web Storage as a driver. Null outside a browser, so items fall back to their defaults. */
88
+ export declare function createWebStorageDriver(name: StorageArea): StorageDriver | null;
89
+ /** In-memory driver: tests, the server, and `session` items on native platforms. */
90
+ export declare function createMemoryStorageDriver(name?: string): StorageDriver;
91
+ /**
92
+ * Build a driver over an async native store. `load` runs once at boot and fills the cache;
93
+ * `write`/`erase` run in the background after each change, so reads stay synchronous.
94
+ */
95
+ export declare function createAsyncStorageDriver(options: {
96
+ name: string;
97
+ load(): Promise<Record<string, string>>;
98
+ write(key: string, raw: string): Promise<void>;
99
+ erase(key: string): Promise<void>;
100
+ onError?: (error: unknown) => void;
101
+ }): StorageDriver;
102
+ /**
103
+ * Replace the Web Storage backing with a platform driver. Called by the storage provider when a
104
+ * platform registered one (`app.drivers.register('storage', driver)`); `session` items keep an
105
+ * in-memory driver unless `areas` says otherwise.
106
+ */
107
+ export declare function setStorageDriver(driver: StorageDriver, areas?: {
108
+ local?: StorageDriver;
109
+ session?: StorageDriver;
110
+ }): void;
111
+ /** The driver behind an area: the platform driver, Web Storage, or null on the server. */
112
+ export declare function getStorageDriver(name: StorageArea): StorageDriver | null;
113
+ /** Set by the provider from `config/storage.ts`; items created later pick it up. */
114
+ export declare function setStoragePrefix(prefix: string): void;
115
+ /**
116
+ * A typed item in localStorage or sessionStorage.
117
+ *
118
+ * const sidebar = defineStorage('sidebar', { default: { open: true } })
119
+ * sidebar.get().open; sidebar.set((s) => ({ ...s, open: false }))
120
+ *
121
+ * Safe everywhere: on the server (or when storage is blocked) `get()` returns the default
122
+ * and `set()` is a no-op, so the same module renders on both sides.
123
+ */
124
+ export declare function defineStorage<T>(name: string, options: StorageItemOptions<T>): StorageItem<T>;
125
+ /** Attributes written into `Set-Cookie`. `path: '/'` and `sameSite: 'lax'` apply unless overridden. */
126
+ export interface CookieOptions {
127
+ /** URL path the cookie is sent for. */
128
+ path?: string;
129
+ /** Domain the cookie is sent to, e.g. `.acme.com` to share it with subdomains. */
130
+ domain?: string;
131
+ /** Seconds. */
132
+ maxAge?: number;
133
+ /** Absolute expiry date. */
134
+ expires?: Date;
135
+ /** Send only over HTTPS. Forced on when `sameSite` is `none`. */
136
+ secure?: boolean;
137
+ /** Server-set cookies only; the browser cannot write httpOnly cookies. */
138
+ httpOnly?: boolean;
139
+ /** Cross-site sending rule. */
140
+ sameSite?: 'lax' | 'strict' | 'none';
141
+ }
142
+ /** Options for `defineCookie`: the default, validation, format, and the cookie attributes. */
143
+ export interface CookieItemOptions<T> extends CookieOptions {
144
+ /** Returned when the cookie is missing or invalid. */
145
+ default: T;
146
+ /** Validate the decoded value (Standard Schema). Invalid → default. */
147
+ schema?: StandardSchemaV1<unknown, T>;
148
+ /** Default: JSON for objects, plain string for string defaults. */
149
+ codec?: Codec<T>;
150
+ }
151
+ /** Anything that can hold cookies: a `Cookie` header string, a Headers object or `document`. */
152
+ export type CookieSource = string | null | undefined | Headers | {
153
+ cookie?: string | null;
154
+ };
155
+ /** A typed cookie returned by `defineCookie`, readable on both sides. */
156
+ export interface CookieItem<T> {
157
+ /** The name it was defined with, without the config prefix. */
158
+ readonly name: string;
159
+ /** Returned when the cookie is missing or invalid. */
160
+ readonly default: T;
161
+ /** The attributes given to `defineCookie`. */
162
+ readonly options: CookieOptions;
163
+ /** Read from `document.cookie`, or from the given source on the server. */
164
+ get(source?: CookieSource): T;
165
+ /** Write `document.cookie` (client). On the server use `cookies(event).set(item, value)`. */
166
+ set(value: T, options?: CookieOptions): void;
167
+ /** Expire the cookie in the browser. A no-op on the server. */
168
+ remove(): void;
169
+ /** Fires after `set`, `remove` or `notifyCookiesChanged()` in this tab. */
170
+ subscribe(listener: (value: T) => void): () => void;
171
+ /** The `Set-Cookie` header value for this item. */
172
+ serialize(value: T | null, options?: CookieOptions): string;
173
+ /** Parse one raw cookie value into T (or the default). */
174
+ decode(raw: string | undefined): T;
175
+ }
176
+ /** Attributes merged into every cookie this app writes. The provider sets them from `cookie` in config. */
177
+ export declare function setCookieDefaults(defaults: Omit<CookieOptions, 'name'>): void;
178
+ /** Parse a `Cookie` header into a map. Values are decoded with decodeURIComponent. */
179
+ export declare function parseCookies(header: string | null | undefined): Record<string, string>;
180
+ /** Build a `Set-Cookie` value. `value === null` expires the cookie. */
181
+ export declare function serializeCookie(name: string, value: string | null, options?: CookieOptions): string;
182
+ /**
183
+ * A typed cookie, readable on both sides and writable from the browser or the server.
184
+ *
185
+ * const locale = defineCookie('locale', { default: 'en', maxAge: 60 * 60 * 24 * 365 })
186
+ * locale.get() // client: document.cookie
187
+ * locale.get(request.headers) // server: any Headers / Cookie header string
188
+ * locale.set('de') // client
189
+ * cookies(event).set(locale, 'de') // server (@kitr/storage/server)
190
+ */
191
+ export declare function defineCookie<T = string>(name: string, options: CookieItemOptions<T>): CookieItem<T>;
192
+ /** Call after something else wrote `document.cookie` so `useCookie()` re-reads. */
193
+ export declare function notifyCookiesChanged(): void;
194
+ /** Every key in an area, for the devtools panel. */
195
+ export declare function listStorage(name: StorageArea): Array<{
196
+ key: string;
197
+ raw: string | null;
198
+ }>;
199
+ /** Delete one raw key from an area and notify subscribers. Used by the devtools panel. */
200
+ export declare function removeStorageKey(name: StorageArea, key: string): void;
201
+ /** Write a line to the devtools `storage` channel. Does nothing when devtools are off. */
202
+ export declare function logStorage(app: KitrApp, label: string, data?: unknown): void;