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 +21 -0
- package/README.md +99 -0
- package/package.json +39 -0
- package/src/container.js +97 -0
- package/src/index.d.ts +41 -0
- package/src/index.js +146 -0
- package/src/serialize.js +165 -0
- package/src/storage/indexeddb.js +211 -0
- package/src/storage/opfs.js +80 -0
- package/src/storage/webstorage.js +23 -0
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
|
+
}
|
package/src/container.js
ADDED
|
@@ -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
|
+
}
|
package/src/serialize.js
ADDED
|
@@ -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
|
+
}
|