├── .editorconfig ├── .github └── workflows │ └── build-testbed.yml ├── .gitignore ├── .npmignore ├── LICENSE.txt ├── README.md ├── package.json ├── scripts ├── build-all.js ├── build-gh-pages.js └── postinstall.js ├── src ├── adapter.cookie.js ├── adapter.idb.js ├── adapter.local-storage.js ├── adapter.opfs-worker.js ├── adapter.opfs.js ├── adapter.session-storage.js ├── copyright-header.txt ├── many.js ├── util.js └── worker.opfs.js └── test ├── index.html └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = tab 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | 9 | [*.md] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.yml] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/build-testbed.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build-TestBed 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the "main" branch 8 | push: 9 | branches: [ "main" ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow one concurrent deployment 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: true 23 | 24 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 25 | jobs: 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 33 | - uses: actions/checkout@v4 34 | 35 | # Runs a set of commands using the runners shell 36 | - name: install deps and build test bed 37 | run: | 38 | npm install 39 | npm run build:gh-pages 40 | - name: Setup Pages 41 | uses: actions/configure-pages@v5 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | # Upload built files 46 | path: './.gh-build' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .gh-build/ 4 | package-lock.json 5 | test/src 6 | test/dist 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .npmignore 3 | .gitignore 4 | .editorconfig 5 | node_modules/ 6 | .gh-build/ 7 | test/src 8 | test/dist 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Kyle Simpson 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storage 2 | 3 | [![npm Module](https://badge.fury.io/js/@byojs%2Fstorage.svg)](https://www.npmjs.org/package/@byojs/storage) 4 | [![License](https://img.shields.io/badge/license-MIT-a1356a)](LICENSE.txt) 5 | 6 | **Storage** provides a set of adapters for easy client-side key-value storage. 7 | 8 | ```js 9 | // for IndexedDB: 10 | import { get, set } from "@byojs/storage/idb"; 11 | 12 | await set("Hello","World!"); // true 13 | 14 | await get("Hello"); // "World!" 15 | ``` 16 | 17 | ---- 18 | 19 | [Library Tests (Demo)](https://byojs.github.io/storage/) 20 | 21 | ---- 22 | 23 | ## Overview 24 | 25 | The main purpose of **Storage** is to provide a set of adapters that normalize across various client side storage mechanisms (`localStorage` / `sessionStorage`, IndexedDB, cookies, and OPFS) with a consistent key-value API (`get()`, `set()`, etc). 26 | 27 | ## Client Side Storage Adapters 28 | 29 | **Storage** ships with adapters for the following storage mechanisms: 30 | 31 | * `idb`: [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) 32 | 33 | * `local-storage`: [Web Storage `localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) 34 | 35 | * `session-storage`: [Web Storage `sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) 36 | 37 | * `cookie`: [Web cookies](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies) 38 | 39 | * `opfs`: [Origin Private File System (OPFS)](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system), specifically [the virtual origin filesystem](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/getDirectory) 40 | 41 | **Warning:** [Browser support for `write()`](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write#browser_compatibility) into OPFS from the main thread (as this adapter does) is limited to Chromium and Firefox browsers (not Safari). See `opfs-worker` for broader device/browser support. 42 | 43 | * `opfs-worker`: Uses a Web Worker (background thread) for OPFS access, specifically to [expand OPFS support to most devices/browsers ](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle/createSyncAccessHandle#browser_compatibility) (via synchronous `write()` in a Web Worker or Service Worker). 44 | 45 | **Warning:** Web workers in some cases require modified security settings (for the site/app) -- for example, [a Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), specifically [the `worker-src` directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src). 46 | 47 | Each of these client-side storage mechanisms has its own pros/cons, so choice should be made carefully. 48 | 49 | However, IndexedDB (`idb` adapter) is the most robust and flexible option, and should generally be considered the best default. 50 | 51 | ### Storage Limitations 52 | 53 | These client storage mechanisms have different storage limits, which in some cases may be rather small (i.e., 5MB for Local-Storage, or 4KB for cookies). Be careful with `set()` calls: look for the [`QuotaExceededError` DOM exception](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#quotaexceedederror) being thrown, and determine what data can be freed up, or potentially switch to another storage mechanism with higher limits. 54 | 55 | For example: 56 | 57 | ```js 58 | try { 59 | await set("session-jwt",sessionJWT); 60 | } 61 | catch (err) { 62 | if (err.reason?.name == "QuotaExceededError") { 63 | // handle storage limit failure! 64 | } 65 | } 66 | ``` 67 | 68 | #### Web Storage (`localStorage`, `sessionStorage`) 69 | 70 | The web storage mechanisms (`localStorage`, `sessionStorage`) are by far the most common place web applications storage client-side data. However, there are some factors to consider when using the `local-storage` / `session-storage` adapters. 71 | 72 | Each mechanism is size-limited to 5MB, on most all browsers/devices. And they are only available from main browser threads, not in workers (Web Workers, Service Workers). 73 | 74 | #### Cookies 75 | 76 | The `cookie` adapter stores data in browser cookies. There are however some strong caveats to consider before choosing this storage mechanism. 77 | 78 | Cookies are limited to ~4KB. Moreover, the provided data object has been JSON-serialized and URI-encoded (e.g, replacing `" "` with `%20`, etc). These steps inflate your data size further towards the 4KB limit, so you might only be able to squeeze ~3KB of original application data in, under the limit. 79 | 80 | Also, cookies are typically sent on *every request* to a first-party origin server (images, CSS, fetch calls, etc). So that data (encrypted, of course) will be sent remotely, and will significantly weigh down all those requests. 81 | 82 | Moreover, cookies are never "persistent" storage, and are subject to both expirations (maximum allowed is ~400 days out from the last update) and to users clearing them. 83 | 84 | All these concerns considered, the `cookie` adapter *really should not be used* except as a last resort, for small amounts of data. For example, your app might use this storage as a temporary location if normal storage quota has been reached, and later synchronize/migrate/backup off-device, etc. 85 | 86 | #### Origin Private File System 87 | 88 | The [Origin Private File System (OPFS)](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) web feature can be used to read/write "files" in a virtual filesystem on the client's device (private to the page's origin). The `opfs` and `opfs-worker` adapters provided with this library create JSON "files" in OPFS to store the data, one file per value. 89 | 90 | ## Deployment / Import 91 | 92 | ```cmd 93 | npm install @byojs/storage 94 | ``` 95 | 96 | The [**@byojs/storage** npm package](https://npmjs.com/package/@byojs/storage) includes a `dist/` directory with all files you need to deploy **Storage** (and its dependencies) into your application/project. 97 | 98 | **Note:** If you obtain this library via git instead of npm, you'll need to [build `dist/` manually](#re-building-dist) before deployment. 99 | 100 | ### Using a bundler 101 | 102 | If you are using a bundler (Astro, Vite, Webpack, etc) for your web application, you should not need to manually copy any files from `dist/`. 103 | 104 | Just `import` the adapter(s) of your choice, like so: 105 | 106 | ```js 107 | // {TYPE}: "idb", "local-storage", etc 108 | import { get, set } from "@byojs/storage/{TYPE}"; 109 | ``` 110 | 111 | The bundler tool should pick up and find whatever files (and dependencies) are needed. 112 | 113 | ### Without using a bundler 114 | 115 | If you are not using a bundler (Astro, Vite, Webpack, etc) for your web application, and just deploying the contents of `dist/` as-is without changes (e.g., to `/path/to/js-assets/storage/`), you'll need an [Import Map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) in your app's HTML: 116 | 117 | ```html 118 | 132 | ``` 133 | 134 | Now, you'll be able to `import` the library in your app in a friendly/readable way: 135 | 136 | ```js 137 | // {TYPE}: "idb", "local-storage", etc 138 | import { get, set } from "storage/{TYPE}"; 139 | ``` 140 | 141 | **Note:** If you omit the above *adapter* import-map entries, you can still `import` **Storage** by specifying the proper full path to whichever `adapter.*.mjs` file(s) you want to use. 142 | 143 | However, the entry above for `idb-keyval` is more required. Alternatively, you'll have to edit the `adapter.idb.mjs` file to change its `import` specifier for `idb-keyval` to the proper path to `idb-keyval.js`. 144 | 145 | ## Storage API 146 | 147 | The API provided by the **Storage** adapters can be accessed, for each adapter, like this: 148 | 149 | ```js 150 | // for IndexedDB: 151 | import { has, get, set, remove } from "{..}/idb"; 152 | 153 | await has("Hello"); // false 154 | 155 | await set("Hello","World!"); // true 156 | 157 | await has("Hello"); // true 158 | 159 | await get("Hello"); // "World!" 160 | 161 | await remove("Hello"); // true 162 | ``` 163 | 164 | The key-value oriented methods available on each adapter's API are: 165 | 166 | * `has(name)`: has a value of `name` been set in this storage before? 167 | 168 | * `get(name)`: get a value of `name` (if any) from storage 169 | 170 | * `set(name,value)`: set a `value` at `name` into storage 171 | 172 | `value` can be any JSON-serializable object (object, array) or any primitive value; however, bare primitive values will end up being stored (and then retrieved) as strings. 173 | 174 | Further, any string value that is parseable as JSON *will be parsed* as JSON; for example, the string value `"[1,2,3]"` will be parsed as a JSON-serialized array, and return `[1,2,3]` instead. 175 | 176 | * `remove(name)`: remove `name` (if any) from storage 177 | 178 | * `keys()`: returns an array of existing keys in storage 179 | 180 | * `entries()`: returns an array of `[ key, value ]` tuples 181 | 182 | **NOTE:** All of these methods are async (promise-returning). 183 | 184 | ### Many API 185 | 186 | The `get(..)`, `set(..)`, and `remove(..)` methods also support a bulk-call form, to process multiple keys/values at once: 187 | 188 | ```js 189 | // for IndexedDB: 190 | import { has, get, set, remove } from "{..}/idb"; 191 | 192 | var entries = [ 193 | [ "Hello", "World!" ], 194 | [ "special", 42 ] 195 | ]; 196 | 197 | await set.many(entries); 198 | // true 199 | 200 | var keys = entries.map(([ key, val ]) => key); 201 | // [ "Hello", "special" ] 202 | 203 | await get.many(keys); 204 | // [ "World!", 42 ] 205 | 206 | await Promise.all(keys.map(has)); 207 | // [ true, true ] 208 | 209 | await remove.many(keys); 210 | // true 211 | 212 | await Promise.all(keys.map(has)); 213 | // [ false, false ] 214 | ``` 215 | 216 | The `*.many(..)` methods also accept objects: 217 | 218 | ```js 219 | import { has, get, set, remove } from "{..}/idb"; 220 | 221 | var obj = { 222 | Hello: "World!", 223 | special: 42 224 | }; 225 | 226 | await set.many(obj); 227 | // true 228 | 229 | var keysObj = { 230 | Hello: null, 231 | special: null 232 | }; 233 | var keysArr = Object.keys(keys) 234 | 235 | await get.many(keysObj); 236 | // [ "World!", 42 ] 237 | 238 | await Promise.all(keysArr.map(has)); 239 | // [ true, true ] 240 | 241 | await remove.many(keysObj); 242 | // true 243 | 244 | await Promise.all(keysArr.map(has)); 245 | // [ false, false ] 246 | ``` 247 | 248 | ## Re-building `dist/*` 249 | 250 | If you need to rebuild the `dist/*` files for any reason, run: 251 | 252 | ```cmd 253 | # only needed one time 254 | npm install 255 | 256 | npm run build:all 257 | ``` 258 | 259 | ## Tests 260 | 261 | This library only works in a browser, so its automated test suite must also be run in a browser. 262 | 263 | Visit [`https://byojs.github.io/storage/`](https://byojs.github.io/storage/) and click the "run tests" button. 264 | 265 | ### Run Locally 266 | 267 | To instead run the tests locally, first make sure you've [already run the build](#re-building-dist), then: 268 | 269 | ```cmd 270 | npm test 271 | ``` 272 | 273 | This will start a static file webserver (no server logic), serving the interactive test page from `http://localhost:8080/`; visit this page in your browser and click the "run tests" button. 274 | 275 | By default, the `test/test.js` file imports the code from the `src/*` directly. However, to test against the `dist/*` files (as included in the npm package), you can modify `test/test.js`, updating the `/src` in its `import` statements to `/dist` (see the import-map in `test/index.html` for more details). 276 | 277 | ## License 278 | 279 | [![License](https://img.shields.io/badge/license-MIT-a1356a)](LICENSE.txt) 280 | 281 | All code and documentation are (c) 2024 Kyle Simpson and released under the [MIT License](http://getify.mit-license.org/). A copy of the MIT License [is also included](LICENSE.txt). 282 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@byojs/storage", 3 | "description": "Simple key-value storage API backed by various client storage mechanisms", 4 | "version": "0.12.1", 5 | "exports": { 6 | "./idb": "./dist/adapter.idb.mjs", 7 | "./local-storage": "./dist/adapter.local-storage.mjs", 8 | "./session-storage": "./dist/adapter.session-storage.mjs", 9 | "./cookie": "./dist/adapter.cookie.mjs", 10 | "./opfs": "./dist/adapter.opfs.mjs", 11 | "./opfs-worker": "./dist/adapter.opfs-worker.mjs" 12 | }, 13 | "browser": { 14 | "@byojs/storage/idb": "./dist/adapter.idb.mjs", 15 | "@byojs/storage/local-storage": "./dist/adapter.local-storage.mjs", 16 | "@byojs/storage/session-storage": "./dist/adapter.session-storage.mjs", 17 | "@byojs/storage/cookie": "./dist/adapter.cookie.mjs", 18 | "@byojs/storage/opfs": "./dist/adapter.opfs.mjs", 19 | "@byojs/storage/opfs-worker": "./dist/adapter.opfs-worker.mjs" 20 | }, 21 | "scripts": { 22 | "build:all": "node scripts/build-all.js", 23 | "build:gh-pages": "npm run build:all && node scripts/build-gh-pages.js", 24 | "build": "npm run build:all", 25 | "test:start": "npx http-server test/ -p 8080", 26 | "test": "npm run test:start", 27 | "postinstall": "node scripts/postinstall.js", 28 | "prepublishOnly": "npm run build:all" 29 | }, 30 | "dependencies": { 31 | "idb-keyval": "~6.2.1" 32 | }, 33 | "devDependencies": { 34 | "micromatch": "~4.0.8", 35 | "recursive-readdir-sync": "~1.0.6", 36 | "terser": "~5.37.0" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/byojs/storage.git" 41 | }, 42 | "keywords": [ 43 | "storage" 44 | ], 45 | "bugs": { 46 | "url": "https://github.com/byojs/storage/issues", 47 | "email": "getify@gmail.com" 48 | }, 49 | "homepage": "https://github.com/byojs/storage", 50 | "author": "Kyle Simpson ", 51 | "license": "MIT" 52 | } 53 | -------------------------------------------------------------------------------- /scripts/build-all.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var fsp = require("fs/promises"); 8 | 9 | var micromatch = require("micromatch"); 10 | var recursiveReadDir = require("recursive-readdir-sync"); 11 | var terser = require("terser"); 12 | 13 | const PKG_ROOT_DIR = path.join(__dirname,".."); 14 | const SRC_DIR = path.join(PKG_ROOT_DIR,"src"); 15 | const MAIN_COPYRIGHT_HEADER = path.join(SRC_DIR,"copyright-header.txt"); 16 | const NODE_MODULES_DIR = path.join(PKG_ROOT_DIR,"node_modules"); 17 | const IDBKEYVAL_DIST = path.join(NODE_MODULES_DIR,"idb-keyval","dist","index.js"); 18 | 19 | const DIST_DIR = path.join(PKG_ROOT_DIR,"dist"); 20 | const DIST_EXTERNAL_DIR = path.join(DIST_DIR,"external"); 21 | 22 | 23 | main().catch(console.error); 24 | 25 | 26 | // ********************** 27 | 28 | async function main() { 29 | console.log("*** Building JS ***"); 30 | 31 | // try to make various dist/ directories, if needed 32 | for (let dir of [ 33 | DIST_DIR, 34 | DIST_EXTERNAL_DIR, 35 | ]) { 36 | if (!(await safeMkdir(dir))) { 37 | throw new Error(`Target directory (${dir}) does not exist and could not be created.`); 38 | } 39 | } 40 | 41 | // read package.json 42 | var packageJSON = require(path.join(PKG_ROOT_DIR,"package.json")); 43 | // read version number from package.json 44 | var version = packageJSON.version; 45 | // read main src copyright-header text 46 | var mainCopyrightHeader = await fsp.readFile(MAIN_COPYRIGHT_HEADER,{ encoding: "utf8", }); 47 | // render main copyright header with version and year 48 | mainCopyrightHeader = ( 49 | mainCopyrightHeader 50 | .replace(/#VERSION#/g,version) 51 | .replace(/#YEAR#/g,(new Date()).getFullYear()) 52 | ); 53 | 54 | // build src/* to bundlers/* 55 | await buildFiles( 56 | recursiveReadDir(SRC_DIR), 57 | SRC_DIR, 58 | DIST_DIR, 59 | (contents,outputPath,filename = path.basename(outputPath)) => prepareFileContents( 60 | contents.replace(/(\.\/(util|many|worker\.opfs))\.js/g,"$1.mjs"), 61 | outputPath.replace(/\.js$/,".mjs"), 62 | filename.replace(/\.js$/,".mjs") 63 | ), 64 | /*skipPatterns=*/[ "**/*.txt", "**/*.json", "**/external" ] 65 | ); 66 | 67 | // build dist/external/* 68 | await buildFiles( 69 | [ IDBKEYVAL_DIST, ], 70 | path.dirname(IDBKEYVAL_DIST), 71 | DIST_EXTERNAL_DIR, 72 | (contents,outputPath) => ({ 73 | contents, 74 | outputPath: path.join(path.dirname(outputPath),"idb-keyval.js"), 75 | }) 76 | ); 77 | 78 | console.log("Complete."); 79 | 80 | 81 | // **************************** 82 | 83 | async function prepareFileContents(contents,outputPath,filename = path.basename(outputPath)) { 84 | // JS file (to minify)? 85 | if (/\.[mc]?js$/i.test(filename)) { 86 | contents = await minifyJS(contents); 87 | } 88 | 89 | // add copyright header 90 | return { 91 | contents: `${ 92 | mainCopyrightHeader.replace(/#FILENAME#/g,filename) 93 | }\n${ 94 | contents 95 | }`, 96 | 97 | outputPath, 98 | }; 99 | } 100 | } 101 | 102 | async function buildFiles(files,fromBasePath,toDir,processFileContents,skipPatterns) { 103 | for (let fromPath of files) { 104 | // should we skip copying this file? 105 | if (matchesSkipPattern(fromPath,skipPatterns)) { 106 | continue; 107 | } 108 | let relativePath = fromPath.slice(fromBasePath.length); 109 | let outputPath = path.join(toDir,relativePath); 110 | let contents = await fsp.readFile(fromPath,{ encoding: "utf8", }); 111 | ({ contents, outputPath, } = await processFileContents(contents,outputPath)); 112 | let outputDir = path.dirname(outputPath); 113 | 114 | if (!(fs.existsSync(outputDir))) { 115 | if (!(await safeMkdir(outputDir))) { 116 | throw new Error(`While copying files, directory (${outputDir}) could not be created.`); 117 | } 118 | } 119 | 120 | await fsp.writeFile(outputPath,contents,{ encoding: "utf8", }); 121 | } 122 | } 123 | 124 | async function minifyJS(contents,esModuleFormat = true) { 125 | let result = await terser.minify(contents,{ 126 | mangle: { 127 | keep_fnames: true, 128 | }, 129 | compress: { 130 | keep_fnames: true, 131 | }, 132 | output: { 133 | comments: /^!/, 134 | }, 135 | module: esModuleFormat, 136 | }); 137 | if (!(result && result.code)) { 138 | if (result.error) throw result.error; 139 | else throw result; 140 | } 141 | return result.code; 142 | } 143 | 144 | function matchesSkipPattern(pathStr,skipPatterns) { 145 | if (skipPatterns && skipPatterns.length > 0) { 146 | return (micromatch(pathStr,skipPatterns).length > 0); 147 | } 148 | } 149 | 150 | async function safeMkdir(pathStr) { 151 | if (!fs.existsSync(pathStr)) { 152 | try { 153 | await fsp.mkdir(pathStr,{ recursive: true, mode: 0o755, }); 154 | return true; 155 | } 156 | catch (err) {} 157 | return false; 158 | } 159 | return true; 160 | } 161 | -------------------------------------------------------------------------------- /scripts/build-gh-pages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var fsp = require("fs/promises"); 8 | 9 | var micromatch = require("micromatch"); 10 | var recursiveReadDir = require("recursive-readdir-sync"); 11 | 12 | const PKG_ROOT_DIR = path.join(__dirname,".."); 13 | const DIST_DIR = path.join(PKG_ROOT_DIR,"dist"); 14 | const TEST_DIR = path.join(PKG_ROOT_DIR,"test"); 15 | const BUILD_DIR = path.join(PKG_ROOT_DIR,".gh-build"); 16 | const BUILD_DIST_DIR = path.join(BUILD_DIR,"dist"); 17 | 18 | 19 | main().catch(console.error); 20 | 21 | 22 | // ********************** 23 | 24 | async function main() { 25 | console.log("*** Building GH-Pages Deployment ***"); 26 | 27 | // try to make various .gh-build/** directories, if needed 28 | for (let dir of [ BUILD_DIR, BUILD_DIST_DIR, ]) { 29 | if (!(await safeMkdir(dir))) { 30 | throw new Error(`Target directory (${dir}) does not exist and could not be created.`); 31 | } 32 | } 33 | 34 | // copy test/* files 35 | await copyFilesTo( 36 | recursiveReadDir(TEST_DIR), 37 | TEST_DIR, 38 | BUILD_DIR, 39 | /*skipPatterns=*/[ "**/src", "**/dist", ] 40 | ); 41 | 42 | // patch import reference in test.js to point to dist/ 43 | var testJSPath = path.join(BUILD_DIR,"test.js"); 44 | var testJSContents = await fsp.readFile(testJSPath,{ encoding: "utf8", }); 45 | testJSContents = testJSContents.replace(/(import[^;]+"storage\/)src([^"]*)"/g,"$1dist$2\""); 46 | await fsp.writeFile(testJSPath,testJSContents,{ encoding: "utf8", }); 47 | 48 | // copy dist/* files 49 | await copyFilesTo( 50 | recursiveReadDir(DIST_DIR), 51 | DIST_DIR, 52 | BUILD_DIST_DIR 53 | ); 54 | 55 | console.log("Complete."); 56 | } 57 | 58 | async function copyFilesTo(files,fromBasePath,toDir,skipPatterns) { 59 | for (let fromPath of files) { 60 | // should we skip copying this file? 61 | if (matchesSkipPattern(fromPath,skipPatterns)) { 62 | continue; 63 | } 64 | 65 | let relativePath = fromPath.slice(fromBasePath.length); 66 | let outputPath = path.join(toDir,relativePath); 67 | let outputDir = path.dirname(outputPath); 68 | 69 | if (!(fs.existsSync(outputDir))) { 70 | if (!(await safeMkdir(outputDir))) { 71 | throw new Error(`While copying files, directory (${outputDir}) could not be created.`); 72 | } 73 | } 74 | 75 | await fsp.copyFile(fromPath,outputPath); 76 | } 77 | } 78 | 79 | function matchesSkipPattern(pathStr,skipPatterns) { 80 | if (skipPatterns && skipPatterns.length > 0) { 81 | return (micromatch(pathStr,skipPatterns).length > 0); 82 | } 83 | } 84 | 85 | async function safeMkdir(pathStr) { 86 | if (!fs.existsSync(pathStr)) { 87 | try { 88 | await fsp.mkdir(pathStr,{ recursive: true, mode: 0o755, }); 89 | return true; 90 | } 91 | catch (err) {} 92 | return false; 93 | } 94 | return true; 95 | } 96 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | 8 | const PKG_ROOT_DIR = path.join(__dirname,".."); 9 | const SRC_DIR = path.join(PKG_ROOT_DIR,"src"); 10 | const TEST_DIR = path.join(PKG_ROOT_DIR,"test"); 11 | 12 | try { fs.symlinkSync(path.join("..","src"),path.join(TEST_DIR,"src"),"dir"); } catch (err) {} 13 | try { fs.symlinkSync(path.join("..","dist"),path.join(TEST_DIR,"dist"),"dir"); } catch (err) {} 14 | -------------------------------------------------------------------------------- /src/adapter.cookie.js: -------------------------------------------------------------------------------- 1 | import { safeJSONParse, } from "./util.js"; 2 | import { 3 | setMany, 4 | getMany, 5 | removeMany, 6 | } from "./many.js"; 7 | 8 | 9 | // *********************** 10 | 11 | get.many = (...args) => getMany(get,...args); 12 | set.many = (...args) => setMany(set,...args); 13 | remove.many = (...args) => removeMany(remove,...args); 14 | 15 | 16 | // *********************** 17 | 18 | var storageType = "cookie"; 19 | export { 20 | storageType, 21 | has, 22 | get, 23 | set, 24 | remove, 25 | keys, 26 | entries, 27 | } 28 | var publicAPI = { 29 | storageType, 30 | has, 31 | get, 32 | set, 33 | remove, 34 | keys, 35 | entries, 36 | }; 37 | export default publicAPI; 38 | 39 | 40 | // *********************** 41 | 42 | async function has(name) { 43 | return (name in getAllCookies()); 44 | } 45 | 46 | async function get(name) { 47 | return safeJSONParse(getAllCookies()[name]); 48 | } 49 | 50 | async function set(name,value) { 51 | var expires = new Date(); 52 | var expiresSeconds = 400 * 24 * 60 * 60; // 400 days from now (max allowed) 53 | expires.setTime(expires.getTime() + (expiresSeconds * 1000)); 54 | var cookieName = encodeURIComponent(name); 55 | var cookieValue = encodeURIComponent( 56 | value != null && typeof value == "object" ? 57 | JSON.stringify(value) : 58 | String(value) 59 | ); 60 | // cookieName + cookieValue > 4kb? 61 | // (https://chromestatus.com/feature/4946713618939904) 62 | if ((cookieName.length + cookieValue.length) > 4096) { 63 | // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#quotaexceedederror 64 | throw new DOMException("Cookie max size (4KB) exceeded","QuotaExceededError"); 65 | } 66 | var cookie = [ 67 | `${cookieName}=${cookieValue}`, 68 | `domain=${document.location.hostname}`, 69 | "path=/", 70 | "samesite=strict", 71 | "secure", 72 | `expires=${expires.toGMTString()}`, 73 | `maxage=${expiresSeconds}`, 74 | ].join("; "); 75 | document.cookie = cookie; 76 | return true; 77 | } 78 | 79 | async function remove(name) { 80 | var expires = new Date(); 81 | expires.setTime(expires.getTime() - 1000); 82 | document.cookie = [ 83 | `${encodeURIComponent(name)}=`, 84 | `domain=${document.location.hostname}`, 85 | "path=/", 86 | "samesite=strict", 87 | "secure", 88 | `expires=${expires.toGMTString()}`, 89 | "maxage=0" 90 | ].join("; "); 91 | return true; 92 | } 93 | 94 | async function keys() { 95 | return Object.keys(getAllCookies()); 96 | } 97 | 98 | async function entries() { 99 | return ( 100 | Object.entries(getAllCookies()) 101 | .map(([ name, value ]) => ([ 102 | name, 103 | safeJSONParse(value) 104 | ])) 105 | ); 106 | } 107 | 108 | function getAllCookies() { 109 | return ( 110 | Object.fromEntries( 111 | document.cookie 112 | .split(/\s*;\s*/) 113 | .filter(Boolean) 114 | .map(rawCookieVal => ( 115 | rawCookieVal.split(/\s*=\s*/) 116 | .map(val => decodeURIComponent(val)) 117 | )) 118 | ) 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/adapter.idb.js: -------------------------------------------------------------------------------- 1 | import { 2 | get as idbGet, 3 | set as idbSet, 4 | del as idbDel, 5 | keys as idbKeys, 6 | entries as idbEntries, 7 | } from "idb-keyval"; 8 | import { 9 | setMany, 10 | getMany, 11 | removeMany, 12 | } from "./many.js"; 13 | 14 | 15 | // *********************** 16 | 17 | get.many = (...args) => getMany(get,...args); 18 | set.many = (...args) => setMany(set,...args); 19 | remove.many = (...args) => removeMany(remove,...args); 20 | 21 | 22 | // *********************** 23 | 24 | var storageType = "idb"; 25 | export { 26 | storageType, 27 | has, 28 | get, 29 | set, 30 | remove, 31 | idbKeys as keys, 32 | idbEntries as entries, 33 | } 34 | var publicAPI = { 35 | storageType, 36 | has, 37 | get, 38 | set, 39 | remove, 40 | keys: idbKeys, 41 | entries: idbEntries, 42 | }; 43 | export default publicAPI; 44 | 45 | 46 | // *********************** 47 | 48 | async function has(name) { 49 | return ((await idbKeys()) || []).includes(name); 50 | } 51 | 52 | async function get(name) { 53 | var value = await idbGet(name); 54 | return (value ?? null); 55 | } 56 | 57 | async function set(name,value) { 58 | try { 59 | await idbSet( 60 | name, 61 | value != null && typeof value == "object" ? 62 | value : 63 | String(value) 64 | ); 65 | return true; 66 | } 67 | catch (err) { 68 | if (err.name == "QuotaExceededError") { 69 | throw new Error("IndexedDB storage is full.",{ cause: err, }); 70 | } 71 | throw err; 72 | } 73 | } 74 | 75 | async function remove(name) { 76 | await idbDel(name); 77 | return true; 78 | } 79 | -------------------------------------------------------------------------------- /src/adapter.local-storage.js: -------------------------------------------------------------------------------- 1 | import { safeJSONParse, } from "./util.js"; 2 | import { 3 | setMany, 4 | getMany, 5 | removeMany, 6 | } from "./many.js"; 7 | 8 | 9 | // *********************** 10 | 11 | get.many = (...args) => getMany(get,...args); 12 | set.many = (...args) => setMany(set,...args); 13 | remove.many = (...args) => removeMany(remove,...args); 14 | 15 | 16 | // *********************** 17 | 18 | var storageType = "local-storage"; 19 | export { 20 | storageType, 21 | has, 22 | get, 23 | set, 24 | remove, 25 | keys, 26 | entries, 27 | }; 28 | var publicAPI = { 29 | storageType, 30 | has, 31 | get, 32 | set, 33 | remove, 34 | keys, 35 | entries, 36 | }; 37 | export default publicAPI; 38 | 39 | // *********************** 40 | 41 | async function has(name) { 42 | // note: https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem#return_value 43 | return (window.localStorage.getItem(name) !== null); 44 | } 45 | 46 | async function get(name) { 47 | return safeJSONParse(window.localStorage.getItem(name)); 48 | } 49 | 50 | async function set(name,value) { 51 | try { 52 | window.localStorage.setItem( 53 | name, 54 | value != null && typeof value == "object" ? 55 | JSON.stringify(value) : 56 | String(value) 57 | ); 58 | return true; 59 | } 60 | catch (err) { 61 | if (err.name == "QuotaExceededError") { 62 | throw new Error("Local-storage is full.",{ cause: err, }); 63 | } 64 | throw err; 65 | } 66 | } 67 | 68 | async function remove(name) { 69 | window.localStorage.removeItem(name); 70 | return true; 71 | } 72 | 73 | async function keys() { 74 | var storeKeys = []; 75 | for (let i = 0; i < window.localStorage.length; i++) { 76 | storeKeys.push(window.localStorage.key(i)); 77 | } 78 | return storeKeys; 79 | } 80 | 81 | async function entries() { 82 | var storeEntries = []; 83 | for (let i = 0; i < window.localStorage.length; i++) { 84 | let name = window.localStorage.key(i); 85 | storeEntries.push([ 86 | name, 87 | safeJSONParse(window.localStorage.getItem(name)), 88 | ]); 89 | } 90 | return storeEntries; 91 | } 92 | -------------------------------------------------------------------------------- /src/adapter.opfs-worker.js: -------------------------------------------------------------------------------- 1 | import { 2 | safeJSONParse, 3 | isPromise, 4 | getDeferred, 5 | } from "./util.js"; 6 | 7 | 8 | // *********************** 9 | 10 | get.many = async (...args) => ( 11 | (await sendToWorker("get.many",...args)) 12 | .map(safeJSONParse) 13 | ); 14 | set.many = (...args) => sendToWorker("set.many",...args); 15 | remove.many = (...args) => sendToWorker("remove.many",...args); 16 | 17 | 18 | // *********************** 19 | 20 | var worker = null; 21 | var pending = null; 22 | var workerListeners = []; 23 | 24 | var storageType = "opfs-worker"; 25 | export { 26 | storageType, 27 | has, 28 | get, 29 | set, 30 | remove, 31 | keys, 32 | entries, 33 | } 34 | var publicAPI = { 35 | storageType, 36 | has, 37 | get, 38 | set, 39 | remove, 40 | keys, 41 | entries, 42 | }; 43 | export default publicAPI; 44 | 45 | 46 | // *********************** 47 | 48 | function has(name) { 49 | return sendToWorker("has",name); 50 | } 51 | 52 | async function get(name) { 53 | return safeJSONParse(await sendToWorker("get",name)); 54 | } 55 | 56 | function set(name,value) { 57 | return sendToWorker("set",name,value); 58 | } 59 | 60 | function remove(name) { 61 | return sendToWorker("remove",name); 62 | } 63 | 64 | function keys() { 65 | return sendToWorker("keys"); 66 | } 67 | 68 | async function entries() { 69 | var response = await sendToWorker("entries"); 70 | return response.map(([ name, value, ]) => ([ 71 | name, 72 | safeJSONParse(value), 73 | ])); 74 | } 75 | 76 | async function sendToWorker(opName,...args) { 77 | if (worker == null) { 78 | worker = addWorkerListener("ready").then(() => loadingWorker); 79 | let loadingWorker = new Worker( 80 | new URL("./worker.opfs.js",import.meta.url), 81 | { type: "module", name: "opfsWorker", } 82 | ); 83 | loadingWorker.addEventListener("message",onWorkerMessage); 84 | } 85 | // note: trick to skip `await` microtask when 86 | // not a promise 87 | worker = isPromise(worker) ? await worker : worker; 88 | 89 | if (isPromise(pending)) { 90 | await pending; 91 | } 92 | 93 | pending = addWorkerListener(`${opName}-complete`); 94 | worker.postMessage({ [opName]: args }); 95 | return pending; 96 | } 97 | 98 | function addWorkerListener(name) { 99 | var def = getDeferred(); 100 | workerListeners.push([ name, def.resolve, ]); 101 | return def.promise; 102 | } 103 | 104 | function onWorkerMessage({ data, } = {}) { 105 | var nextListener = workerListeners[0]; 106 | if (nextListener[0] in data) { 107 | nextListener[1](data[nextListener[0]]); 108 | workerListeners.shift(); 109 | pending = null; 110 | } 111 | else { 112 | console.error(`Unrecognized/unexpected message from worker: ${JSON.stringify(data)}`); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/adapter.opfs.js: -------------------------------------------------------------------------------- 1 | import { 2 | safeJSONParse, 3 | isPromise, 4 | getRootFS, 5 | } from "./util.js"; 6 | import { 7 | setMany, 8 | getMany, 9 | removeMany, 10 | } from "./many.js"; 11 | 12 | 13 | // *********************** 14 | 15 | get.many = (...args) => getMany(get,...args); 16 | set.many = (...args) => setMany(set,...args); 17 | remove.many = (...args) => removeMany(remove,...args); 18 | 19 | 20 | // *********************** 21 | 22 | var storageType = "opfs"; 23 | export { 24 | storageType, 25 | has, 26 | get, 27 | set, 28 | remove, 29 | keys, 30 | entries, 31 | } 32 | var publicAPI = { 33 | storageType, 34 | has, 35 | get, 36 | set, 37 | remove, 38 | keys, 39 | entries, 40 | }; 41 | export default publicAPI; 42 | 43 | 44 | // *********************** 45 | 46 | async function has(name) { 47 | // note: trick to skip `await` microtask when 48 | // not a promise 49 | var rootFS = getRootFS(); 50 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 51 | 52 | var keys = []; 53 | for await (let key of rootFS.keys()) { 54 | if (key == name) { 55 | return true; 56 | } 57 | } 58 | return false; 59 | } 60 | 61 | async function get(name) { 62 | // note: trick to skip `await` microtask when 63 | // not a promise 64 | var rootFS = getRootFS(); 65 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 66 | 67 | var fh = await rootFS.getFileHandle(name,{ create: true, }); 68 | var file = await fh.getFile(); 69 | var value = (await file.text()) || null; 70 | return safeJSONParse(value); 71 | } 72 | 73 | async function set(name,value) { 74 | // note: trick to skip `await` microtask when 75 | // not a promise 76 | var rootFS = getRootFS(); 77 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 78 | 79 | var fh = await rootFS.getFileHandle(name,{ create: true, }); 80 | var file = await fh.createWritable(); 81 | await file.write( 82 | value != null && typeof value == "object" ? 83 | JSON.stringify(value) : 84 | String(value) 85 | ); 86 | await file.close(); 87 | return true; 88 | } 89 | 90 | async function remove(name) { 91 | // note: trick to skip `await` microtask when 92 | // not a promise 93 | var rootFS = getRootFS(); 94 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 95 | 96 | await rootFS.removeEntry(name); 97 | return true; 98 | } 99 | 100 | async function keys() { 101 | // note: trick to skip `await` microtask when 102 | // not a promise 103 | var rootFS = getRootFS(); 104 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 105 | 106 | var fsKeys = []; 107 | for await (let key of rootFS.keys()) { 108 | fsKeys.push(key); 109 | } 110 | return fsKeys; 111 | } 112 | 113 | async function entries() { 114 | // note: trick to skip `await` microtask when 115 | // not a promise 116 | var rootFS = getRootFS(); 117 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 118 | 119 | var fsEntries = []; 120 | for await (let [ name, fh ] of rootFS.entries()) { 121 | let file = await fh.getFile(); 122 | let value = (await file.text()) || null; 123 | if (value != null && value != "") { 124 | try { 125 | fsEntries.push([ name, safeJSONParse(value), ]); 126 | continue; 127 | } catch (err) {} 128 | } 129 | fsEntries.push([ name, value, ]); 130 | } 131 | return fsEntries; 132 | } 133 | -------------------------------------------------------------------------------- /src/adapter.session-storage.js: -------------------------------------------------------------------------------- 1 | import { safeJSONParse, } from "./util.js"; 2 | import { 3 | setMany, 4 | getMany, 5 | removeMany, 6 | } from "./many.js"; 7 | 8 | 9 | // *********************** 10 | 11 | get.many = (...args) => getMany(get,...args); 12 | set.many = (...args) => setMany(set,...args); 13 | remove.many = (...args) => removeMany(remove,...args); 14 | 15 | 16 | // *********************** 17 | 18 | var storageType = "session-storage"; 19 | export { 20 | storageType, 21 | has, 22 | get, 23 | set, 24 | remove, 25 | keys, 26 | entries, 27 | }; 28 | var publicAPI = { 29 | storageType, 30 | has, 31 | get, 32 | set, 33 | remove, 34 | keys, 35 | entries, 36 | }; 37 | export default publicAPI; 38 | 39 | 40 | // *********************** 41 | 42 | async function has(name) { 43 | // note: https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem#return_value 44 | return (window.sessionStorage.getItem(name) !== null); 45 | } 46 | 47 | async function get(name) { 48 | return safeJSONParse(window.sessionStorage.getItem(name)); 49 | } 50 | 51 | async function set(name,value) { 52 | try { 53 | window.sessionStorage.setItem( 54 | name, 55 | value != null && typeof value == "object" ? 56 | JSON.stringify(value) : 57 | String(value) 58 | ); 59 | return true; 60 | } 61 | catch (err) { 62 | if (err.name == "QuotaExceededError") { 63 | throw new Error("Local-storage is full.",{ cause: err, }); 64 | } 65 | throw err; 66 | } 67 | } 68 | 69 | async function remove(name) { 70 | window.sessionStorage.removeItem(name); 71 | return true; 72 | } 73 | 74 | async function keys() { 75 | var storeKeys = []; 76 | for (let i = 0; i < window.sessionStorage.length; i++) { 77 | storeKeys.push(window.sessionStorage.key(i)); 78 | } 79 | return storeKeys; 80 | } 81 | 82 | async function entries() { 83 | var storeEntries = []; 84 | for (let i = 0; i < window.sessionStorage.length; i++) { 85 | let name = window.sessionStorage.key(i); 86 | storeEntries.push([ 87 | name, 88 | safeJSONParse(window.sessionStorage.getItem(name)), 89 | ]); 90 | } 91 | return storeEntries; 92 | } 93 | -------------------------------------------------------------------------------- /src/copyright-header.txt: -------------------------------------------------------------------------------- 1 | /*! byojs/Storage: #FILENAME# 2 | v#VERSION# (c) #YEAR# Kyle Simpson 3 | MIT License: http://getify.mit-license.org 4 | */ 5 | -------------------------------------------------------------------------------- /src/many.js: -------------------------------------------------------------------------------- 1 | export { setMany, getMany, removeMany }; 2 | 3 | 4 | // *********************** 5 | 6 | function setMany(set,entries) { 7 | // not already an entries-array? 8 | if ( 9 | entries != null && 10 | typeof entries == "object" && 11 | !Array.isArray(entries) 12 | ) { 13 | entries = [ ...Object.entries(entries), ]; 14 | } 15 | return ( 16 | Promise.all( 17 | entries.map(([ key, val ]) => set(key,val)) 18 | ) 19 | .then(() => true) 20 | ); 21 | } 22 | 23 | function getMany(get,keys) { 24 | // not already a keys-array? 25 | if ( 26 | keys != null && 27 | typeof keys == "object" && 28 | !Array.isArray(keys) 29 | ) { 30 | keys = [ ...Object.keys(keys), ]; 31 | } 32 | return Promise.all( 33 | keys.map(key => get(key)) 34 | ); 35 | } 36 | 37 | function removeMany(remove,keys) { 38 | // not already a keys-array? 39 | if ( 40 | keys != null && 41 | typeof keys == "object" && 42 | !Array.isArray(keys) 43 | ) { 44 | keys = [ ...Object.keys(keys), ]; 45 | } 46 | return ( 47 | Promise.all( 48 | keys.map(key => remove(key)) 49 | ) 50 | .then(() => true) 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | var rootFS; 2 | 3 | 4 | // *********************** 5 | 6 | export { 7 | safeJSONParse, 8 | isPromise, 9 | getDeferred, 10 | getRootFS, 11 | }; 12 | var publicAPI = { 13 | safeJSONParse, 14 | isPromise, 15 | getDeferred, 16 | getRootFS, 17 | }; 18 | export default publicAPI; 19 | 20 | 21 | // *********************** 22 | 23 | function safeJSONParse(value) { 24 | if (value != null && value != "") { 25 | try { return JSON.parse(value); } catch (err) {} 26 | } 27 | return (value ?? null); 28 | } 29 | 30 | function isPromise(val) { 31 | return (val != null && typeof val == "object" && typeof val.then == "function"); 32 | } 33 | 34 | function getDeferred() { 35 | if (typeof Promise.withResolvers == "function") { 36 | return Promise.withResolvers(); 37 | } 38 | else { 39 | let resolve, reject, promise = new Promise((res,rej) => { 40 | resolve = res; 41 | reject = rej; 42 | }); 43 | return { promise, resolve, reject, }; 44 | } 45 | } 46 | 47 | // used by opfs adapter, and worker.opfs module 48 | function getRootFS() { 49 | return ( 50 | rootFS ?? ( 51 | navigator.storage.getDirectory() 52 | .then(root => (rootFS = root)) 53 | ) 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/worker.opfs.js: -------------------------------------------------------------------------------- 1 | import { 2 | isPromise, 3 | getRootFS, 4 | } from "./util.js"; 5 | import { 6 | setMany, 7 | getMany, 8 | removeMany, 9 | } from "./many.js"; 10 | 11 | 12 | // *********************** 13 | 14 | get.many = (...args) => getMany(get,...args); 15 | set.many = (...args) => setMany(set,...args); 16 | remove.many = (...args) => removeMany(remove,...args); 17 | 18 | self.addEventListener("message",onMessage); 19 | self.postMessage({ "ready": true }); 20 | 21 | 22 | // *********************** 23 | 24 | async function onMessage({ data, } = {}) { 25 | var recognizedMessages = { 26 | has, 27 | get, 28 | "get.many": get.many, 29 | set, 30 | "set.many": set.many, 31 | remove, 32 | "remove.many": remove.many, 33 | keys, 34 | entries, 35 | }; 36 | for (let [ type, handler ] of Object.entries(recognizedMessages)) { 37 | if (type in data) { 38 | self.postMessage({ [`${type}-complete`]: (await handler(...data[type])), }); 39 | return; 40 | } 41 | } 42 | console.error(`Unrecognized/unexpected message from host: ${JSON.stringify(data)}`); 43 | } 44 | 45 | async function has(name) { 46 | // note: trick to skip `await` microtask when 47 | // not a promise 48 | var rootFS = getRootFS(); 49 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 50 | 51 | var keys = []; 52 | for await (let key of rootFS.keys()) { 53 | if (key == name) { 54 | return true; 55 | } 56 | } 57 | return false; 58 | } 59 | 60 | async function get(name) { 61 | // note: trick to skip `await` microtask when 62 | // not a promise 63 | var rootFS = getRootFS(); 64 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 65 | 66 | var fh = await rootFS.getFileHandle(name,{ create: true, }); 67 | var ah = await fh.createSyncAccessHandle(); 68 | var buffer = new ArrayBuffer(ah.getSize()); 69 | ah.read(buffer); 70 | ah.close(); 71 | return (new TextDecoder()).decode(buffer) || null; 72 | } 73 | 74 | async function set(name,value) { 75 | // note: trick to skip `await` microtask when 76 | // not a promise 77 | var rootFS = getRootFS(); 78 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 79 | 80 | var fh = await rootFS.getFileHandle(name,{ create: true, }); 81 | var ah = await fh.createSyncAccessHandle(); 82 | var data = (new TextEncoder()).encode( 83 | value != null && typeof value == "object" ? 84 | JSON.stringify(value) : 85 | String(value) 86 | ); 87 | ah.truncate(0); 88 | ah.write(data); 89 | ah.flush(); 90 | ah.close(); 91 | return true; 92 | } 93 | 94 | async function remove(name) { 95 | // note: trick to skip `await` microtask when 96 | // not a promise 97 | var rootFS = getRootFS(); 98 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 99 | 100 | await rootFS.removeEntry(name); 101 | return true; 102 | } 103 | 104 | async function keys() { 105 | // note: trick to skip `await` microtask when 106 | // not a promise 107 | var rootFS = getRootFS(); 108 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 109 | 110 | var fsKeys = []; 111 | for await (let key of rootFS.keys()) { 112 | fsKeys.push(key); 113 | } 114 | return fsKeys; 115 | } 116 | 117 | async function entries() { 118 | // note: trick to skip `await` microtask when 119 | // not a promise 120 | var rootFS = getRootFS(); 121 | rootFS = isPromise(rootFS) ? await rootFS : rootFS; 122 | 123 | var fsEntries = []; 124 | for await (let [ name, fh ] of rootFS.entries()) { 125 | let file = await fh.getFile(); 126 | let value = (await file.text()) || null; 127 | if (value != null && value != "") { 128 | try { 129 | fsEntries.push([ name, value, ]); 130 | continue; 131 | } catch (err) {} 132 | } 133 | fsEntries.push([ name, value, ]); 134 | } 135 | return fsEntries; 136 | } 137 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Storage: Tests 7 | 8 | 9 | 10 |
11 |

Storage: Tests

12 | 13 |

Github

14 | 15 |
16 | 17 |

18 | Note: these tests include the Origin Private File System (OPFS) adapter, which is only currently supported on Chrome/Firefox; those specific tests will fail in other browsers. However, the OPFS-Worker adapter (which uses a Web Worker) should work on most modern browsers (desktop and mobile). 19 |

20 | 21 |

22 | 23 |

24 | 25 |
26 | 27 |
28 | 29 | 30 | 31 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | // note: these module specifiers come from the import-map 2 | // in index.html; swap "src" for "dist" here to test 3 | // against the dist/* files 4 | import IDBStore from "storage/src/idb"; 5 | import LSStore from "storage/src/local-storage"; 6 | import SSStore from "storage/src/session-storage"; 7 | import CookieStore from "storage/src/cookie"; 8 | import OPFSStore from "storage/src/opfs"; 9 | import OPFSWorkerStore from "storage/src/opfs-worker"; 10 | 11 | 12 | // *********************** 13 | 14 | const storageTypes = { 15 | "idb": [ "IndexedDB", IDBStore, ], 16 | "local-storage": [ "Local Storage", LSStore, ], 17 | "session-storage": [ "Session Storage", SSStore, ], 18 | "cookie": [ "Cookies", CookieStore, ], 19 | "opfs": [ "Origin Private FS", OPFSStore, ], 20 | "opfs-worker": [ "OPFS-Worker", OPFSWorkerStore, ], 21 | }; 22 | 23 | var runTestsBtn; 24 | var testResultsEl; 25 | 26 | var currentVault; 27 | 28 | if (document.readyState == "loading") { 29 | document.addEventListener("DOMContentLoaded",ready,false); 30 | } 31 | else { 32 | ready(); 33 | } 34 | 35 | 36 | // *********************** 37 | 38 | async function ready() { 39 | runTestsBtn = document.getElementById("run-tests-btn"); 40 | testResultsEl = document.getElementById("test-results"); 41 | 42 | runTestsBtn.addEventListener("click",runTests,false); 43 | } 44 | 45 | function logError(err,returnLog = false) { 46 | var err = `${ 47 | err.stack ? err.stack : err.toString() 48 | }${ 49 | err.cause ? `\n${logError(err.cause,/*returnLog=*/true)}` : "" 50 | }`; 51 | if (returnLog) return err; 52 | else console.error(err); 53 | } 54 | 55 | async function runTests() { 56 | var expectedResults = [ 57 | [ "idb", "has(1)", false ], 58 | [ "idb", "get(1)", null ], 59 | [ "idb", "set(1)", true ], 60 | [ "idb", "has(2)", true ], 61 | [ "idb", "get(2)", "world" ], 62 | [ "idb", "set(2)", true ], 63 | [ "idb", "keys(1)", [ "hello", "meaning", ], ], 64 | [ "idb", "entries", [ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ], ], 65 | [ "idb", "remove", true ], 66 | [ "idb", "keys(2)", [ "meaning", ], ], 67 | [ "idb", "{cleared} (1)", [ false, false, ], ], 68 | [ "idb", "set.many (1)", true], 69 | [ "idb", "get.many (1)", [ "world", { ofLife: 42, }, ], ], 70 | [ "idb", "remove.many (1)", true], 71 | [ "idb", "set.many (2)", true], 72 | [ "idb", "get.many (2)", [ "world", { ofLife: 42, }, ], ], 73 | [ "idb", "remove.many (2)", true], 74 | [ "idb", "{cleared} (2)", [ false, false, ], ], 75 | [ "local-storage", "has(1)", false ], 76 | [ "local-storage", "get(1)", null ], 77 | [ "local-storage", "set(1)", true ], 78 | [ "local-storage", "has(2)", true ], 79 | [ "local-storage", "get(2)", "world" ], 80 | [ "local-storage", "set(2)", true ], 81 | [ "local-storage", "keys(1)", [ "hello", "meaning", ], ], 82 | [ "local-storage", "entries", [ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ], ], 83 | [ "local-storage", "remove", true ], 84 | [ "local-storage", "keys(2)", [ "meaning", ], ], 85 | [ "local-storage", "{cleared} (1)", [ false, false, ], ], 86 | [ "local-storage", "set.many (1)", true], 87 | [ "local-storage", "get.many (1)", [ "world", { ofLife: 42, }, ], ], 88 | [ "local-storage", "remove.many (1)", true], 89 | [ "local-storage", "set.many (2)", true], 90 | [ "local-storage", "get.many (2)", [ "world", { ofLife: 42, }, ], ], 91 | [ "local-storage", "remove.many (2)", true], 92 | [ "local-storage", "{cleared} (2)", [ false, false, ], ], 93 | [ "session-storage", "has(1)", false ], 94 | [ "session-storage", "get(1)", null ], 95 | [ "session-storage", "set(1)", true ], 96 | [ "session-storage", "has(2)", true ], 97 | [ "session-storage", "get(2)", "world" ], 98 | [ "session-storage", "set(2)", true ], 99 | [ "session-storage", "keys(1)", [ "hello", "meaning", ], ], 100 | [ "session-storage", "entries", [ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ], ], 101 | [ "session-storage", "remove", true ], 102 | [ "session-storage", "keys(2)", [ "meaning", ], ], 103 | [ "session-storage", "{cleared} (1)", [ false, false, ], ], 104 | [ "session-storage", "set.many (1)", true], 105 | [ "session-storage", "get.many (1)", [ "world", { ofLife: 42, }, ], ], 106 | [ "session-storage", "remove.many (1)", true], 107 | [ "session-storage", "set.many (2)", true], 108 | [ "session-storage", "get.many (2)", [ "world", { ofLife: 42, }, ], ], 109 | [ "session-storage", "remove.many (2)", true], 110 | [ "session-storage", "{cleared} (2)", [ false, false, ], ], 111 | [ "cookie", "has(1)", false ], 112 | [ "cookie", "get(1)", null ], 113 | [ "cookie", "set(1)", true ], 114 | [ "cookie", "has(2)", true ], 115 | [ "cookie", "get(2)", "world" ], 116 | [ "cookie", "set(2)", true ], 117 | [ "cookie", "keys(1)", [ "hello", "meaning", ], ], 118 | [ "cookie", "entries", [ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ], ], 119 | [ "cookie", "remove", true ], 120 | [ "cookie", "keys(2)", [ "meaning", ], ], 121 | [ "cookie", "{cleared} (1)", [ false, false, ], ], 122 | [ "cookie", "set.many (1)", true], 123 | [ "cookie", "get.many (1)", [ "world", { ofLife: 42, }, ], ], 124 | [ "cookie", "remove.many (1)", true], 125 | [ "cookie", "set.many (2)", true], 126 | [ "cookie", "get.many (2)", [ "world", { ofLife: 42, }, ], ], 127 | [ "cookie", "remove.many (2)", true], 128 | [ "cookie", "{cleared} (2)", [ false, false, ], ], 129 | [ "opfs", "has(1)", false ], 130 | [ "opfs", "get(1)", null ], 131 | [ "opfs", "set(1)", true ], 132 | [ "opfs", "has(2)", true ], 133 | [ "opfs", "get(2)", "world" ], 134 | [ "opfs", "set(2)", true ], 135 | [ "opfs", "keys(1)", [ "hello", "meaning", ], ], 136 | [ "opfs", "entries", [ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ], ], 137 | [ "opfs", "remove", true ], 138 | [ "opfs", "keys(2)", [ "meaning", ], ], 139 | [ "opfs", "{cleared} (1)", [ false, false, ], ], 140 | [ "opfs", "set.many (1)", true], 141 | [ "opfs", "get.many (1)", [ "world", { ofLife: 42, }, ], ], 142 | [ "opfs", "remove.many (1)", true], 143 | [ "opfs", "set.many (2)", true], 144 | [ "opfs", "get.many (2)", [ "world", { ofLife: 42, }, ], ], 145 | [ "opfs", "remove.many (2)", true], 146 | [ "opfs", "{cleared} (2)", [ false, false, ], ], 147 | [ "opfs-worker", "has(1)", false ], 148 | [ "opfs-worker", "get(1)", null ], 149 | [ "opfs-worker", "set(1)", true ], 150 | [ "opfs-worker", "has(2)", true ], 151 | [ "opfs-worker", "get(2)", "world" ], 152 | [ "opfs-worker", "set(2)", true ], 153 | [ "opfs-worker", "keys(1)", [ "hello", "meaning", ], ], 154 | [ "opfs-worker", "entries", [ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ], ], 155 | [ "opfs-worker", "remove", true ], 156 | [ "opfs-worker", "keys(2)", [ "meaning", ], ], 157 | [ "opfs-worker", "{cleared} (1)", [ false, false, ], ], 158 | [ "opfs-worker", "set.many (1)", true], 159 | [ "opfs-worker", "get.many (1)", [ "world", { ofLife: 42, }, ], ], 160 | [ "opfs-worker", "remove.many (1)", true], 161 | [ "opfs-worker", "set.many (2)", true], 162 | [ "opfs-worker", "get.many (2)", [ "world", { ofLife: 42, }, ], ], 163 | [ "opfs-worker", "remove.many (2)", true], 164 | [ "opfs-worker", "{cleared} (2)", [ false, false, ], ], 165 | ]; 166 | var testResults = []; 167 | 168 | testResultsEl.innerHTML = "Storage tests running...
"; 169 | 170 | var stores = [ IDBStore, LSStore, SSStore, CookieStore, OPFSStore, OPFSWorkerStore, ]; 171 | for (let store of stores) { 172 | testResults.push([ storageTypes[store.storageType][0], "has(1)", await store.has("hello"), ]); 173 | testResults.push([ storageTypes[store.storageType][0], "get(1)", await store.get("hello"), ]); 174 | testResults.push([ storageTypes[store.storageType][0], "set(1)", await store.set("hello","world"), ]); 175 | testResults.push([ storageTypes[store.storageType][0], "has(2)", await store.has("hello"), ]); 176 | testResults.push([ storageTypes[store.storageType][0], "get(2)", await store.get("hello"), ]); 177 | testResults.push([ storageTypes[store.storageType][0], "set(2)", await store.set("meaning",{ ofLife: 42, }), ]); 178 | testResults.push([ storageTypes[store.storageType][0], "keys(1)", sortKeys(filterKnownNames("hello","meaning")(await store.keys())), ]); 179 | testResults.push([ storageTypes[store.storageType][0], "entries", sortKeys(filterKnownNames("hello","meaning")(await store.entries())), ]); 180 | testResults.push([ storageTypes[store.storageType][0], "remove", await store.remove("hello"), ]); 181 | testResults.push([ storageTypes[store.storageType][0], "keys(2)", sortKeys(filterKnownNames("hello","meaning")(await store.keys())), ]); 182 | await store.remove("meaning"); 183 | 184 | testResults.push([ storageTypes[store.storageType][0], "{cleared} (1)", await Promise.all(["hello","world"].map(store.has)), ]); 185 | 186 | testResults.push([ storageTypes[store.storageType][0], "set.many (1)", await store.set.many([ [ "hello", "world", ], [ "meaning", { ofLife: 42, }, ], ]), ]); 187 | testResults.push([ storageTypes[store.storageType][0], "get.many (1)", await store.get.many([ "hello", "meaning", ]), ]); 188 | testResults.push([ storageTypes[store.storageType][0], "remove.many (1)", await store.remove.many([ "hello", "meaning", ]), ]); 189 | testResults.push([ storageTypes[store.storageType][0], "set.many (2)", await store.set.many({ hello: "world", meaning: { ofLife: 42, }, }), ]); 190 | testResults.push([ storageTypes[store.storageType][0], "get.many (2)", await store.get.many({ hello: null, meaning: null, }), ]); 191 | testResults.push([ storageTypes[store.storageType][0], "remove.many (2)", await store.remove.many({ hello: null, meaning: null, }), ]); 192 | 193 | testResults.push([ storageTypes[store.storageType][0], "{cleared} (2)", await Promise.all(["hello","world"].map(store.has)), ]); 194 | } 195 | var testsPassed = true; 196 | for (let [ testIdx, testResult ] of testResults.entries()) { 197 | if (JSON.stringify(testResult[2]) == JSON.stringify(expectedResults[testIdx][2])) { 198 | testResultsEl.innerHTML += `(${testIdx}) ${testResult[0]}:${testResult[1]} passed
`; 199 | } 200 | else { 201 | testsPassed = false; 202 | testResultsEl.innerHTML += `(${testIdx}) ${testResult[0]}:${testResult[1]} failed
`; 203 | testResultsEl.innerHTML += `   Expected: ${expectedResults[testIdx][2]}, but found: ${testResult[2]}
`; 204 | } 205 | } 206 | if (testsPassed) { 207 | testResultsEl.innerHTML += "...ALL PASSED.
"; 208 | } 209 | else { 210 | testResultsEl.innerHTML += "...Some tests failed.
"; 211 | } 212 | } 213 | 214 | function filterKnownNames(...knownNames) { 215 | return function filterer(vals) { 216 | if (vals.length > 0) { 217 | // entries? 218 | if (Array.isArray(vals[0])) { 219 | return vals.filter(([ name, value ]) => ( 220 | knownNames.includes(name) 221 | )); 222 | } 223 | else { 224 | return vals.filter(name => ( 225 | knownNames.includes(name) 226 | )); 227 | } 228 | } 229 | return vals; 230 | } 231 | } 232 | 233 | function sortKeys(vals) { 234 | if (vals.length > 0) { 235 | vals = [ ...vals ]; 236 | // entries? 237 | if (Array.isArray(vals[0])) { 238 | return vals.sort(([ name1, ],[ name2, ]) => ( 239 | name1.localeCompare(name2) 240 | )); 241 | } 242 | else { 243 | return vals.sort((name1,name2) => ( 244 | name1.localeCompare(name2) 245 | )); 246 | } 247 | } 248 | return vals; 249 | } 250 | --------------------------------------------------------------------------------