@rozenite/storage-plugin 1.4.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +20 -0
  3. package/README.md +77 -0
  4. package/dist/assets/panel-C7usUotG.js +22 -0
  5. package/dist/assets/panel-DMhXYHH4.css +1 -0
  6. package/dist/panel.html +31 -0
  7. package/dist/react-native.cjs +1 -0
  8. package/dist/react-native.d.ts +4 -0
  9. package/dist/react-native.js +200 -0
  10. package/dist/rozenite.config.d.ts +7 -0
  11. package/dist/rozenite.json +1 -0
  12. package/dist/src/react-native/adapters/async-storage.d.ts +33 -0
  13. package/dist/src/react-native/adapters/index.d.ts +6 -0
  14. package/dist/src/react-native/adapters/mmkv.d.ts +13 -0
  15. package/dist/src/react-native/adapters/secure-storage.d.ts +18 -0
  16. package/dist/src/react-native/is-garbled.d.ts +1 -0
  17. package/dist/src/react-native/storage-view.d.ts +19 -0
  18. package/dist/src/react-native/useRozeniteStoragePlugin.d.ts +6 -0
  19. package/dist/src/shared/messaging.d.ts +29 -0
  20. package/dist/src/shared/types.d.ts +60 -0
  21. package/dist/src/ui/add-entry-dialog.d.ts +9 -0
  22. package/dist/src/ui/confirm-dialog.d.ts +11 -0
  23. package/dist/src/ui/edit-entry-dialog.d.ts +9 -0
  24. package/dist/src/ui/editable-table.d.ts +10 -0
  25. package/dist/src/ui/entry-detail-dialog.d.ts +8 -0
  26. package/dist/src/ui/panel.d.ts +1 -0
  27. package/dist/types.cjs +1 -0
  28. package/dist/types.js +11 -0
  29. package/dist/useRozeniteStoragePlugin.cjs +1 -0
  30. package/dist/useRozeniteStoragePlugin.js +212 -0
  31. package/package.json +59 -0
  32. package/postcss.config.js +6 -0
  33. package/react-native.ts +40 -0
  34. package/rozenite.config.ts +8 -0
  35. package/src/css-modules.d.ts +4 -0
  36. package/src/react-native/adapters/async-storage.ts +115 -0
  37. package/src/react-native/adapters/index.ts +19 -0
  38. package/src/react-native/adapters/mmkv.ts +187 -0
  39. package/src/react-native/adapters/secure-storage.ts +76 -0
  40. package/src/react-native/is-garbled.ts +17 -0
  41. package/src/react-native/storage-view.ts +245 -0
  42. package/src/react-native/useRozeniteStoragePlugin.ts +162 -0
  43. package/src/shared/messaging.ts +37 -0
  44. package/src/shared/types.ts +66 -0
  45. package/src/ui/add-entry-dialog.tsx +310 -0
  46. package/src/ui/confirm-dialog.tsx +100 -0
  47. package/src/ui/edit-entry-dialog.tsx +280 -0
  48. package/src/ui/editable-table.tsx +298 -0
  49. package/src/ui/entry-detail-dialog.tsx +220 -0
  50. package/src/ui/globals.css +123 -0
  51. package/src/ui/panel.tsx +442 -0
  52. package/tailwind.config.ts +94 -0
  53. package/tsconfig.json +36 -0
  54. package/vite.config.ts +20 -0
