origin-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rogo
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 ADDED
@@ -0,0 +1,99 @@
1
+ # origin-snapshot
2
+
3
+ Export and restore everything portable about a browser **origin** — `localStorage`,
4
+ `sessionStorage`, `IndexedDB`, and `OPFS` — as a single, compact file.
5
+
6
+ The output is a gzip-compressed binary container (`.galaxy`), **not** JSON.
7
+ Binary payloads (IndexedDB blobs, OPFS game/emulator saves) are stored as raw
8
+ bytes and the whole thing is gzipped, so there's no base64 inflation and gzip
9
+ de-duplicates across files. That makes it the smallest practical representation
10
+ you can build in the browser with zero dependencies. (Brotli would be a touch
11
+ smaller, but `CompressionStream` only ships `gzip`/`deflate`.)
12
+
13
+ ## What's included
14
+
15
+ | Included | Skipped (and why) |
16
+ | --------------------------------------- | --------------------------------------------------------- |
17
+ | `localStorage` | Cache API — huge, regenerable proxied assets |
18
+ | `sessionStorage` (opt-in) | Service-worker registrations — re-register automatically |
19
+ | `IndexedDB` (schema + records) | Real/HttpOnly cookies — invisible to JS |
20
+ | `OPFS` (full file tree) | Push subscriptions / background sync — device-bound |
21
+ | Proxied cookies (ride along in storage) | Permissions / WebAuthn / passkeys — origin + device bound |
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install origin-snapshot
27
+ ```
28
+
29
+ Browser-only (it needs `localStorage`, `indexedDB`, OPFS, and `CompressionStream`).
30
+ Ships as ESM source — no build step.
31
+
32
+ ## Usage
33
+
34
+ ```js
35
+ import {
36
+ downloadSnapshot,
37
+ exportOrigin,
38
+ importOrigin,
39
+ readManifest,
40
+ } from "origin-snapshot";
41
+
42
+ // One-liner: build the snapshot and trigger a download.
43
+ await downloadSnapshot(); // example.com-2026-06-28.galaxy
44
+
45
+ // Or get the Blob yourself (upload it, store it, etc.).
46
+ const blob = await exportOrigin({ sessionStorage: true });
47
+
48
+ // Later — on the same origin — restore it.
49
+ const file = await pickFile(); // <input type="file">
50
+ await importOrigin(file); // wipes + restores each section by default
51
+
52
+ // Peek at metadata without restoring.
53
+ const manifest = await readManifest(file);
54
+ console.log(manifest.origin, manifest.createdAt);
55
+ ```
56
+
57
+ ## API
58
+
59
+ ### `exportOrigin(options?) => Promise<Blob>`
60
+
61
+ | option | default | description |
62
+ | ---------------- | ------- | --------------------------------------------------------------------- |
63
+ | `localStorage` | `true` | include `localStorage` |
64
+ | `sessionStorage` | `false` | include `sessionStorage` (clears on tab close) |
65
+ | `indexedDB` | `true` | include all IndexedDB databases |
66
+ | `opfs` | `true` | include the OPFS file tree |
67
+ | `databases` | — | explicit IndexedDB names for browsers without `indexedDB.databases()` |
68
+
69
+ ### `importOrigin(source, options?) => Promise<manifest>`
70
+
71
+ `source` may be a `Blob`/`File`, `ArrayBuffer`, or `Uint8Array`.
72
+
73
+ | option | default | description |
74
+ | ------- | ------- | ---------------------------------------------------- |
75
+ | `clear` | `true` | wipe each section before restoring (`false` = merge) |
76
+
77
+ ### `downloadSnapshot(options?) => Promise<Blob>`
78
+
79
+ `exportOrigin` plus a browser download. Accepts every `exportOrigin` option, plus
80
+ `filename`.
81
+
82
+ ### `readManifest(source) => Promise<manifest>`
83
+
84
+ Returns the snapshot metadata (`format`, `version`, `createdAt`, `origin`,
85
+ `sections`) without touching local storage.
86
+
87
+ ## Notes & limits
88
+
89
+ - **Same-origin restore.** IndexedDB and OPFS are origin-scoped; restore on the
90
+ origin the snapshot came from.
91
+ - **Close other tabs before restoring.** Recreating an IndexedDB database needs
92
+ an exclusive connection; another open tab can block it.
93
+ - **Round-trips structured-clone types** — `Date`, `Blob`, `File`, `ArrayBuffer`,
94
+ typed arrays, `Map`, `Set`, `RegExp`, `bigint`, `undefined`, `NaN`/`Infinity`.
95
+ Circular references are rejected with a clear error.
96
+
97
+ ## License
98
+
99
+ MIT
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "origin-snapshot",
3
+ "version": "0.1.0",
4
+ "description": "Export and restore a browser origin's localStorage, sessionStorage, IndexedDB, and OPFS as a single gzip-compressed snapshot file.",
5
+ "keywords": [
6
+ "localstorage",
7
+ "sessionstorage",
8
+ "indexeddb",
9
+ "opfs",
10
+ "origin",
11
+ "export",
12
+ "import",
13
+ "backup",
14
+ "snapshot",
15
+ "migration"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "rogo",
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.d.ts",
23
+ "import": "./src/index.js"
24
+ }
25
+ },
26
+ "main": "./src/index.js",
27
+ "types": "./src/index.d.ts",
28
+ "files": [
29
+ "src"
30
+ ],
31
+ "scripts": {
32
+ "test": "echo \"Error: no test specified\" && exit 1"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "module": "./src/index.js",
38
+ "sideEffects": false
39
+ }
@@ -0,0 +1,97 @@
1
+ const MAGIC = 'GLXYSNAP';
2
+ const CONTAINER_VERSION = 1;
3
+ const FLAG_GZIP = 1;
4
+ const HEADER_SIZE = 10;
5
+
6
+ /**
7
+ * @param {object} manifest
8
+ * @param {Array<Blob | Uint8Array>} bin
9
+ * @returns {Promise<Blob>}
10
+ */
11
+ export async function encodeContainer(manifest, bin) {
12
+ const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest));
13
+ checkLength(manifestBytes.length, 'manifest');
14
+
15
+ const parts = [u32le(manifestBytes.length), manifestBytes];
16
+ for (const part of bin) {
17
+ const size = part instanceof Blob ? part.size : part.byteLength;
18
+ checkLength(size, 'payload');
19
+ parts.push(u32le(size), part);
20
+ }
21
+ const body = new Blob(parts);
22
+
23
+ let flags = 0;
24
+ let payload = body;
25
+ if (typeof CompressionStream !== 'undefined') {
26
+ payload = await new Response(body.stream().pipeThrough(new CompressionStream('gzip'))).blob();
27
+ flags |= FLAG_GZIP;
28
+ }
29
+
30
+ const header = new Uint8Array(HEADER_SIZE);
31
+ for (let i = 0; i < 8; i++) header[i] = MAGIC.charCodeAt(i);
32
+ header[8] = CONTAINER_VERSION;
33
+ header[9] = flags;
34
+
35
+ return new Blob([header, payload], { type: 'application/octet-stream' });
36
+ }
37
+
38
+ /**
39
+ * @param {Blob} blob
40
+ * @returns {Promise<{ manifest: object, bin: Uint8Array[], containerVersion: number }>}
41
+ */
42
+ export async function decodeContainer(blob) {
43
+ const header = new Uint8Array(await blob.slice(0, HEADER_SIZE).arrayBuffer());
44
+ if (header.byteLength < HEADER_SIZE) throw new Error('Not a Galaxy origin snapshot (truncated)');
45
+ for (let i = 0; i < 8; i++) {
46
+ if (header[i] !== MAGIC.charCodeAt(i))
47
+ throw new Error('Not a Galaxy origin snapshot (bad magic)');
48
+ }
49
+ const containerVersion = header[8];
50
+ const flags = header[9];
51
+
52
+ let bodyBlob = blob.slice(HEADER_SIZE);
53
+ if (flags & FLAG_GZIP) {
54
+ bodyBlob = await new Response(
55
+ bodyBlob.stream().pipeThrough(new DecompressionStream('gzip'))
56
+ ).blob();
57
+ }
58
+
59
+ const buf = await bodyBlob.arrayBuffer();
60
+ const view = new DataView(buf);
61
+ let off = 0;
62
+
63
+ const manifestLen = view.getUint32(off, true);
64
+ off += 4;
65
+ const manifest = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, off, manifestLen)));
66
+ off += manifestLen;
67
+
68
+ /** @type {Uint8Array[]} */
69
+ const bin = [];
70
+ while (off < buf.byteLength) {
71
+ const len = view.getUint32(off, true);
72
+ off += 4;
73
+ bin.push(new Uint8Array(buf, off, len)); // view — no copy
74
+ off += len;
75
+ }
76
+
77
+ return { manifest, bin, containerVersion };
78
+ }
79
+
80
+ const MAX_U32 = 0xffffffff;
81
+
82
+ /**
83
+ * @param {number} length
84
+ * @param {string} what
85
+ */
86
+ function checkLength(length, what) {
87
+ if (length > MAX_U32) {
88
+ throw new RangeError(`${what} is ${length} bytes, over the 4 GiB per-entry container limit`);
89
+ }
90
+ }
91
+
92
+ /** @param {number} n */
93
+ function u32le(n) {
94
+ const b = new Uint8Array(4);
95
+ new DataView(b.buffer).setUint32(0, n, true);
96
+ return b;
97
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export interface ExportOptions {
2
+ localStorage?: boolean;
3
+ sessionStorage?: boolean;
4
+ indexedDB?: boolean;
5
+ opfs?: boolean;
6
+ /**
7
+ */
8
+ databases?: string[];
9
+ }
10
+
11
+ export interface ImportOptions {
12
+ clear?: boolean;
13
+ }
14
+
15
+ export interface SnapshotManifest {
16
+ format: "origin-snapshot";
17
+ version: number;
18
+ createdAt: string;
19
+ origin: string | null;
20
+ sections: {
21
+ localStorage?: Record<string, string>;
22
+ sessionStorage?: Record<string, string>;
23
+ indexedDB?: unknown[];
24
+ opfs?: unknown[];
25
+ };
26
+ }
27
+
28
+ export function exportOrigin(options?: ExportOptions): Promise<Blob>;
29
+
30
+ export function importOrigin(
31
+ source: Blob | ArrayBuffer | Uint8Array,
32
+ options?: ImportOptions,
33
+ ): Promise<SnapshotManifest>;
34
+
35
+ export function readManifest(
36
+ source: Blob | ArrayBuffer | Uint8Array,
37
+ ): Promise<SnapshotManifest>;
38
+
39
+ export function downloadSnapshot(
40
+ options?: ExportOptions & { filename?: string },
41
+ ): Promise<Blob>;
package/src/index.js ADDED
@@ -0,0 +1,146 @@
1
+ import { encodeContainer, decodeContainer } from "./container.js";
2
+ import { dumpWebStorage, restoreWebStorage } from "./storage/webstorage.js";
3
+ import { dumpDatabases, restoreDatabases } from "./storage/indexeddb.js";
4
+ import { dumpOPFS, restoreOPFS } from "./storage/opfs.js";
5
+
6
+ const FORMAT = "origin-snapshot";
7
+ const SNAPSHOT_VERSION = 1;
8
+
9
+ /**
10
+ * @typedef {object} ExportOptions
11
+ * @property {boolean} [localStorage=true]
12
+ * @property {boolean} [sessionStorage=false]
13
+ * @property {boolean} [indexedDB=true]
14
+ * @property {boolean} [opfs=true]
15
+ * @property {string[]} [databases]
16
+ *
17
+ */
18
+
19
+ /**
20
+ *
21
+ *
22
+ * @param {ExportOptions} [options]
23
+ * @returns {Promise<Blob>}
24
+ */
25
+ export async function exportOrigin(options = {}) {
26
+ const {
27
+ localStorage: doLocal = true,
28
+ sessionStorage: doSession = false,
29
+ indexedDB: doIDB = true,
30
+ opfs: doOPFS = true,
31
+ databases,
32
+ } = options;
33
+
34
+ /** @type {Array<Blob | Uint8Array>} */
35
+ const bin = [];
36
+ const sections = {};
37
+
38
+ if (doLocal && typeof localStorage !== "undefined") {
39
+ sections.localStorage = dumpWebStorage(localStorage);
40
+ }
41
+ if (doSession && typeof sessionStorage !== "undefined") {
42
+ sections.sessionStorage = dumpWebStorage(sessionStorage);
43
+ }
44
+ if (doIDB && typeof indexedDB !== "undefined") {
45
+ sections.indexedDB = await dumpDatabases(bin, databases);
46
+ }
47
+ if (doOPFS) {
48
+ const opfs = await dumpOPFS(bin);
49
+ if (opfs) sections.opfs = opfs;
50
+ }
51
+
52
+ const manifest = {
53
+ format: FORMAT,
54
+ version: SNAPSHOT_VERSION,
55
+ createdAt: new Date().toISOString(),
56
+ origin: typeof location !== "undefined" ? location.origin : null,
57
+ sections,
58
+ };
59
+
60
+ return encodeContainer(manifest, bin);
61
+ }
62
+
63
+ /**
64
+ * @typedef {object} ImportOptions
65
+ * @property {boolean} [clear=true]
66
+ */
67
+
68
+ /**
69
+ * @param {Blob | ArrayBuffer | Uint8Array} source
70
+ * @param {ImportOptions} [options]
71
+ * @returns {Promise<object>} the snapshot manifest (metadata)
72
+ */
73
+
74
+ export async function importOrigin(source, options = {}) {
75
+ const { clear = true } = options;
76
+ const blob = toBlob(source);
77
+ const { manifest, bin } = await decodeContainer(blob);
78
+
79
+ if (manifest.format !== FORMAT) {
80
+ throw new Error(`Unsupported snapshot format "${manifest.format}"`);
81
+ }
82
+ if (manifest.version > SNAPSHOT_VERSION) {
83
+ throw new Error(
84
+ `Snapshot version ${manifest.version} is newer than this library supports (${SNAPSHOT_VERSION})`,
85
+ );
86
+ }
87
+
88
+ const s = manifest.sections || {};
89
+ if (s.localStorage && typeof localStorage !== "undefined") {
90
+ restoreWebStorage(localStorage, s.localStorage, { clear });
91
+ }
92
+ if (s.sessionStorage && typeof sessionStorage !== "undefined") {
93
+ restoreWebStorage(sessionStorage, s.sessionStorage, { clear });
94
+ }
95
+ if (s.indexedDB && typeof indexedDB !== "undefined") {
96
+ await restoreDatabases(s.indexedDB, bin, { clear });
97
+ }
98
+ if (s.opfs) {
99
+ await restoreOPFS(s.opfs, bin, { clear });
100
+ }
101
+
102
+ return manifest;
103
+ }
104
+
105
+ /**
106
+ *
107
+ * @param {Blob | ArrayBuffer | Uint8Array} source
108
+ * @returns {Promise<object>}
109
+ */
110
+ export async function readManifest(source) {
111
+ const { manifest } = await decodeContainer(toBlob(source));
112
+ return manifest;
113
+ }
114
+
115
+ /**
116
+ *
117
+ * @param {ExportOptions & { filename?: string }} [options]
118
+ * @returns {Promise<Blob>}
119
+ */
120
+ export async function downloadSnapshot(options = {}) {
121
+ const { filename, ...exportOptions } = options;
122
+ const blob = await exportOrigin(exportOptions);
123
+ const name = filename || defaultFilename();
124
+
125
+ const url = URL.createObjectURL(blob);
126
+ const a = document.createElement("a");
127
+ a.href = url;
128
+ a.download = name;
129
+ document.body.appendChild(a);
130
+ a.click();
131
+ a.remove();
132
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
133
+
134
+ return blob;
135
+ }
136
+
137
+ function defaultFilename() {
138
+ const date = new Date().toISOString().slice(0, 10);
139
+ const host = typeof location !== "undefined" ? location.hostname : "origin";
140
+ return `${host || "origin"}-${date}.galaxy`;
141
+ }
142
+
143
+ /** @param {Blob | ArrayBuffer | Uint8Array} source */
144
+ function toBlob(source) {
145
+ return source instanceof Blob ? source : new Blob([source]);
146
+ }
@@ -0,0 +1,165 @@
1
+ const TAG = '~gsnap~';
2
+
3
+ /**
4
+ * @param {*} value
5
+ * @param {Array<Blob | Uint8Array>} bin
6
+ * @param {WeakSet<object>} [seen]
7
+ * @returns {*}
8
+ */
9
+ export function serializeValue(value, bin, seen = new WeakSet()) {
10
+ if (value === null) return null;
11
+ const type = typeof value;
12
+ if (type === 'string' || type === 'boolean') return value;
13
+ if (type === 'number') {
14
+ if (Number.isNaN(value)) return tag('num', { v: 'NaN' });
15
+ if (value === Infinity) return tag('num', { v: 'Infinity' });
16
+ if (value === -Infinity) return tag('num', { v: '-Infinity' });
17
+ return value;
18
+ }
19
+ if (type === 'undefined') return tag('undef');
20
+ if (type === 'bigint') return tag('bigint', { v: value.toString() });
21
+
22
+ // Structured-clone object types.
23
+ if (value instanceof Date) return tag('date', { v: value.getTime() });
24
+ if (value instanceof RegExp) return tag('regexp', { src: value.source, flags: value.flags });
25
+
26
+ if (value instanceof ArrayBuffer) {
27
+ return tag('arraybuffer', { b: push(bin, new Uint8Array(value)) });
28
+ }
29
+ if (ArrayBuffer.isView(value)) {
30
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
31
+ return tag('view', { k: value.constructor.name, b: push(bin, bytes) });
32
+ }
33
+ if (typeof File !== 'undefined' && value instanceof File) {
34
+ return tag('file', {
35
+ b: push(bin, value),
36
+ name: value.name,
37
+ type: value.type,
38
+ lastModified: value.lastModified
39
+ });
40
+ }
41
+ if (typeof Blob !== 'undefined' && value instanceof Blob) {
42
+ return tag('blob', { b: push(bin, value), type: value.type });
43
+ }
44
+ if (type === 'object') {
45
+ if (seen.has(value)) {
46
+ throw new TypeError('Cannot serialize a value with a circular reference');
47
+ }
48
+ seen.add(value);
49
+ try {
50
+ if (value instanceof Map) {
51
+ return tag('map', {
52
+ v: [...value].map(([k, v]) => [
53
+ serializeValue(k, bin, seen),
54
+ serializeValue(v, bin, seen)
55
+ ])
56
+ });
57
+ }
58
+ if (value instanceof Set) {
59
+ return tag('set', { v: [...value].map((v) => serializeValue(v, bin, seen)) });
60
+ }
61
+ if (Array.isArray(value)) {
62
+ return value.map((v) => serializeValue(v, bin, seen));
63
+ }
64
+ if (Object.prototype.hasOwnProperty.call(value, TAG)) {
65
+ return tag('object', {
66
+ e: Object.keys(value).map((k) => [k, serializeValue(value[k], bin, seen)])
67
+ });
68
+ }
69
+ const out = {};
70
+ for (const k of Object.keys(value)) out[k] = serializeValue(value[k], bin, seen);
71
+ return out;
72
+ } finally {
73
+ seen.delete(value);
74
+ }
75
+ }
76
+ throw new TypeError(`Cannot serialize value of type "${type}"`);
77
+ }
78
+
79
+ /**
80
+ * Inverse of {@link serializeValue}.
81
+ *
82
+ * @param {*} node
83
+ * @param {Array<Uint8Array>} bin
84
+ * @returns {*}
85
+ */
86
+ export function deserializeValue(node, bin) {
87
+ if (node === null || typeof node !== 'object') return node;
88
+ if (Array.isArray(node)) return node.map((n) => deserializeValue(n, bin));
89
+
90
+ const t = node[TAG];
91
+ if (t === undefined) {
92
+ const out = {};
93
+ for (const k of Object.keys(node)) out[k] = deserializeValue(node[k], bin);
94
+ return out;
95
+ }
96
+
97
+ switch (t) {
98
+ case 'undef':
99
+ return undefined;
100
+ case 'num':
101
+ return Number(node.v);
102
+ case 'bigint':
103
+ return BigInt(node.v);
104
+ case 'date':
105
+ return new Date(node.v);
106
+ case 'regexp':
107
+ return new RegExp(node.src, node.flags);
108
+ case 'arraybuffer':
109
+ return copyBytes(bin[node.b]).buffer;
110
+ case 'view': {
111
+ const bytes = copyBytes(bin[node.b]);
112
+ if (node.k === 'DataView') return new DataView(bytes.buffer);
113
+ const Ctor = globalThis[node.k];
114
+ if (!Ctor) throw new Error(`Unknown typed-array constructor "${node.k}"`);
115
+ return new Ctor(bytes.buffer);
116
+ }
117
+ case 'blob':
118
+ return new Blob([bin[node.b]], { type: node.type });
119
+ case 'file':
120
+ return new File([bin[node.b]], node.name, {
121
+ type: node.type,
122
+ lastModified: node.lastModified
123
+ });
124
+ case 'map': {
125
+ const m = new Map();
126
+ for (const [k, v] of node.v) m.set(deserializeValue(k, bin), deserializeValue(v, bin));
127
+ return m;
128
+ }
129
+ case 'set': {
130
+ const s = new Set();
131
+ for (const v of node.v) s.add(deserializeValue(v, bin));
132
+ return s;
133
+ }
134
+ case 'object': {
135
+ const out = {};
136
+ for (const [k, v] of node.e) out[k] = deserializeValue(v, bin);
137
+ return out;
138
+ }
139
+ default:
140
+ throw new Error(`Unknown serialization tag "${t}"`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * @param {string} type
146
+ * @param {object} [extra]
147
+ */
148
+ function tag(type, extra) {
149
+ return { [TAG]: type, ...extra };
150
+ }
151
+
152
+ /**
153
+ * @param {Array<Blob | Uint8Array>} bin
154
+ * @param {Blob | Uint8Array} part
155
+ * @returns {number}
156
+ */
157
+ function push(bin, part) {
158
+ bin.push(part);
159
+ return bin.length - 1;
160
+ }
161
+
162
+ /** @param {Uint8Array} view */
163
+ function copyBytes(view) {
164
+ return view.slice();
165
+ }
@@ -0,0 +1,211 @@
1
+ import { serializeValue, deserializeValue } from '../serialize.js';
2
+
3
+ /**
4
+ * @param {Array<Blob | Uint8Array>} bin
5
+ * @param {string[]} [names]
6
+ * @returns {Promise<object[]>}
7
+ */
8
+ export async function dumpDatabases(bin, names) {
9
+ let list = names;
10
+ if (!list) {
11
+ list =
12
+ typeof indexedDB.databases === 'function'
13
+ ? (await indexedDB.databases()).map((d) => d.name).filter(Boolean)
14
+ : [];
15
+ }
16
+
17
+ const dump = [];
18
+ for (const name of list) {
19
+ const db = await openExisting(name);
20
+ try {
21
+ const stores = [];
22
+ for (const storeName of [...db.objectStoreNames]) {
23
+ stores.push(await dumpStore(db, storeName, bin));
24
+ }
25
+ dump.push({ name: db.name, version: db.version, stores });
26
+ } finally {
27
+ db.close();
28
+ }
29
+ }
30
+ return dump;
31
+ }
32
+
33
+ /**
34
+ * @param {IDBDatabase} db
35
+ * @param {string} storeName
36
+ * @param {Array<Blob | Uint8Array>} bin
37
+ */
38
+ async function dumpStore(db, storeName, bin) {
39
+ const tx = db.transaction(storeName, 'readonly');
40
+ const store = tx.objectStore(storeName);
41
+
42
+ const schema = {
43
+ name: storeName,
44
+ keyPath: store.keyPath,
45
+ autoIncrement: store.autoIncrement,
46
+ indexes: [...store.indexNames].map((iname) => {
47
+ const ix = store.index(iname);
48
+ return { name: iname, keyPath: ix.keyPath, unique: ix.unique, multiEntry: ix.multiEntry };
49
+ })
50
+ };
51
+
52
+ const outOfLine = store.keyPath === null;
53
+ const values = await request(store.getAll());
54
+ const keys = outOfLine ? await request(store.getAllKeys()) : null;
55
+
56
+ const records = values.map((value, i) => {
57
+ const rec = { value: serializeValue(value, bin) };
58
+ if (outOfLine) rec.key = serializeValue(keys[i], bin);
59
+ return rec;
60
+ });
61
+
62
+ await transactionDone(tx);
63
+ return { ...schema, records };
64
+ }
65
+
66
+ /**
67
+ * @param {object[]} dump
68
+ * @param {Uint8Array[]} bin
69
+ * @param {{ clear?: boolean }} [options]
70
+ */
71
+ export async function restoreDatabases(dump, bin, { clear = true } = {}) {
72
+ for (const dbInfo of dump) {
73
+ if (clear) await deleteDatabase(dbInfo.name);
74
+ const db = await openForRestore(dbInfo, clear);
75
+ try {
76
+ for (const storeInfo of dbInfo.stores) {
77
+ await restoreStore(db, storeInfo, bin);
78
+ }
79
+ } finally {
80
+ db.close();
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {IDBDatabase} db
87
+ * @param {any} storeInfo
88
+ * @param {Uint8Array[]} bin
89
+ */
90
+ async function restoreStore(db, storeInfo, bin) {
91
+ const tx = db.transaction(storeInfo.name, 'readwrite');
92
+ const store = tx.objectStore(storeInfo.name);
93
+ const inline = storeInfo.keyPath !== null;
94
+
95
+ for (const rec of storeInfo.records) {
96
+ const value = deserializeValue(rec.value, bin);
97
+ if (inline) store.put(value);
98
+ else store.put(value, deserializeValue(rec.key, bin));
99
+ }
100
+
101
+ await transactionDone(tx);
102
+ }
103
+
104
+ /** @param {string} name */
105
+ function openExisting(name) {
106
+ return new Promise((resolve, reject) => {
107
+ const open = indexedDB.open(name);
108
+ open.onsuccess = () => {
109
+ const db = open.result;
110
+ db.onversionchange = () => db.close();
111
+ resolve(db);
112
+ };
113
+ open.onerror = () => reject(open.error);
114
+ });
115
+ }
116
+
117
+ /**
118
+ *
119
+ * @param {any} dbInfo
120
+ * @param {boolean} wasCleared
121
+ * @returns {Promise<IDBDatabase>}
122
+ */
123
+ async function openForRestore(dbInfo, wasCleared) {
124
+ if (wasCleared) {
125
+ return openAndUpgrade(dbInfo.name, dbInfo.version || 1, dbInfo.stores);
126
+ }
127
+
128
+ const current = await openExisting(dbInfo.name);
129
+ const needsUpgrade = dbInfo.stores.some((s) => !hasSchema(current, s));
130
+ if (!needsUpgrade) return current;
131
+
132
+ const targetVersion = Math.max(current.version + 1, dbInfo.version || 1);
133
+ current.close();
134
+ return openAndUpgrade(dbInfo.name, targetVersion, dbInfo.stores);
135
+ }
136
+
137
+ /**
138
+ * @param {IDBDatabase} db
139
+ * @param {any} storeInfo
140
+ */
141
+ function hasSchema(db, storeInfo) {
142
+ if (!db.objectStoreNames.contains(storeInfo.name)) return false;
143
+ if (storeInfo.indexes.length === 0) return true;
144
+ const store = db.transaction(storeInfo.name, 'readonly').objectStore(storeInfo.name);
145
+ return storeInfo.indexes.every((ix) => store.indexNames.contains(ix.name));
146
+ }
147
+
148
+ /**
149
+ * @param {string} name
150
+ * @param {number} version
151
+ * @param {any[]} stores
152
+ * @returns {Promise<IDBDatabase>}
153
+ */
154
+ function openAndUpgrade(name, version, stores) {
155
+ return new Promise((resolve, reject) => {
156
+ const open = indexedDB.open(name, version);
157
+ open.onupgradeneeded = () => {
158
+ const db = open.result;
159
+ const tx = open.transaction;
160
+ for (const s of stores) {
161
+ const store = db.objectStoreNames.contains(s.name)
162
+ ? tx.objectStore(s.name)
163
+ : db.createObjectStore(s.name, { keyPath: s.keyPath, autoIncrement: s.autoIncrement });
164
+ for (const ix of s.indexes) {
165
+ if (!store.indexNames.contains(ix.name)) {
166
+ store.createIndex(ix.name, ix.keyPath, {
167
+ unique: ix.unique,
168
+ multiEntry: ix.multiEntry
169
+ });
170
+ }
171
+ }
172
+ }
173
+ };
174
+ open.onsuccess = () => resolve(open.result);
175
+ open.onerror = () => reject(open.er open.onblocked = () => reject(blockedError(name));
176
+ });
177
+ }
178
+
179
+ /** @param {string} name */
180
+ function deleteDatabase(name) {
181
+ return new Promise((resolve, reject) => {
182
+ const req = indexedDB.deleteDatabase(name);
183
+ req.onsuccess = () => resolve(undefined);
184
+ req.onerror = () => reject(req.error);
185
+ req.onblocked = () => reject(blockedError(name));
186
+ });
187
+ }
188
+
189
+ /** @param {string} name */
190
+ function blockedError(name) {
191
+ return new DOMException(
192
+ `IndexedDB "${name}" is blocked by another open connection — close other tabs on this origin and retry`,
193
+ 'InvalidStateError'
194
+ );
195
+ }
196
+
197
+ /** @param {IDBRequest} req */
198
+ function request(req) {
199
+ return new Promise((resolve, reject) => {
200
+ req.onsuccess = () => resolve(req.result);
201
+ req.onerror = () => reject(req.error);
202
+ });
203
+ }
204
+
205
+ /** @param {IDBTransaction} tx */
206
+ function transactionDone(tx) {
207
+ return new Promise((resolve, reject) => {
208
+ tx.oncomplete = () => resolve(undefined);
209
+ tx.onabort = tx.onerror = () => reject(tx.error);
210
+ });
211
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @param {Array<Blob | Uint8Array>} bin
3
+ * @returns {Promise<object[] | null>}
4
+ */
5
+ export async function dumpOPFS(bin) {
6
+ const root = await getRoot();
7
+ if (!root) return null;
8
+ return dumpDir(root, bin);
9
+ }
10
+
11
+ /**
12
+ * @param {FileSystemDirectoryHandle} dir
13
+ * @param {Array<Blob | Uint8Array>} bin
14
+ */
15
+ async function dumpDir(dir, bin) {
16
+ const entries = [];
17
+ for await (const [name, handle] of dir.entries()) {
18
+ if (handle.kind === 'file') {
19
+ const file = await handle.getFile();
20
+ bin.push(file);
21
+ entries.push({
22
+ name,
23
+ kind: 'file',
24
+ b: bin.length - 1,
25
+ type: file.type,
26
+ lastModified: file.lastModified
27
+ });
28
+ } else {
29
+ entries.push({ name, kind: 'directory', children: await dumpDir(handle, bin) });
30
+ }
31
+ }
32
+ return entries;
33
+ }
34
+
35
+ /**
36
+ * @param {object[] | null | undefined} tree
37
+ * @param {Uint8Array[]} bin
38
+ * @param {{ clear?: boolean }} [options]
39
+ */
40
+ export async function restoreOPFS(tree, bin, { clear = true } = {}) {
41
+ if (!tree) return;
42
+ const root = await getRoot();
43
+ if (!root) return;
44
+
45
+ if (clear) {
46
+ const names = [];
47
+ for await (const name of root.keys()) names.push(name);
48
+ for (const name of names) await root.removeEntry(name, { recursive: true });
49
+ }
50
+
51
+ await restoreDir(root, tree, bin);
52
+ }
53
+
54
+ /**
55
+ * @param {FileSystemDirectoryHandle} dir
56
+ * @param {object[]} entries
57
+ * @param {Uint8Array[]} bin
58
+ */
59
+ async function restoreDir(dir, entries, bin) {
60
+ for (const entry of entries) {
61
+ if (entry.kind === 'file') {
62
+ const handle = await dir.getFileHandle(entry.name, { create: true });
63
+ const writable = await handle.createWritable();
64
+ await writable.write(bin[entry.b]);
65
+ await writable.close();
66
+ } else {
67
+ const sub = await dir.getDirectoryHandle(entry.name, { create: true });
68
+ await restoreDir(sub, entry.children, bin);
69
+ }
70
+ }
71
+ }
72
+
73
+ async function getRoot() {
74
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
75
+ try {
76
+ return await navigator.storage.getDirectory();
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @param {Storage} storage
3
+ * @returns {Record<string, string>}
4
+ */
5
+ export function dumpWebStorage(storage) {
6
+ /** @type {Record<string, string>} */
7
+ const out = {};
8
+ for (let i = 0; i < storage.length; i++) {
9
+ const key = storage.key(i);
10
+ if (key !== null) out[key] = storage.getItem(key) ?? '';
11
+ }
12
+ return out;
13
+ }
14
+
15
+ /**
16
+ * @param {Storage} storage
17
+ * @param {Record<string, string>} data
18
+ * @param {{ clear?: boolean }} [options]
19
+ */
20
+ export function restoreWebStorage(storage, data, { clear = false } = {}) {
21
+ if (clear) storage.clear();
22
+ for (const [key, value] of Object.entries(data)) storage.setItem(key, value);
23
+ }