diff --git a/.github/workflows/browsers-ci.yml b/.github/workflows/browsers-ci.yml
new file mode 100644
index 0000000..9736660
--- /dev/null
+++ b/.github/workflows/browsers-ci.yml
@@ -0,0 +1,59 @@
+name: browsers ci
+
+on:
+ push:
+ paths-ignore:
+ - 'docs/**'
+ - '*.md'
+ pull_request:
+ paths-ignore:
+ - 'docs/**'
+ - '*.md'
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: ['ubuntu-latest', 'windows-latest', 'macos-latest']
+ browser: ['chrome', 'firefox', 'safari', 'edge']
+ bundler: ['browserify', 'esbuild', 'rollup', 'vite', 'webpack']
+ exclude:
+ - os: ubuntu-latest
+ browser: safari
+ - os: windows-latest
+ browser: safari
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Use Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - name: Restore cached playwright dependency
+ uses: actions/cache@v3
+ id: playwright-cache
+ with:
+ path: | # playwright installs browsers into .local-browsers
+ ~/.cache/ms-playwright
+ ~/Library/Caches/ms-playwright
+ %LOCALAPPDATA%\ms-playwright
+ key: ${{ matrix.os }}-playwright-${{ hashFiles('package.json') }}
+
+ - name: Restore cached dependencies
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: node-modules-${{ matrix.os }}-${{ hashFiles('package.json') }}
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Install browser
+ run: ./node_modules/.bin/playwright install ${{ fromJSON('{"chrome":"chromium","edge":"msedge","firefox":"firefox","safari":"webkit"}')[matrix.browser] }}
+
+ - name: Run Tests on Browsers
+ run: npm run test:browser ${{ matrix.browser }} ${{ matrix.bundler }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 996fa5c..7fcb1d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,3 +108,6 @@ dist
.vscode
docker-compose.yml
dump.rdb
+
+# Temporary files used for browser testing
+tmp/
\ No newline at end of file
diff --git a/README.md b/README.md
index b1aee4e..5a28572 100644
--- a/README.md
+++ b/README.md
@@ -311,6 +311,40 @@ const p1 = cache.fetchSomething(42) // <--- TypeScript doesn't argue anymore her
---
+## Browser
+
+All the major browser are supported; only `memory` storage type is supported, `redis` storage can't be used in a browser env.
+
+This is a very simple example of how to use this module in a browser environment:
+
+```html
+
+
+
+```
+
+You can also use the module with a bundler. The supported bundlers are `webpack`, `rollup`, `esbuild` and `browserify`.
+
+---
+
## Maintainers
* [__Matteo Collina__](https://github.com/mcollina), ,
diff --git a/bench/storage.js b/bench/storage.js
index d406567..74dfa20 100644
--- a/bench/storage.js
+++ b/bench/storage.js
@@ -3,7 +3,7 @@
const { hrtime } = require('process')
const path = require('path')
const Redis = require('ioredis')
-const createStorage = require(path.resolve(__dirname, '../storage'))
+const createStorage = require(path.resolve(__dirname, '../src/storage/index.js'))
// NOTE: this is a very basic benchmarks for tweaking
// performance is effected by keys and references size
diff --git a/package.json b/package.json
index 9c372b8..4d3df1e 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"types": "index.d.ts",
"scripts": {
"test": "standard | snazzy && tap test/*test.js && tsd",
+ "test:browser": "node test/browser/helpers/runner-browser.mjs",
"lint:fix": "standard --fix",
"redis": "docker run --rm -p 6379:6379 redis redis-server"
},
@@ -30,15 +31,34 @@
"license": "MIT",
"devDependencies": {
"@fastify/pre-commit": "^2.0.2",
+ "@rollup/plugin-commonjs": "^24.0.1",
+ "@rollup/plugin-inject": "^5.0.3",
+ "@rollup/plugin-node-resolve": "^15.0.1",
+ "browserify": "^17.0.0",
+ "buffer": "^6.0.3",
+ "esbuild": "^0.17.4",
+ "esbuild-plugin-alias": "^0.2.1",
+ "events": "^3.3.0",
"ioredis": "^5.2.3",
+ "path-browserify": "^1.0.1",
+ "playwright": "^1.29.2",
+ "process": "^0.11.10",
"proxyquire": "^2.1.3",
+ "rollup": "^3.11.0",
+ "rollup-plugin-polyfill-node": "^0.12.0",
"snazzy": "^9.0.0",
"standard": "^17.0.0",
+ "stream-browserify": "^3.0.0",
"tap": "^16.3.0",
- "tsd": "^0.25.0"
+ "tap-mocha-reporter": "^5.0.3",
+ "tap-parser": "^12.0.1",
+ "tape": "^5.6.3",
+ "tsd": "^0.25.0",
+ "vite": "^4.0.4",
+ "webpack": "^5.75.0",
+ "webpack-cli": "^5.0.1"
},
"dependencies": {
- "abstract-logging": "^2.0.1",
"mnemonist": "^0.39.2",
"safe-stable-stringify": "^2.3.1"
},
diff --git a/src/storage/index.js b/src/storage/index.js
index 410adf6..c1f0b0b 100644
--- a/src/storage/index.js
+++ b/src/storage/index.js
@@ -1,6 +1,11 @@
'use strict'
-const StorageRedis = require('./redis')
+const { isServerSide } = require('../util')
+
+let StorageRedis
+if (isServerSide) {
+ StorageRedis = require('./redis')
+}
const StorageMemory = require('./memory')
/**
@@ -27,6 +32,10 @@ const StorageOptionsType = {
* @returns {StorageMemory|StorageRedis}
*/
function createStorage (type, options) {
+ if (!isServerSide && type === StorageOptionsType.redis) {
+ throw new Error('Redis storage is not supported in the browser')
+ }
+
if (type === StorageOptionsType.redis) {
return new StorageRedis(options)
}
diff --git a/src/storage/memory.js b/src/storage/memory.js
index 7c98ec2..e4be749 100644
--- a/src/storage/memory.js
+++ b/src/storage/memory.js
@@ -1,7 +1,7 @@
'use strict'
const LRUCache = require('mnemonist/lru-cache')
-const nullLogger = require('abstract-logging')
+const { abstractLogging } = require('../util')
const StorageInterface = require('./interface')
const { findMatchingIndexes, findNotMatching, bsearchIndex, wildcardMatch } = require('../util')
@@ -26,7 +26,7 @@ class StorageMemory extends StorageInterface {
super(options)
this.size = options.size || DEFAULT_CACHE_SIZE
- this.log = options.log || nullLogger
+ this.log = options.log || abstractLogging()
this.invalidation = options.invalidation || false
this.init()
@@ -401,7 +401,9 @@ function now () {
return _timer
}
_timer = Math.floor(Date.now() / 1000)
- setTimeout(_clearTimer, 1000).unref()
+ const timeout = setTimeout(_clearTimer, 1000)
+ // istanbul ignore next
+ if (typeof timeout.unref === 'function') timeout.unref()
return _timer
}
diff --git a/src/storage/redis.js b/src/storage/redis.js
index 86f9a3e..ec90e14 100644
--- a/src/storage/redis.js
+++ b/src/storage/redis.js
@@ -1,9 +1,8 @@
'use strict'
const stringify = require('safe-stable-stringify')
-const nullLogger = require('abstract-logging')
const StorageInterface = require('./interface')
-const { findNotMatching, randomSubset } = require('../util')
+const { findNotMatching, randomSubset, abstractLogging } = require('../util')
const GC_DEFAULT_CHUNK = 64
const GC_DEFAULT_LAZY_CHUNK = 64
@@ -33,7 +32,7 @@ class StorageRedis extends StorageInterface {
throw new Error('invalidation.referencesTTL must be a positive integer greater than 1')
}
- this.log = options.log || nullLogger
+ this.log = options.log || abstractLogging()
this.store = options.client
this.invalidation = !!options.invalidation
this.referencesTTL = (options.invalidation && options.invalidation.referencesTTL) || REFERENCES_DEFAULT_TTL
diff --git a/src/util.js b/src/util.js
index a3622e6..33856b0 100644
--- a/src/util.js
+++ b/src/util.js
@@ -127,11 +127,28 @@ function wildcardMatch (value, content) {
return i >= value.length - 1
}
+// `abstract-logging` dependency has been removed because there is a bug on Rollup
+// https://github.com/jsumners/abstract-logging/issues/6
+function abstractLogging () {
+ const noop = () => {}
+ return {
+ fatal: noop,
+ error: noop,
+ warn: noop,
+ info: noop,
+ debug: noop,
+ trace: noop
+ }
+}
+
+const isServerSide = typeof window === 'undefined'
+
module.exports = {
findNotMatching,
findMatchingIndexes,
bsearchIndex,
wildcardMatch,
-
- randomSubset
+ randomSubset,
+ abstractLogging,
+ isServerSide
}
diff --git a/test/browser/fixtures/esbuild.browser-shims.mjs b/test/browser/fixtures/esbuild.browser-shims.mjs
new file mode 100644
index 0000000..f8e5173
--- /dev/null
+++ b/test/browser/fixtures/esbuild.browser-shims.mjs
@@ -0,0 +1,7 @@
+import * as processModule from 'process'
+
+export const process = processModule
+
+export function setImmediate (fn, ...args) {
+ setTimeout(() => fn(...args), 0)
+}
diff --git a/test/browser/fixtures/esbuild.browser.config.mjs b/test/browser/fixtures/esbuild.browser.config.mjs
new file mode 100644
index 0000000..120878c
--- /dev/null
+++ b/test/browser/fixtures/esbuild.browser.config.mjs
@@ -0,0 +1,26 @@
+import { build } from 'esbuild'
+import alias from 'esbuild-plugin-alias'
+import { createRequire } from 'module'
+
+const require = createRequire(import.meta.url)
+
+build({
+ entryPoints: ['test/browser/test-browser.js'],
+ outfile: 'tmp/esbuild/suite.browser.js',
+ bundle: true,
+ platform: 'browser',
+ plugins: [
+ alias({
+ path: require.resolve('path-browserify'),
+ stream: require.resolve('stream-browserify')
+ })
+ ],
+ define: {
+ global: 'globalThis'
+ },
+ inject: ['test/browser/fixtures/esbuild.browser-shims.mjs'],
+ external: ['./src/storage/redis.js']
+}).catch((err) => {
+ console.log(err)
+ process.exit(1)
+})
diff --git a/test/browser/fixtures/index.html b/test/browser/fixtures/index.html
new file mode 100644
index 0000000..349557b
--- /dev/null
+++ b/test/browser/fixtures/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/browser/fixtures/rollup.browser.config.mjs b/test/browser/fixtures/rollup.browser.config.mjs
new file mode 100644
index 0000000..ebf8bd9
--- /dev/null
+++ b/test/browser/fixtures/rollup.browser.config.mjs
@@ -0,0 +1,27 @@
+import commonjs from '@rollup/plugin-commonjs'
+import inject from '@rollup/plugin-inject'
+import nodeResolve from '@rollup/plugin-node-resolve'
+import { resolve } from 'path'
+import nodePolyfill from 'rollup-plugin-polyfill-node'
+
+export default {
+ input: ['test/browser/test-browser.js'],
+ external: ['./src/storage/redis.js'],
+ output: {
+ intro: 'function setImmediate(fn, ...args) { setTimeout(() => fn(...args), 0) }',
+ file: 'tmp/rollup/suite.browser.js',
+ format: 'iife',
+ name: 'asyncDedupeStorageTestSuite'
+ },
+ plugins: [
+ commonjs(),
+ nodePolyfill(),
+ inject({
+ process: resolve('node_modules/process/browser.js')
+ }),
+ nodeResolve({
+ browser: true,
+ preferBuiltins: false
+ })
+ ]
+}
diff --git a/test/browser/fixtures/vite.browser.config.mjs b/test/browser/fixtures/vite.browser.config.mjs
new file mode 100644
index 0000000..37c86ea
--- /dev/null
+++ b/test/browser/fixtures/vite.browser.config.mjs
@@ -0,0 +1,44 @@
+import { defineConfig } from 'vite'
+import inject from '@rollup/plugin-inject'
+import { resolve } from 'path'
+import commonjs from '@rollup/plugin-commonjs'
+import { createRequire } from 'module'
+
+const require = createRequire(import.meta.url)
+
+export default defineConfig({
+ build: {
+ outDir: 'tmp/vite',
+ lib: {
+ entry: 'test/browser/test-browser.js',
+ name: 'suite',
+ fileName: () => 'suite.browser.js',
+ formats: ['iife']
+ },
+ rollupOptions: {
+ output: {
+ intro: 'function setImmediate(fn, ...args) { setTimeout(() => fn(...args), 0) }'
+ },
+ external: ['./src/storage/redis.js']
+ },
+ emptyOutDir: false,
+ commonjsOptions: {
+ include: [/src/],
+ transformMixedEsModules: true
+ }
+ },
+ resolve: {
+ alias: {
+ path: require.resolve('path-browserify'),
+ stream: require.resolve('stream-browserify')
+ }
+ },
+ plugins: [
+ commonjs({
+ transformMixedEsModules: true
+ }),
+ inject({
+ process: resolve('node_modules/process/browser.js')
+ })
+ ]
+})
diff --git a/test/browser/fixtures/webpack.browser.config.mjs b/test/browser/fixtures/webpack.browser.config.mjs
new file mode 100644
index 0000000..440582b
--- /dev/null
+++ b/test/browser/fixtures/webpack.browser.config.mjs
@@ -0,0 +1,35 @@
+import { createRequire } from 'module'
+import { resolve } from 'path'
+import { fileURLToPath } from 'url'
+import webpack from 'webpack'
+
+const require = createRequire(import.meta.url)
+const rootDir = resolve(fileURLToPath(new URL('.', import.meta.url)), '../../../')
+
+export default {
+ entry: './test/browser/test-browser.js',
+ output: {
+ filename: 'suite.browser.js',
+ path: resolve(rootDir, 'tmp/webpack')
+ },
+ externals: ['./src/storage/redis.js'],
+ mode: 'production',
+ target: 'web',
+ performance: false,
+ plugins: [
+ new webpack.BannerPlugin({
+ banner: 'function setImmediate(fn, ...args) { setTimeout(() => fn(...args), 0) }',
+ raw: true
+ }),
+ new webpack.ProvidePlugin({
+ process: require.resolve('process')
+ })
+ ],
+ resolve: {
+ aliasFields: ['browser'],
+ fallback: {
+ path: require.resolve('path-browserify'),
+ stream: require.resolve('stream-browserify')
+ }
+ }
+}
diff --git a/test/browser/helpers/runner-browser.mjs b/test/browser/helpers/runner-browser.mjs
new file mode 100644
index 0000000..7c4c12a
--- /dev/null
+++ b/test/browser/helpers/runner-browser.mjs
@@ -0,0 +1,204 @@
+import { supportedBrowsers } from './supported-browsers.js'
+import { supportedBundlers } from './supported-bundlers.js'
+import { chromium, firefox, webkit } from 'playwright'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { Readable } from 'node:stream'
+import { copyFile, mkdir, rm } from 'node:fs/promises'
+import { exec } from 'node:child_process'
+import { info, error, highlightFile, createDeferredPromise } from './util.js'
+import Parser from 'tap-parser'
+import Reporter from 'tap-mocha-reporter'
+
+async function runCommand (command) {
+ info(`Executing \x1b[33m${command}\x1b[0m ...`)
+ const { promise, reject, resolve } = createDeferredPromise()
+
+ let hasOutput = false
+ function logOutput (chunk) {
+ if (!hasOutput) {
+ hasOutput = true
+ info('')
+ }
+
+ info(chunk.toString('utf-8').trim().replace(/^/gm, ' '))
+ }
+
+ try {
+ const process = exec(command, { stdio: 'pipe' }, (error) => {
+ if (error) {
+ return reject(error)
+ }
+
+ resolve(error)
+ })
+
+ process.stdout.on('data', logOutput)
+ process.stderr.on('data', logOutput)
+
+ await promise
+
+ if (hasOutput) {
+ info('')
+ }
+ } catch (err) {
+ if (hasOutput) {
+ info('')
+ }
+
+ error(`Command \x1b[33m${command}\x1b[0m failed with exit code ${err.code}.`)
+ process.exit(1)
+ }
+}
+
+function createConfiguration () {
+ let [browser, bundler] = process.argv.slice(2, 4)
+ if (!browser) browser = process.env.BROWSER
+ if (!bundler) bundler = process.env.BUNDLER
+
+ if (!supportedBrowsers.includes(browser) || !supportedBundlers.includes(bundler)) {
+ error(`Usage: npm run test:browser [${supportedBrowsers.join('|')}] [${supportedBundlers.join('|')}]`)
+ error('You can also use the BROWSER and BUNDLER environment variables.')
+ process.exit(1)
+ }
+
+ const headless = process.env.HEADLESS !== 'false'
+ const reporter = process.env.REPORTER !== 'true'
+
+ return { browser, bundler, headless, reporter }
+}
+
+async function setupTest ({ bundler }) {
+ const rootDir = resolve(fileURLToPath(new URL('.', import.meta.url)), `../../../tmp/${bundler}`)
+ const sourceIndex = resolve(fileURLToPath(new URL('.', import.meta.url)), '../../../test/browser/fixtures/index.html')
+ const targetIndex = resolve(rootDir, 'index.html')
+
+ info(`Emptying directory ${highlightFile(rootDir)} ...`)
+ try {
+ await rm(rootDir, { recursive: true })
+ } catch (err) {
+ // noop
+ }
+
+ await mkdir(rootDir, { recursive: true })
+ info(`Copying ${highlightFile(sourceIndex)} to ${highlightFile(targetIndex)} ...`)
+ await copyFile(sourceIndex, targetIndex)
+
+ switch (bundler) {
+ case 'browserify': {
+ await runCommand('browserify test/browser/test-browser.js -o tmp/browserify/suite.browser.js -u ./src/storage/redis.js')
+ break
+ }
+ case 'esbuild': {
+ await runCommand('node test/browser/fixtures/esbuild.browser.config.mjs')
+ break
+ }
+ case 'rollup': {
+ await runCommand('rollup -c test/browser/fixtures/rollup.browser.config.mjs')
+ break
+ }
+ case 'vite': {
+ await runCommand('vite build --config test/browser/fixtures/vite.browser.config.mjs')
+ break
+ }
+ case 'webpack': {
+ await runCommand('webpack -c test/browser/fixtures/webpack.browser.config.mjs')
+ break
+ }
+ }
+}
+
+function createBrowser ({ browser, headless }) {
+ switch (browser) {
+ case 'edge':
+ return chromium.launch({ headless, channel: 'msedge' })
+ case 'firefox':
+ return firefox.launch({ headless })
+ case 'safari':
+ return webkit.launch({ headless })
+ default:
+ return chromium.launch({ headless })
+ }
+}
+
+function setupTape (browser, page, config) {
+ const output = new Readable({ read () {} })
+ const parser = new Parser({ strict: true })
+
+ output.pipe(parser)
+
+ if (config.reporter) {
+ output.pipe(Reporter('spec'))
+ }
+
+ parser.on('line', (line) => {
+ if (line !== '# async-cache-dedupe-finished\n') {
+ if (line.startsWith('# not ok')) {
+ process.exitCode = 1
+ }
+
+ if (!config.reporter) {
+ info(line.replace(/\n$/, ''))
+ }
+
+ return
+ }
+
+ output.push(null)
+
+ if (config.headless) {
+ browser.close()
+ }
+ })
+
+ // Catching console errors
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ error(`\x1b[31m\x1b[1mconsole.error:\x1b[0m ${msg.text()}\n`)
+ return
+ }
+
+ output.push(msg.text() + '\n')
+ })
+
+ // Firefox in headless mode is showing an error even if onerror caught it. Disable in that case
+ if (!config.headless || config.browser !== 'firefox') {
+ page.on('pageerror', (err) => {
+ error('\x1b[31m\x1b[1m--- The browser thrown an uncaught error ---\x1b[0m')
+ error(err)
+
+ if (config.headless) {
+ error('\x1b[31m\x1b[1m--- Exiting with exit code 1 ---\x1b[0m')
+ process.exit(1)
+ } else {
+ process.exitCode = 1
+ }
+ })
+ }
+}
+
+async function main () {
+ const config = createConfiguration()
+
+ // Generate the bundles
+ await setupTest(config).catch(err => {
+ error(err)
+ })
+
+ // Creating the browser and configuring the pagew with Tape
+ const browser = await createBrowser(config)
+ const page = await browser.newPage()
+ setupTape(browser, page, config)
+
+ // Run the test suite
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
+ const url = `file://${resolve(__dirname, `../../../tmp/${config.bundler}/index.html`)}`
+ await page.goto(url).catch((err) => {
+ error(err)
+ })
+}
+
+await main().catch(err => {
+ error(err)
+ process.exit(1)
+})
diff --git a/test/browser/helpers/supported-browsers.js b/test/browser/helpers/supported-browsers.js
new file mode 100644
index 0000000..a1aa9b5
--- /dev/null
+++ b/test/browser/helpers/supported-browsers.js
@@ -0,0 +1,5 @@
+'use strict'
+
+const supportedBrowsers = ['chrome', 'edge', 'firefox', 'safari']
+
+module.exports = { supportedBrowsers }
diff --git a/test/browser/helpers/supported-bundlers.js b/test/browser/helpers/supported-bundlers.js
new file mode 100644
index 0000000..c09fe02
--- /dev/null
+++ b/test/browser/helpers/supported-bundlers.js
@@ -0,0 +1,5 @@
+'use strict'
+
+const supportedBundlers = ['browserify', 'esbuild', 'rollup', 'vite', 'webpack']
+
+module.exports = { supportedBundlers }
diff --git a/test/browser/helpers/symbols.js b/test/browser/helpers/symbols.js
new file mode 100644
index 0000000..96ffc2f
--- /dev/null
+++ b/test/browser/helpers/symbols.js
@@ -0,0 +1,6 @@
+'use strict'
+
+module.exports = {
+ kAsyncCacheDedupeSuiteName: Symbol('async-cache-dedupe.suiteName'),
+ kAsyncCacheDedupeSuiteHasMultipleTests: Symbol('async-cache-dedupe.suiteHasMultipleTests')
+}
diff --git a/test/browser/helpers/util.js b/test/browser/helpers/util.js
new file mode 100644
index 0000000..96732ac
--- /dev/null
+++ b/test/browser/helpers/util.js
@@ -0,0 +1,30 @@
+'use strict'
+
+function createDeferredPromise () {
+ let _resolve
+ let _reject
+
+ const promise = new Promise((resolve, reject) => {
+ _resolve = resolve
+ _reject = reject
+ })
+ return {
+ promise,
+ resolve: _resolve,
+ reject: _reject
+ }
+}
+
+function highlightFile (file) {
+ return `\x1b[33m${file.replace(process.cwd() + '/', '')}\x1b[0m`
+}
+
+function info (message) {
+ console.info(`\x1b[34m[INFO]\x1b[0m ${message}`)
+}
+
+function error (message) {
+ console.info(`\x1b[31m[ERROR]\x1b[0m ${message}`)
+}
+
+module.exports = { createDeferredPromise, highlightFile, info, error }
diff --git a/test/browser/storage-base.browser.test.js b/test/browser/storage-base.browser.test.js
new file mode 100644
index 0000000..407b25d
--- /dev/null
+++ b/test/browser/storage-base.browser.test.js
@@ -0,0 +1,17 @@
+'use strict'
+
+const createStorage = require('../../src/storage')
+const { kAsyncCacheDedupeSuiteName, kAsyncCacheDedupeSuiteHasMultipleTests } = require('./helpers/symbols.js')
+
+module.exports = async function (test) {
+ test('should fail when using redis storage in the browser', async (t) => {
+ t.plan(1)
+
+ t.throws(() => {
+ createStorage('redis', { client: {} })
+ }, { message: 'Redis storage is not supported in the browser' })
+ })
+}
+
+module.exports[kAsyncCacheDedupeSuiteName] = 'storage-base browser suite'
+module.exports[kAsyncCacheDedupeSuiteHasMultipleTests] = true
diff --git a/test/browser/storage-memory.browser.test.js b/test/browser/storage-memory.browser.test.js
new file mode 100644
index 0000000..2265ad1
--- /dev/null
+++ b/test/browser/storage-memory.browser.test.js
@@ -0,0 +1,29 @@
+'use strict'
+
+const createStorage = require('../../src/storage')
+const { kAsyncCacheDedupeSuiteName, kAsyncCacheDedupeSuiteHasMultipleTests } = require('./helpers/symbols.js')
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+
+module.exports = async function (test) {
+ test('should get undefined retrieving an expired value', async (t) => {
+ const storage = createStorage('memory')
+
+ await storage.set('foo', 'bar', 1)
+ await sleep(1500)
+
+ t.equal(await storage.get('foo'), undefined)
+ })
+
+ test('should return the stored value if not expired', async (t) => {
+ const storage = createStorage('memory')
+
+ await storage.set('foo', 'bar', 1)
+ await sleep(500)
+
+ t.equal(await storage.get('foo'), 'bar')
+ })
+}
+
+module.exports[kAsyncCacheDedupeSuiteName] = 'storage-memory browser suite'
+module.exports[kAsyncCacheDedupeSuiteHasMultipleTests] = true
diff --git a/test/browser/test-browser.js b/test/browser/test-browser.js
new file mode 100644
index 0000000..3f709f9
--- /dev/null
+++ b/test/browser/test-browser.js
@@ -0,0 +1,84 @@
+'use strict'
+
+const logger = globalThis.logger || console.log
+const { createDeferredPromise } = require('./helpers/util.js')
+const tape = require('tape')
+const { kAsyncCacheDedupeSuiteName, kAsyncCacheDedupeSuiteHasMultipleTests } = require('./helpers/symbols.js')
+
+let totalTests = 0
+let completed = 0
+let failed = 0
+
+async function test (rootName, fn) {
+ // Gather all tests in the file
+ const tests = {}
+ function addTests (name, fn) {
+ tests[`${rootName} - ${name}`] = fn
+ }
+ if (fn[kAsyncCacheDedupeSuiteHasMultipleTests]) {
+ fn(addTests)
+ } else {
+ tests[rootName] = fn
+ }
+
+ // Execute each test in a separate harness and then output overall results
+ for (const [name, subtest] of Object.entries(tests)) {
+ const currentIndex = ++totalTests
+ const harness = tape.createHarness()
+ const { promise, resolve } = createDeferredPromise()
+ const messages = [`# Subtest: ${name}`]
+
+ harness.createStream().on('data', function (row) {
+ if (row.startsWith('TAP version') || row.match(new RegExp(`^# (?:${name})`))) {
+ return
+ }
+ messages.push(row.trim().replace(/^/gm, ' '))
+ })
+
+ harness.onFinish(() => {
+ const success = harness._exitCode === 0
+ messages.push(`${success ? 'ok' : 'not ok'} ${currentIndex} - ${name}`)
+ logger(messages.join('\n'))
+ completed++
+ if (!success) {
+ failed++
+ }
+ resolve()
+ })
+
+ harness(name, subtest)
+ await promise
+ }
+}
+
+async function runTests (suites) {
+ // Setup an interval
+ const interval = setInterval(() => {
+ if (completed < totalTests) {
+ return
+ }
+ clearInterval(interval)
+
+ logger(`1..${totalTests}`)
+ logger(`# tests ${totalTests}`)
+ logger(`# pass ${completed - failed}`)
+ logger(`# fail ${failed}`)
+ logger(`# ${failed === 0 ? 'ok' : 'not ok'}`)
+
+ // This line is used by the playwright script to detect we're done
+ logger('# async-cache-dedupe-finished')
+ }, 100)
+
+ // Execute each test serially, to avoid side-effects errors when dealing with global error handling
+ for (const suite of suites) {
+ await test(suite[kAsyncCacheDedupeSuiteName], suite)
+ }
+}
+
+runTests([
+ require('./storage-base.browser.test.js'),
+ require('./storage-memory.browser.test.js')
+]).catch((err) => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/test/storage-redis.test.js b/test/storage-redis.test.js
index 8e95409..0a5531c 100644
--- a/test/storage-redis.test.js
+++ b/test/storage-redis.test.js
@@ -1017,4 +1017,14 @@ test('storage redis', async (t) => {
t.equal(await storage.getTTL('foo'), 0)
})
})
+
+ test('should throw if is not server side and storage is redis', async (t) => {
+ const createStorageMock = t.mock('../src/storage/index.js', {
+ '../src/util.js': module.exports = {
+ isServerSide: false
+ }
+ })
+
+ t.throws(() => createStorageMock('redis', { client: redisClient }), 'Redis storage is not supported in the browser')
+ })
})