@@ -0,0 +1,76 @@
1
+ import type { StorageAdapter, StorageNode } from '../../shared/types';
2
+
3
+ export type SecureStorageLike = {
4
+ getItemAsync: (key: string) => Promise<string | null>;
5
+ setItemAsync: (key: string, value: string) => Promise<void>;
6
+ deleteItemAsync: (key: string) => Promise<void>;
7
+ };
8
+
9
+ type KeySource = string[] | (() => Promise<string[]>);
10
+
11
+ export type CreateExpoSecureStorageAdapterOptions = {
12
+ storage: SecureStorageLike;
13
+ keys: KeySource;
14
+ adapterId?: string;
15
+ adapterName?: string;
16
+ storageId?: string;
17
+ storageName?: string;
18
+ blacklist?: RegExp;
19
+ };
20
+
21
+ const resolveKeys = async (source: KeySource) => {
22
+ if (Array.isArray(source)) {
23
+ return source;
24
+ }
25
+
26
+ return source();
27
+ };
28
+
29
+ export const createExpoSecureStorageAdapter = ({
30
+ storage,
31
+ keys,
32
+ adapterId = 'expo-secure-store',
33
+ adapterName = 'Expo SecureStore',
34
+ storageId = 'default',
35
+ storageName = 'Default Secure Storage',
36
+ blacklist,
37
+ }: CreateExpoSecureStorageAdapterOptions): StorageAdapter => {
38
+ const storageNode: StorageNode = {
39
+ id: storageId,
40
+ name: storageName,
41
+ blacklist,
42
+ capabilities: {
43
+ supportedTypes: ['string'],
44
+ },
45
+ storage: {
46
+ kind: 'async',
47
+ getAllKeys: () => resolveKeys(keys),
48
+ get: async (key) => {
49
+ const value = await storage.getItemAsync(key);
50
+ if (value === null) {
51
+ return undefined;
52
+ }
53
+
54
+ return {
55
+ key,
56
+ type: 'string',
57
+ value,
58
+ };
59
+ },
60
+ set: async (entry) => {
61
+ if (entry.type !== 'string') {
62
+ throw new Error('Expo SecureStore adapter supports only string values.');
63
+ }
64
+
65
+ await storage.setItemAsync(entry.key, entry.value);
66
+ },
67
+ delete: (key) => storage.deleteItemAsync(key),
68
+ },
69
+ };
70
+
71
+ return {
72
+ id: adapterId,
73
+ name: adapterName,
74
+ storages: [storageNode],
75
+ };
76
+ };
@@ -0,0 +1,17 @@
1
+ // This is a heuristic to determine if a string is garbled.
2
+ export const looksLikeGarbled = (str: string): boolean => {
3
+ // 1. Check for replacement character (�)
4
+ if (str.includes('\uFFFD')) return true;
5
+
6
+ // 2. Check for unusual control characters
7
+ // eslint-disable-next-line no-control-regex
8
+ const controlChars = /[\u0000-\u001F\u007F-\u009F]/;
9
+ if (controlChars.test(str)) return true;
10
+
11
+ // 3. Optionally, check if most chars are non-printable
12
+ const printableRatio =
13
+ [...str].filter((c) => c >= ' ' && c <= '~').length / str.length;
14
+ if (printableRatio < 0.7) return true; // mostly non-printable → probably binary
15
+
16
+ return false; // seems like valid string
17
+ };
@@ -0,0 +1,245 @@
1
+ import {
2
+ getStorageViewId,
3
+ supportsType,
4
+ type AsyncStorage,
5
+ type StorageAdapter,
6
+ type StorageCapabilities,
7
+ type StorageEntry,
8
+ type StorageEntryType,
9
+ type StorageNode,
10
+ type StorageSubscription,
11
+ type StorageTarget,
12
+ type SyncStorage,
13
+ } from '../shared/types';
14
+
15
+ const POLLING_INTERVAL_MS = 1500;
16
+
17
+ type StorageSnapshotMap = Map<string, StorageEntry>;
18
+
19
+ type AsyncStorageLike = SyncStorage | AsyncStorage;
20
+
21
+ const isAsyncStorage = (storage: AsyncStorageLike): storage is AsyncStorage =>
22
+ storage.kind === 'async';
23
+
24
+ const fingerprintEntry = (entry: StorageEntry) => {
25
+ if (entry.type === 'buffer') {
26
+ return `${entry.type}:${entry.value.join(',')}`;
27
+ }
28
+
29
+ return `${entry.type}:${String(entry.value)}`;
30
+ };
31
+
32
+ const toSnapshotMap = (entries: StorageEntry[]) => {
33
+ return new Map(entries.map((entry) => [entry.key, entry]));
34
+ };
35
+
36
+ const shouldFilterKey = (storage: StorageNode, key: string) => {
37
+ if (!storage.blacklist) {
38
+ return false;
39
+ }
40
+
41
+ storage.blacklist.lastIndex = 0;
42
+ return storage.blacklist.test(key);
43
+ };
44
+
45
+ const checkTypeSupport = (
46
+ capabilities: StorageCapabilities,
47
+ type: StorageEntryType,
48
+ target: StorageTarget
49
+ ) => {
50
+ if (supportsType(capabilities, type)) {
51
+ return;
52
+ }
53
+
54
+ throw new Error(
55
+ `Type "${type}" is not supported by storage "${target.storageId}" in adapter "${target.adapterId}".`
56
+ );
57
+ };
58
+
59
+ const getAllKeys = async (storage: AsyncStorageLike) => {
60
+ if (isAsyncStorage(storage)) {
61
+ return storage.getAllKeys();
62
+ }
63
+
64
+ return storage.getAllKeys();
65
+ };
66
+
67
+ const getEntry = async (storage: AsyncStorageLike, key: string) => {
68
+ if (isAsyncStorage(storage)) {
69
+ return storage.get(key);
70
+ }
71
+
72
+ return storage.get(key);
73
+ };
74
+
75
+ const setEntry = async (storage: AsyncStorageLike, entry: StorageEntry) => {
76
+ if (isAsyncStorage(storage)) {
77
+ await storage.set(entry);
78
+ return;
79
+ }
80
+
81
+ storage.set(entry);
82
+ };
83
+
84
+ const deleteEntry = async (storage: AsyncStorageLike, key: string) => {
85
+ if (isAsyncStorage(storage)) {
86
+ await storage.delete(key);
87
+ return;
88
+ }
89
+
90
+ storage.delete(key);
91
+ };
92
+
93
+ export type StorageView = {
94
+ id: string;
95
+ target: StorageTarget;
96
+ adapterName: string;
97
+ storageName: string;
98
+ capabilities: StorageCapabilities;
99
+ get: (key: string) => Promise<StorageEntry | undefined>;
100
+ set: (entry: StorageEntry) => Promise<void>;
101
+ delete: (key: string) => Promise<void>;
102
+ getAllKeys: () => Promise<string[]>;
103
+ getAllEntries: () => Promise<StorageEntry[]>;
104
+ watch: (callbacks: {
105
+ onSet: (entry: StorageEntry) => void;
106
+ onDelete: (key: string) => void;
107
+ }) => Promise<StorageSubscription>;
108
+ };
109
+
110
+ const buildSnapshotMap = async (
111
+ getAllEntries: () => Promise<StorageEntry[]>
112
+ ): Promise<StorageSnapshotMap> => {
113
+ const entries = await getAllEntries();
114
+ return toSnapshotMap(entries);
115
+ };
116
+
117
+ const diffSnapshots = (
118
+ previous: StorageSnapshotMap,
119
+ next: StorageSnapshotMap,
120
+ handlers: {
121
+ onSet: (entry: StorageEntry) => void;
122
+ onDelete: (key: string) => void;
123
+ }
124
+ ) => {
125
+ next.forEach((nextEntry, key) => {
126
+ const previousEntry = previous.get(key);
127
+
128
+ if (!previousEntry) {
129
+ handlers.onSet(nextEntry);
130
+ return;
131
+ }
132
+
133
+ if (fingerprintEntry(previousEntry) !== fingerprintEntry(nextEntry)) {
134
+ handlers.onSet(nextEntry);
135
+ }
136
+ });
137
+
138
+ previous.forEach((_value, key) => {
139
+ if (!next.has(key)) {
140
+ handlers.onDelete(key);
141
+ }
142
+ });
143
+ };
144
+
145
+ const createPollingSubscription = async (
146
+ getAllEntries: () => Promise<StorageEntry[]>,
147
+ handlers: {
148
+ onSet: (entry: StorageEntry) => void;
149
+ onDelete: (key: string) => void;
150
+ }
151
+ ): Promise<StorageSubscription> => {
152
+ let previousSnapshot = await buildSnapshotMap(getAllEntries);
153
+
154
+ const interval = setInterval(async () => {
155
+ try {
156
+ const nextSnapshot = await buildSnapshotMap(getAllEntries);
157
+ diffSnapshots(previousSnapshot, nextSnapshot, handlers);
158
+ previousSnapshot = nextSnapshot;
159
+ } catch {
160
+ // Silently ignore polling errors and try again on next tick.
161
+ }
162
+ }, POLLING_INTERVAL_MS);
163
+
164
+ return {
165
+ remove: () => {
166
+ clearInterval(interval);
167
+ },
168
+ };
169
+ };
170
+
171
+ export const createStorageView = (
172
+ adapter: StorageAdapter,
173
+ storageNode: StorageNode
174
+ ): StorageView => {
175
+ const storage = storageNode.storage;
176
+ const target: StorageTarget = {
177
+ adapterId: adapter.id,
178
+ storageId: storageNode.id,
179
+ };
180
+
181
+ const get = async (key: string) => {
182
+ if (shouldFilterKey(storageNode, key)) {
183
+ return undefined;
184
+ }
185
+
186
+ return getEntry(storage, key);
187
+ };
188
+
189
+ const getAllEntries = async () => {
190
+ const keys = await getAllKeys(storage);
191
+ const visibleEntries = await Promise.all(
192
+ keys
193
+ .filter((key) => !shouldFilterKey(storageNode, key))
194
+ .map((key) => getEntry(storage, key))
195
+ );
196
+
197
+ return visibleEntries.filter((entry): entry is StorageEntry => !!entry);
198
+ };
199
+
200
+ return {
201
+ id: getStorageViewId(target),
202
+ target,
203
+ adapterName: adapter.name,
204
+ storageName: storageNode.name,
205
+ capabilities: storageNode.capabilities,
206
+ get,
207
+ set: async (entry) => {
208
+ checkTypeSupport(storageNode.capabilities, entry.type, target);
209
+ await setEntry(storage, entry);
210
+ },
211
+ delete: async (key) => {
212
+ await deleteEntry(storage, key);
213
+ },
214
+ getAllKeys: async () => {
215
+ const keys = await getAllKeys(storage);
216
+ return keys.filter((key) => !shouldFilterKey(storageNode, key));
217
+ },
218
+ getAllEntries,
219
+ watch: async ({ onSet, onDelete }) => {
220
+ if (storage.subscribe) {
221
+ return storage.subscribe(async (key) => {
222
+ try {
223
+ const entry = await get(key);
224
+
225
+ if (!entry) {
226
+ onDelete(key);
227
+ return;
228
+ }
229
+
230
+ onSet(entry);
231
+ } catch {
232
+ // Ignore runtime callback errors; polling fallback is not needed when subscribe exists.
233
+ }
234
+ });
235
+ }
236
+
237
+ return createPollingSubscription(getAllEntries, { onSet, onDelete });
238
+ },
239
+ };
240
+ };
241
+
242
+ export const createStorageViews = (storages: StorageAdapter[]) =>
243
+ storages.flatMap((adapter) =>
244
+ adapter.storages.map((storageNode) => createStorageView(adapter, storageNode))
245
+ );
@@ -0,0 +1,162 @@
1
+ import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
2
+ import { useEffect, useMemo } from 'react';
3
+ import type {
4
+ StorageDeleteEntryEvent,
5
+ StorageEventMap,
6
+ StorageGetSnapshotEvent,
7
+ StorageSetEntryEvent,
8
+ } from '../shared/messaging';
9
+ import type { StorageAdapter } from '../shared/types';
10
+ import { createStorageViews } from './storage-view';
11
+
12
+ export type RozeniteStoragePluginOptions = {
13
+ storages: StorageAdapter[];
14
+ };
15
+
16
+ export const useRozeniteStoragePlugin = ({
17
+ storages,
18
+ }: RozeniteStoragePluginOptions) => {
19
+ const views = useMemo(() => createStorageViews(storages), [storages]);
20
+
21
+ const client = useRozeniteDevToolsClient<StorageEventMap>({
22
+ pluginId: '@rozenite/storage-plugin',
23
+ });
24
+
25
+ useEffect(() => {
26
+ if (!client) {
27
+ return;
28
+ }
29
+
30
+ const pushSnapshot = async (viewId?: string) => {
31
+ const selectedViews = viewId ? views.filter((view) => view.id === viewId) : views;
32
+
33
+ for (const view of selectedViews) {
34
+ try {
35
+ const entries = await view.getAllEntries();
36
+ client.send('snapshot', {
37
+ type: 'snapshot',
38
+ target: view.target,
39
+ adapterName: view.adapterName,
40
+ storageName: view.storageName,
41
+ capabilities: view.capabilities,
42
+ entries,
43
+ });
44
+ } catch (error) {
45
+ console.warn(
46
+ `[Rozenite] Storage Plugin: Failed to snapshot ${view.target.adapterId}/${view.target.storageId}.`,
47
+ error
48
+ );
49
+ }
50
+ }
51
+ };
52
+
53
+ void pushSnapshot();
54
+
55
+ const viewSubscriptions: { remove: () => void }[] = [];
56
+ let disposed = false;
57
+
58
+ // Prevent one storage watcher failure from breaking the whole plugin.
59
+ void Promise.all(
60
+ views.map(async (view) => {
61
+ try {
62
+ const subscription = await view.watch({
63
+ onSet: (entry) => {
64
+ client.send('set-entry', {
65
+ type: 'set-entry',
66
+ target: view.target,
67
+ entry,
68
+ });
69
+ },
70
+ onDelete: (key) => {
71
+ client.send('delete-entry', {
72
+ type: 'delete-entry',
73
+ target: view.target,
74
+ key,
75
+ });
76
+ },
77
+ });
78
+
79
+ if (disposed) {
80
+ subscription.remove();
81
+ return;
82
+ }
83
+
84
+ viewSubscriptions.push(subscription);
85
+ } catch (error) {
86
+ console.warn(
87
+ `[Rozenite] Storage Plugin: Failed to attach watcher for ${view.target.adapterId}/${view.target.storageId}.`,
88
+ error
89
+ );
90
+ }
91
+ })
92
+ );
93
+
94
+ const messageSubscriptions = [
95
+ client.onMessage('set-entry', async ({ target, entry }: StorageSetEntryEvent) => {
96
+ const view = views.find(
97
+ (candidate) =>
98
+ candidate.target.adapterId === target.adapterId &&
99
+ candidate.target.storageId === target.storageId
100
+ );
101
+
102
+ if (!view) {
103
+ console.warn(
104
+ `[Rozenite] Storage Plugin: Storage target not found for ${target.adapterId}/${target.storageId}`
105
+ );
106
+ return;
107
+ }
108
+
109
+ try {
110
+ await view.set(entry);
111
+ } catch (error) {
112
+ console.warn(
113
+ `[Rozenite] Storage Plugin: Failed to set entry in ${target.adapterId}/${target.storageId}.`,
114
+ error
115
+ );
116
+ }
117
+ }),
118
+ client.onMessage(
119
+ 'delete-entry',
120
+ async ({ target, key }: StorageDeleteEntryEvent) => {
121
+ const view = views.find(
122
+ (candidate) =>
123
+ candidate.target.adapterId === target.adapterId &&
124
+ candidate.target.storageId === target.storageId
125
+ );
126
+
127
+ if (!view) {
128
+ console.warn(
129
+ `[Rozenite] Storage Plugin: Storage target not found for ${target.adapterId}/${target.storageId}`
130
+ );
131
+ return;
132
+ }
133
+
134
+ try {
135
+ await view.delete(key);
136
+ } catch (error) {
137
+ console.warn(
138
+ `[Rozenite] Storage Plugin: Failed to delete entry in ${target.adapterId}/${target.storageId}.`,
139
+ error
140
+ );
141
+ }
142
+ }
143
+ ),
144
+ client.onMessage('get-snapshot', async ({ target }: StorageGetSnapshotEvent) => {
145
+ if (target === 'all') {
146
+ await pushSnapshot();
147
+ return;
148
+ }
149
+
150
+ await pushSnapshot(`${target.adapterId}:${target.storageId}`);
151
+ }),
152
+ ];
153
+
154
+ return () => {
155
+ disposed = true;
156
+ viewSubscriptions.forEach((subscription) => subscription.remove());
157
+ messageSubscriptions.forEach((subscription) => subscription.remove());
158
+ };
159
+ }, [client, views]);
160
+
161
+ return client;
162
+ };
@@ -0,0 +1,37 @@
1
+ import type { StorageCapabilities, StorageEntry, StorageTarget } from './types';
2
+
3
+ export type StorageSnapshotEvent = {
4
+ type: 'snapshot';
5
+ target: StorageTarget;
6
+ adapterName: string;
7
+ storageName: string;
8
+ capabilities: StorageCapabilities;
9
+ entries: StorageEntry[];
10
+ };
11
+
12
+ export type StorageSetEntryEvent = {
13
+ type: 'set-entry';
14
+ target: StorageTarget;
15
+ entry: StorageEntry;
16
+ };
17
+
18
+ export type StorageDeleteEntryEvent = {
19
+ type: 'delete-entry';
20
+ target: StorageTarget;
21
+ key: string;
22
+ };
23
+
24
+ export type StorageGetSnapshotEvent = {
25
+ type: 'get-snapshot';
26
+ target: StorageTarget | 'all';
27
+ };
28
+
29
+ export type StorageEvent =
30
+ | StorageSnapshotEvent
31
+ | StorageSetEntryEvent
32
+ | StorageDeleteEntryEvent
33
+ | StorageGetSnapshotEvent;
34
+
35
+ export type StorageEventMap = {
36
+ [K in StorageEvent['type']]: Extract<StorageEvent, { type: K }>;
37
+ };
@@ -0,0 +1,66 @@
1
+ export type StorageEntry =
2
+ | { key: string; type: 'string'; value: string }
3
+ | { key: string; type: 'number'; value: number }
4
+ | { key: string; type: 'boolean'; value: boolean }
5
+ | { key: string; type: 'buffer'; value: number[] };
6
+
7
+ export type StorageEntryType = StorageEntry['type'];
8
+ export type StorageEntryValue = StorageEntry['value'];
9
+
10
+ export type StorageCapabilities = {
11
+ supportedTypes: StorageEntryType[];
12
+ };
13
+
14
+ export type StorageSubscription = { remove: () => void };
15
+
16
+ export type SyncStorage = {
17
+ kind: 'sync';
18
+ getAllKeys: () => string[];
19
+ get: (key: string) => StorageEntry | undefined;
20
+ set: (entry: StorageEntry) => void;
21
+ delete: (key: string) => void;
22
+ subscribe?: (callback: (key: string) => void) => StorageSubscription;
23
+ };
24
+
25
+ export type AsyncStorage = {
26
+ kind: 'async';
27
+ getAllKeys: () => Promise<string[]>;
28
+ get: (key: string) => Promise<StorageEntry | undefined>;
29
+ set: (entry: StorageEntry) => Promise<void>;
30
+ delete: (key: string) => Promise<void>;
31
+ subscribe?: (callback: (key: string) => void) => StorageSubscription;
32
+ };
33
+
34
+ export type StorageNode = {
35
+ id: string;
36
+ name: string;
37
+ storage: SyncStorage | AsyncStorage;
38
+ capabilities: StorageCapabilities;
39
+ blacklist?: RegExp;
40
+ };
41
+
42
+ export type StorageAdapter = {
43
+ id: string;
44
+ name: string;
45
+ storages: StorageNode[];
46
+ };
47
+
48
+ export type StorageTarget = {
49
+ adapterId: string;
50
+ storageId: string;
51
+ };
52
+
53
+ export const DEFAULT_SUPPORTED_TYPES: StorageEntryType[] = [
54
+ 'string',
55
+ 'number',
56
+ 'boolean',
57
+ 'buffer',
58
+ ];
59
+
60
+ export const getStorageViewId = ({ adapterId, storageId }: StorageTarget) =>
61
+ `${adapterId}:${storageId}`;
62
+
63
+ export const supportsType = (
64
+ capabilities: StorageCapabilities,
65
+ type: StorageEntryType
66
+ ) => capabilities.supportedTypes.includes(type);