diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..52bc8af6a2 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,77 @@ +{ + "root": true, + "parserOptions": { + "parser": "@babel/eslint-parser" + }, + "extends": [ + "plugin:cypress/recommended", + "plugin:flowtype/recommended", + "plugin:vue/essential", + "standard" + ], + "ignorePatterns": [ + "contracts/*", + "dist/*", + "frontend/assets/*", + "frontend/model/contracts/misc/flowTyper.js", + "historical/*", + "ignored/*", + "node_modules/*", + "shared/**/*.flow.js", + "shared/declarations.js", + "test/common/*", + "test/contracts/*", + "test/cypress/cache/*" + ], + "overrides": [ + { + "files": "*.ts", + "parserOptions": { + "parser": "@typescript-eslint/parser" + }, + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "plugins": [ + "@typescript-eslint", + "import" + ], + "rules": { + "@typescript-eslint/no-this-alias": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], + "dot-notation": "off", + "import/extensions": [ + 2, + "ignorePackages" + ], + "no-use-before-define": "off", + "quote-props": "off" + } + }, + { + "files": "*.test.ts", + "globals": { + "Deno": true + }, + "rules": { + "require-await": "off" + } + } + ], + "plugins": [ + "cypress", + "flowtype", + "import" + ], + "rules": { + "require-await": "error", + "vue/max-attributes-per-line": "off", + "vue/html-indent": "off", + "flowtype/no-types-missing-file-annotation": "off", + "quote-props": "off", + "dot-notation": "off", + "import/extensions": [ + 2, + "ignorePackages" + ] + } +} diff --git a/.flowconfig b/.flowconfig index 81200dce27..81dde484be 100644 --- a/.flowconfig +++ b/.flowconfig @@ -7,22 +7,26 @@ # - https://flowtype.org/docs/objects.html # - https://flowtype.org/docs/functions.html .*/Gruntfile.js -.*/dist/.* +# Backend files use TypeScript instead of Flow since they run using Deno. +/backend/.* /contracts/.* +# Shared files must be imported by Deno backend files, so they better not contain Flowtype annotations. +/shared/.* +.*/dist/.* .*/frontend/assets/.* .*/frontend/controller/service-worker.js +.*/frontend/model/contracts/misc/flowTyper.js .*/frontend/utils/blockies.js .*/frontend/utils/crypto.js .*/frontend/utils/vuexQueue.js -.*/frontend/model/contracts/misc/flowTyper.js .*/historical/.* .*/ignored/.* .*/node_modules/.* .*/scripts/.* -.*/test/backend.js .*/test/.* -.*/test/frontend.js .*.test.js +# Don't ignore declaration files. +!.*/types.flow.js [libs] ./shared/declarations.js diff --git a/.gitignore b/.gitignore index 6cb276eb9c..c9a23ed448 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build artifacts /node_modules/ /dist/ +/test/contracts/ *?rollup-plugin-vue=script.js # prevent accidental addition of yarn.lock from making Travis builds longer diff --git a/.travis.yml b/.travis.yml index a78b4d07bb..e6a1654fa5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,23 @@ env: branches: only: - master # https://github.com/okTurtles/group-income/issues/58 +before_install: + - set -e + - deno_target="x86_64-unknown-linux-gnu" + - deno_version="v1.26.2" + - deno_uri="https://github.com/denoland/deno/releases/download/${deno_version}/deno-${deno_target}.zip" + - deno_install="${DENO_INSTALL:-$HOME/.deno}" + - deno_bin_dir="$deno_install/bin" + - deno_exe="$deno_bin_dir/deno" + - if [ ! -d "$deno_bin_dir" ]; then mkdir -p "$deno_bin_dir"; fi + - curl --fail --location --output "$deno_exe.zip" "$deno_uri" + - unzip -d "$deno_bin_dir" -o "$deno_exe.zip" + - chmod +x "$deno_exe" + - rm "$deno_exe.zip" + - echo "Deno was installed successfully to $deno_exe" + - export DENO_INSTALL="$HOME/.deno" + - export PATH="$DENO_INSTALL/bin:$PATH" + - deno run https://deno.land/std/examples/welcome.ts cache: # Caches $HOME/.npm when npm ci is default script command # Caches node_modules in all other cases diff --git a/Gruntfile.js b/Gruntfile.js index 588e36ecae..685cbcc543 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,11 +1,13 @@ 'use strict' +if (process.env['CI']) process.exit(1) + // ======================= // Entry point. // // Ensures: // -// - Babel support is available on the backend, in Mocha tests, etc. +// - Babel support is available in Mocha tests, etc. // - Environment variables are set to different values depending // on whether we're in a production environment or otherwise. // @@ -14,7 +16,7 @@ const util = require('util') const chalk = require('chalk') const crypto = require('crypto') -const { exec, fork } = require('child_process') +const { exec, spawn } = require('child_process') const execP = util.promisify(exec) const { copyFile, readFile } = require('fs/promises') const fs = require('fs') @@ -31,23 +33,17 @@ const packageJSON = require('./package.json') // See https://esbuild.github.io/api/#define // ======================= -/** - * Creates a modified copy of the given `process.env` object, according to its `PORT_SHIFT` variable. - * - * The `API_PORT` and `API_URL` variables will be updated. - * TODO: make the protocol (http vs https) variable based on environment var. - * @param {Object} env - * @returns {Object} - */ -const applyPortShift = (env) => { - // TODO: implement automatic port selection when `PORT_SHIFT` is 'auto'. +// Nodejs version of `~/scripts/applyPortShift.ts`. See comments there. +// TODO: dedupe this. +function applyPortShift (env) { + const API_HOSTNAME = env.NODE_ENV === 'production' || require('os').platform() === 'linux' ? 'localhost' : '127.0.0.1' const API_PORT = 8000 + Number.parseInt(env.PORT_SHIFT || '0') - const API_URL = 'http://127.0.0.1:' + API_PORT + const API_URL = `http://${API_HOSTNAME}:${API_PORT}` if (Number.isNaN(API_PORT) || API_PORT < 8000 || API_PORT > 65535) { throw new RangeError(`Invalid API_PORT value: ${API_PORT}.`) } - return { ...env, API_PORT, API_URL } + return { ...env, API_HOSTNAME, API_PORT: String(API_PORT), API_URL } } Object.assign(process.env, applyPortShift(process.env)) @@ -66,17 +62,20 @@ const { EXPOSE_SBP = '' } = process.env -const backendIndex = './backend/index.js' +const backendIndex = './backend/index.ts' +const contractsDir = 'frontend/model/contracts' +const denoRunPermissions = ['--allow-env', '--allow-net', '--allow-read', '--allow-write'] +const denoTestPermissions = ['--allow-env', '--allow-net', '--allow-read', '--allow-write'] const distAssets = 'dist/assets' const distCSS = 'dist/assets/css' const distDir = 'dist' const distContracts = 'dist/contracts' const distJS = 'dist/assets/js' -const serviceWorkerDir = 'frontend/controller/serviceworkers' +const manifestJSON = path.join(contractsDir, 'manifests.json') const srcDir = 'frontend' -const contractsDir = 'frontend/model/contracts' +const serviceWorkerDir = 'frontend/controller/serviceworkers' + const mainSrc = path.join(srcDir, 'main.js') -const manifestJSON = path.join(contractsDir, 'manifests.json') const development = NODE_ENV === 'development' const production = !development @@ -226,6 +225,7 @@ module.exports = (grunt) => { entryPoints: ['./frontend/controller/serviceworkers/primary.js'] } } + esbuildOptionBags.contracts = { ...pick(clone(esbuildOptionBags.default), [ 'define', 'bundle', 'watch', 'incremental' @@ -237,7 +237,12 @@ module.exports = (grunt) => { // }, splitting: false, outdir: distContracts, - entryPoints: [`${contractsDir}/group.js`, `${contractsDir}/chatroom.js`, `${contractsDir}/identity.js`, `${contractsDir}/mailbox.js`], + entryPoints: [ + 'chatroom.js', + 'group.js', + 'identity.js', + 'mailbox.js' + ].map(s => `${contractsDir}/${s}`), external: ['@sbp/sbp'] } // prevent contract hash from changing each time we build them @@ -246,6 +251,20 @@ module.exports = (grunt) => { esbuildOptionBags.contractsSlim.entryNames = '[name]-slim' esbuildOptionBags.contractsSlim.external = ['@common/common.js', '@sbp/sbp'] + esbuildOptionBags.testContractsShared = { + ...esbuildOptionBags.default, + bundle: true, + entryNames: 'shared', + entryPoints: [`${contractsDir}/shared/index.js`], + external: ['dompurify', 'vue'], + minifyIdentifiers: false, + minifySyntax: false, + minifyWhitespace: false, + outdir: './test/contracts', + sourcemap: false, + splitting: false + } + // Additional options which are not part of the esbuild API. const esbuildOtherOptionBags = { main: { @@ -356,19 +375,14 @@ module.exports = (grunt) => { }, exec: { - eslint: 'node ./node_modules/eslint/bin/eslint.js --cache "**/*.{js,vue}"', + chelDeployAll: 'find contracts -iname "*.manifest.json" | xargs -r ./node_modules/.bin/chel deploy ./data', + eslint: 'node ./node_modules/eslint/bin/eslint.js --cache "**/*.{js,ts,vue}"', flow: '"./node_modules/.bin/flow" --quiet || echo The Flow check failed!', puglint: '"./node_modules/.bin/pug-lint-vue" frontend/views', stylelint: 'node ./node_modules/stylelint/bin/stylelint.js --cache "frontend/assets/style/**/*.{css,sass,scss}" "frontend/views/**/*.vue"', - // Test files: - // - anything in the `/test` folder, e.g. integration tests; - // - anything that ends with `.test.js`, e.g. unit tests for SBP domains kept in the domain folder. - // The `--require` flag ensures custom Babel support in our test files. - test: { - cmd: 'node --experimental-fetch node_modules/mocha/bin/mocha --require ./scripts/mocha-helper.js --exit -R spec --bail "./{test/,!(node_modules|ignored|dist|historical|test)/**/}*.test.js"', - options: { env: process.env } - }, - chelDeployAll: 'find contracts -iname "*.manifest.json" | xargs -r ./node_modules/.bin/chel deploy ./data' + // Test anything that ends with `.test.{js|ts}` in the specified folders, e.g. unit tests for SBP domains kept in the domain folder. + test: `deno test ${denoTestPermissions.join(' ')} frontend shared test`, + ts: 'deno check backend/*.ts shared/*.ts shared/domains/chelonia/*.ts' } }) @@ -378,58 +392,11 @@ module.exports = (grunt) => { let child = null - // Useful helper task for `grunt test`. - grunt.registerTask('backend:launch', '[internal]', function () { - const done = this.async() - grunt.log.writeln('backend: launching...') - // Provides Babel support for the backend files. - require('@babel/register') - require(backendIndex).then(done).catch(done) - }) - - // Used with `grunt dev` only, makes it possible to restart just the server when - // backend or shared files are modified. - grunt.registerTask('backend:relaunch', '[internal]', function () { - const done = this.async() // Tell Grunt we're async. - const fork2 = function () { - grunt.log.writeln('backend: forking...') - child = fork(backendIndex, process.argv, { - env: process.env, - execArgv: ['--require', '@babel/register'] - }) - child.on('error', (err) => { - if (err) { - console.error('error starting or sending message to child:', err) - process.exit(1) - } - }) - child.on('exit', (c) => { - if (c !== 0) { - grunt.log.error(`child exited with error code: ${c}`.bold) - // ^C can cause c to be null, which is an OK error. - process.exit(c || 0) - } - }) - done() - } - if (child) { - grunt.log.writeln('Killing child!') - // Wait for successful shutdown to avoid EADDRINUSE errors. - child.on('message', () => { - child = null - fork2() - }) - child.send({ shutdown: 1 }) - } else { - fork2() - } - }) - grunt.registerTask('build', function () { const esbuild = this.flags.watch ? 'esbuild:watch' : 'esbuild' if (!grunt.option('skipbuild')) { - grunt.task.run(['exec:eslint', 'exec:flow', 'exec:puglint', 'exec:stylelint', 'clean', 'copy', esbuild]) + grunt.task.run(['exec:eslint', 'exec:flow', 'exec:puglint', 'exec:stylelint', 'exec:ts', 'clean', 'copy', esbuild]) } }) @@ -489,8 +456,56 @@ module.exports = (grunt) => { }) grunt.registerTask('default', ['dev']) + + grunt.registerTask('deno:start', function () { + const done = this.async() // Tell Grunt we're async. + child = spawn( + 'deno', + ['run', ...denoRunPermissions, backendIndex], + { + stdio: 'inherit' + } + ) + child.on('error', (err) => { + if (err) { + console.error('Error starting or sending message to child:', err) + process.exit(1) + } + }) + child.on('exit', (c) => { + // ^C can cause c to be null, which is an OK error. + if (c === null) { + grunt.log.writeln('Backend process exited with null code.') + } else if (c !== 0) { + grunt.log.error(`Backend process exited with error code: ${c}`.bold) + process.exit(c) + } else { + grunt.log.writeln('Backend process exited normally.') + } + }) + child.on('close', (code) => { + console.log(`Backend process closed with code ${code}`) + }) + child.on('spawn', () => { + grunt.log.writeln('Backend process spawned.') + done() + }) + }) + + grunt.registerTask('deno:stop', function () { + if (child) { + const killed = child.kill() + if (killed) { + grunt.log.writeln('Deno backend stopped.') + child = null + } else { + grunt.log.error('Failed to quit dangling child!') + } + } + }) + // TODO: add 'deploy' as per https://github.com/okTurtles/group-income/issues/10 - grunt.registerTask('dev', ['checkDependencies', 'exec:chelDeployAll', 'build:watch', 'backend:relaunch', 'keepalive']) + grunt.registerTask('dev', ['checkDependencies', 'exec:chelDeployAll', 'build:watch', 'deno:start', 'keepalive']) grunt.registerTask('dist', ['build']) // -------------------- @@ -525,6 +540,9 @@ module.exports = (grunt) => { const buildContractsSlim = createEsbuildTask({ ...esbuildOptionBags.contractsSlim, plugins: defaultPlugins }) + const buildTestContractsShared = createEsbuildTask({ + ...esbuildOptionBags.testContractsShared, plugins: defaultPlugins + }) // first we build the contracts since genManifestsAndDeploy depends on that // and then we build the main bundle since it depends on manifests.json @@ -535,6 +553,7 @@ module.exports = (grunt) => { .then(() => { return Promise.all([buildMain.run(), buildServiceWorkers.run()]) }) + .then(() => buildTestContractsShared.run()) .catch(error => { grunt.log.error(error.message) process.exit(1) @@ -554,7 +573,7 @@ module.exports = (grunt) => { ;[ [['Gruntfile.js'], [eslint]], - [['backend/**/*.js', 'shared/**/*.js'], [eslint, 'backend:relaunch']], + [['backend/**/*.ts', 'shared/**/*.ts'], [eslint, 'deno:stop', 'deno:start']], [['frontend/**/*.html'], ['copy']], [['frontend/**/*.js'], [eslint]], [['frontend/assets/{fonts,images}/**/*'], ['copy']], @@ -627,6 +646,19 @@ module.exports = (grunt) => { done() }) + // Stops the Flowtype server. + grunt.registerTask('flow:stop', function () { + const done = this.async() + exec('./node_modules/.bin/flow stop', (err, stdout, stderr) => { + if (!err) { + grunt.log.writeln('Flowtype server stopped') + } else { + grunt.log.error('Could not stop Flowtype server:', err.message) + } + done(err) + }) + }) + // eslint-disable-next-line no-unused-vars let killKeepAlive = null grunt.registerTask('keepalive', function () { @@ -635,14 +667,15 @@ module.exports = (grunt) => { killKeepAlive = this.async() }) - grunt.registerTask('test', ['build', 'exec:chelDeployAll', 'backend:launch', 'exec:test', 'cypress']) - grunt.registerTask('test:unit', ['backend:launch', 'exec:test']) + grunt.registerTask('test', ['build', 'exec:chelDeployAll', 'deno:start', 'exec:test', 'cypress', 'deno:stop', 'flow:stop']) + grunt.registerTask('test:unit', ['deno:start', 'exec:test', 'deno:stop']) // ------------------------------------------------------------------------- // Process event handlers // ------------------------------------------------------------------------- - process.on('exit', () => { + process.on('exit', (code, signal) => { + console.log('[node] Exiting with code:', code, 'signal:', signal) // Note: 'beforeExit' doesn't work. // In cases where 'watch' fails while child (server) is still running // we will exit and child will continue running in the background. @@ -650,12 +683,14 @@ module.exports = (grunt) => { // the PORT_SHIFT envar. If grunt-contrib-watch livereload process // cannot bind to the port for some reason, then the parent process // will exit leaving a dangling child server process. - if (child) { + if (child && !child.killed) { grunt.log.writeln('Quitting dangling child!') - child.send({ shutdown: 2 }) + child.kill() } - // Stops the Flowtype server. - exec('./node_modules/.bin/flow stop') + // Make sure to stop the Flowtype server in case `flow:stop` wasn't called. + exec('./node_modules/.bin/flow stop', () => { + grunt.log.writeln('Flowtype server stopped in process exit handler') + }) }) process.on('uncaughtException', (err) => { diff --git a/backend/database.js b/backend/database.js deleted file mode 100644 index c47eb9e689..0000000000 --- a/backend/database.js +++ /dev/null @@ -1,222 +0,0 @@ -'use strict' - -import sbp from '@sbp/sbp' -import { strToB64 } from '~/shared/functions.js' -import { Readable } from 'stream' -import fs from 'fs' -import util from 'util' -import path from 'path' -import '@sbp/okturtles.data' -import '~/shared/domains/chelonia/db.js' -import LRU from 'lru-cache' - -const Boom = require('@hapi/boom') - -const writeFileAsync = util.promisify(fs.writeFile) -const readFileAsync = util.promisify(fs.readFile) -const dataFolder = path.resolve('./data') - -if (!fs.existsSync(dataFolder)) { - fs.mkdirSync(dataFolder, { mode: 0o750 }) -} - -const production = process.env.NODE_ENV === 'production' - -export default (sbp('sbp/selectors/register', { - 'backend/db/streamEntriesSince': async function (contractID: string, hash: string): Promise<*> { - let currentHEAD = await sbp('chelonia/db/latestHash', contractID) - if (!currentHEAD) { - throw Boom.notFound(`contractID ${contractID} doesn't exist!`) - } - let prefix = '[' - // NOTE: if this ever stops working you can also try Readable.from(): - // https://nodejs.org/api/stream.html#stream_stream_readable_from_iterable_options - return new Readable({ - async read (): any { - try { - const entry = await sbp('chelonia/db/getEntry', currentHEAD) - const json = `"${strToB64(entry.serialize())}"` - if (currentHEAD !== hash) { - this.push(prefix + json) - currentHEAD = entry.message().previousHEAD - prefix = ',' - } else { - this.push(prefix + json + ']') - this.push(null) - } - } catch (e) { - console.error(`read(): ${e.message}:`, e) - this.push(']') - this.push(null) - } - } - }) - }, - 'backend/db/streamEntriesBefore': async function (before: string, limit: number): Promise<*> { - let prefix = '[' - let currentHEAD = before - let entry = await sbp('chelonia/db/getEntry', currentHEAD) - if (!entry) { - throw Boom.notFound(`entry ${currentHEAD} doesn't exist!`) - } - limit++ // to return `before` apart from the `limit` number of events - // NOTE: if this ever stops working you can also try Readable.from(): - // https://nodejs.org/api/stream.html#stream_stream_readable_from_iterable_options - return new Readable({ - async read (): any { - try { - if (!currentHEAD || !limit) { - this.push(']') - this.push(null) - } else { - entry = await sbp('chelonia/db/getEntry', currentHEAD) - const json = `"${strToB64(entry.serialize())}"` - this.push(prefix + json) - prefix = ',' - limit-- - currentHEAD = entry.message().previousHEAD - } - } catch (e) { - // TODO: properly return an error to caller, see https://nodejs.org/api/stream.html#errors-while-reading - console.error(`read(): ${e.message}:`, e) - this.push(']') - this.push(null) - } - } - }) - }, - 'backend/db/streamEntriesBetween': async function (startHash: string, endHash: string, offset: number): Promise<*> { - let prefix = '[' - let isMet = false - let currentHEAD = endHash - let entry = await sbp('chelonia/db/getEntry', currentHEAD) - if (!entry) { - throw Boom.notFound(`entry ${currentHEAD} doesn't exist!`) - } - // NOTE: if this ever stops working you can also try Readable.from(): - // https://nodejs.org/api/stream.html#stream_stream_readable_from_iterable_options - return new Readable({ - async read (): any { - try { - entry = await sbp('chelonia/db/getEntry', currentHEAD) - const json = `"${strToB64(entry.serialize())}"` - this.push(prefix + json) - prefix = ',' - - if (currentHEAD === startHash) { - isMet = true - } else if (isMet) { - offset-- - } - - currentHEAD = entry.message().previousHEAD - if (!currentHEAD || (isMet && !offset)) { - this.push(']') - this.push(null) - } - } catch (e) { - // TODO: properly return an error to caller, see https://nodejs.org/api/stream.html#errors-while-reading - console.error(`read(): ${e.message}:`, e) - this.push(']') - this.push(null) - } - } - }) - }, - // ======================= - // wrapper methods to add / lookup names - // ======================= - 'backend/db/registerName': async function (name: string, value: string): Promise<*> { - const exists = await sbp('backend/db/lookupName', name) - if (exists) { - if (!Boom.isBoom(exists)) { - return Boom.conflict('exists') - } else if (exists.output.statusCode !== 404) { - throw exists // throw if this is an error other than "not found" - } - // otherwise it is a Boom.notFound(), proceed ahead - } - await sbp('chelonia/db/set', namespaceKey(name), value) - return { name, value } - }, - 'backend/db/lookupName': async function (name: string): Promise<*> { - const value = await sbp('chelonia/db/get', namespaceKey(name)) - return value || Boom.notFound() - }, - // ======================= - // Filesystem API - // - // TODO: add encryption - // ======================= - 'backend/db/readFile': async function (filename: string): Promise<*> { - const filepath = throwIfFileOutsideDataDir(filename) - if (!fs.existsSync(filepath)) { - return Boom.notFound() - } - return await readFileAsync(filepath) - }, - 'backend/db/writeFile': async function (filename: string, data: any): Promise<*> { - // TODO: check for how much space we have, and have a server setting - // that determines how much of the disk space we're allowed to - // use. If the size of the file would cause us to exceed this - // amount, throw an exception - return await writeFileAsync(throwIfFileOutsideDataDir(filename), data) - }, - 'backend/db/writeFileOnce': async function (filename: string, data: any): Promise<*> { - const filepath = throwIfFileOutsideDataDir(filename) - if (fs.existsSync(filepath)) { - console.warn('writeFileOnce: exists:', filepath) - return - } - return await writeFileAsync(filepath, data) - } -}): any) - -function namespaceKey (name: string): string { - return 'name=' + name -} - -function throwIfFileOutsideDataDir (filename: string): string { - const filepath = path.resolve(path.join(dataFolder, filename)) - if (filepath.indexOf(dataFolder) !== 0) { - throw Boom.badRequest(`bad name: ${filename}`) - } - return filepath -} - -if (production || process.env.GI_PERSIST) { - // https://github.com/isaacs/node-lru-cache#usage - const cache = new LRU({ - max: Number(process.env.GI_LRU_NUM_ITEMS) || 10000 - }) - - sbp('sbp/selectors/overwrite', { - // we cannot simply map this to readFile, because 'chelonia/db/getEntry' - // calls this and expects a string, not a Buffer - // 'chelonia/db/get': sbp('sbp/selectors/fn', 'backend/db/readFile'), - 'chelonia/db/get': async function (filename: string) { - const lookupValue = cache.get(filename) - if (lookupValue !== undefined) { - return lookupValue - } - const bufferOrError = await sbp('backend/db/readFile', filename) - if (Boom.isBoom(bufferOrError)) { - return null - } - const value = bufferOrError.toString('utf8') - cache.set(filename, value) - return value - }, - 'chelonia/db/set': async function (filename: string, data: any): Promise<*> { - // eslint-disable-next-line no-useless-catch - try { - const result = await sbp('backend/db/writeFile', filename, data) - cache.set(filename, data) - return result - } catch (err) { - throw err - } - } - }) - sbp('sbp/selectors/lock', ['chelonia/db/get', 'chelonia/db/set', 'chelonia/db/delete']) -} diff --git a/backend/database.ts b/backend/database.ts new file mode 100644 index 0000000000..de9e427010 --- /dev/null +++ b/backend/database.ts @@ -0,0 +1,241 @@ +/* globals Deno */ +import * as pathlib from 'path' + +import LRU from 'lru-cache' +import sbp from '@sbp/sbp' + +import '~/shared/domains/chelonia/db.ts' +import { strToB64 } from '~/shared/functions.ts' + +const NODE_ENV = Deno.env.get('NODE_ENV') + +// Don't use errors from any server framework in this file. +const { AlreadyExists, NotFound, PermissionDenied } = Deno.errors +const dataFolder = pathlib.resolve('./data') +const production = NODE_ENV === 'production' +const readFileAsync = Deno.readFile +const writeFileAsync = Deno.writeFile + +const dirExists = async (pathname: string): Promise => { + try { + return (await Deno.stat(pathname)).isDirectory + } catch { + return false + } +} + +const fileExists = async (pathname: string): Promise => { + try { + return (await Deno.stat(pathname)).isFile + } catch { + return false + } +} + +if (!(await dirExists(dataFolder))) { + await Deno.mkdir(dataFolder, { mode: 0o750, recursive: true }) +} + +// These selectors must not return bang errors. +export default sbp('sbp/selectors/register', { + 'backend/db/streamEntriesSince': async function (contractID: string, hash: string): Promise { + let currentHEAD = await sbp('chelonia/db/latestHash', contractID) + if (!currentHEAD) { + throw new NotFound(`contractID ${contractID} doesn't exist!`) + } + const chunks = ['['] + try { + while (true) { + const entry = await sbp('chelonia/db/getEntry', currentHEAD) + if (!entry) { + console.error(`read(): entry ${currentHEAD} no longer exists.`) + chunks.push(']') + break + } + if (chunks.length > 1) chunks.push(',') + chunks.push(`"${strToB64(entry.serialize())}"`) + if (currentHEAD === hash) { + chunks.push(']') + break + } else { + currentHEAD = entry.message().previousHEAD + } + } + } catch (error) { + console.error(`read(): ${error.message}:`, error) + } + return chunks.join('') + }, + 'backend/db/streamEntriesBefore': async function (before: string, limit: number): Promise { + let currentHEAD = before + const entry = await sbp('chelonia/db/getEntry', currentHEAD) + if (!entry) { + throw new NotFound(`entry ${currentHEAD} doesn't exist!`) + } + // To return `before` apart from the `limit` number of events. + limit++ + const chunks = ['['] + try { + while (true) { + if (!currentHEAD || !limit) { + chunks.push(']') + break + } + const entry = await sbp('chelonia/db/getEntry', currentHEAD) + if (!entry) { + console.error(`read(): entry ${currentHEAD} no longer exists.`) + chunks.push(']') + break + } + if (chunks.length > 1) chunks.push(',') + chunks.push(`"${strToB64(entry.serialize())}"`) + + currentHEAD = entry.message().previousHEAD + limit-- + } + } catch (error) { + console.error(`read(): ${error.message}:`, error) + } + return chunks.join('') + }, + 'backend/db/streamEntriesBetween': async function (startHash: string, endHash: string, offset: number): Promise { + let isMet = false + let currentHEAD = endHash + const entry = await sbp('chelonia/db/getEntry', currentHEAD) + if (!entry) { + throw new NotFound(`entry ${currentHEAD} doesn't exist!`) + } + const chunks = ['['] + try { + while (true) { + const entry = await sbp('chelonia/db/getEntry', currentHEAD) + if (!entry) { + console.error(`read(): entry ${currentHEAD} no longer exists.`) + chunks.push(']') + break + } + if (chunks.length > 1) chunks.push(',') + chunks.push(`"${strToB64(entry.serialize())}"`) + + if (currentHEAD === startHash) { + isMet = true + } else if (isMet) { + offset-- + } + currentHEAD = entry.message().previousHEAD + + if (!currentHEAD || (isMet && !offset)) { + chunks.push(']') + break + } + } + } catch (error) { + console.error(`read(): ${error.message}:`, error) + } + return chunks.join('') + }, + // ======================= + // Wrapper methods to add / lookup names + // ======================= + 'backend/db/registerName': async function (name: string, value: string): Promise<{ name: string, value: string } | Error> { + try { + await sbp('backend/db/lookupName', name) + // If no error was thrown, then the given name has already been registered. + return new AlreadyExists(`in backend/db/registerName: ${name}`) + } catch (err) { + // Proceed ahead if this is a NotFound error. + if (err instanceof NotFound) { + await sbp('chelonia/db/set', namespaceKey(name), value) + return { name, value } + } + // Otherwise it is an unexpected error, so rethrow it. + throw err + } + }, + 'backend/db/lookupName': async function (name: string) { + const value = await sbp('chelonia/db/get', namespaceKey(name)) + console.log('value:', value) + if (value !== undefined) { + return value + } else { + throw new NotFound(name) + } + }, + // ======================= + // Filesystem API + // + // TODO: add encryption + // ======================= + 'backend/db/readFile': async function (filename: string) { + const filepath = throwIfFileOutsideDataDir(filename) + if (!(await fileExists(filepath))) { + return new NotFound() + } + return await readFileAsync(filepath) + }, + 'backend/db/writeFile': async function (filename: string, data: Uint8Array) { + // TODO: check for how much space we have, and have a server setting + // that determines how much of the disk space we're allowed to + // use. If the size of the file would cause us to exceed this + // amount, throw an exception + return await writeFileAsync(throwIfFileOutsideDataDir(filename), data) + }, + 'backend/db/writeFileOnce': async function (filename: string, data: Uint8Array) { + const filepath = throwIfFileOutsideDataDir(filename) + if (await fileExists(filepath)) { + console.warn('writeFileOnce: exists:', filepath) + return + } + return await writeFileAsync(filepath, data) + } +}) + +function namespaceKey (name: string): string { + return 'name=' + name +} + +// TODO: maybe Deno's own filesystem permissions can make this unnecessary. +function throwIfFileOutsideDataDir (filename: string): string { + const filepath = pathlib.resolve(pathlib.join(dataFolder, filename)) + if (!filepath.startsWith(dataFolder)) { + throw new PermissionDenied(`bad name: ${filename}`) + } + return filepath +} + +if (production || Deno.env.get('GI_PERSIST')) { + // https://github.com/isaacs/node-lru-cache#usage + const cache = new LRU({ + max: Number(Deno.env.get('GI_LRU_NUM_ITEMS')) || 10000 + }) + + sbp('sbp/selectors/overwrite', { + // we cannot simply map this to readFile, because 'chelonia/db/getEntry' + // calls this and expects a string, not a Buffer + // 'chelonia/db/get': sbp('sbp/selectors/fn', 'backend/db/readFile'), + 'chelonia/db/get': async function (filename: string) { + const lookupValue = cache.get(filename) + if (lookupValue !== undefined) { + return lookupValue + } + const bufferOrError = await sbp('backend/db/readFile', filename) + if (bufferOrError instanceof Error) { + return null + } + const value = bufferOrError.toString('utf8') + cache.set(filename, value) + return value + }, + 'chelonia/db/set': async function (filename: string, data: unknown): Promise { + // eslint-disable-next-line no-useless-catch + try { + const result = await sbp('backend/db/writeFile', filename, data) + cache.set(filename, data) + return result + } catch (err) { + throw err + } + } + }) + sbp('sbp/selectors/lock', ['chelonia/db/get', 'chelonia/db/set', 'chelonia/db/delete']) +} diff --git a/backend/events.js b/backend/events.ts similarity index 100% rename from backend/events.js rename to backend/events.ts diff --git a/backend/index.js b/backend/index.js deleted file mode 100644 index ce4f2d84d3..0000000000 --- a/backend/index.js +++ /dev/null @@ -1,76 +0,0 @@ -'use strict' - -import sbp from '@sbp/sbp' -import '@sbp/okturtles.data' -import '@sbp/okturtles.events' -import { SERVER_RUNNING } from './events.js' -import { PUBSUB_INSTANCE } from './instance-keys.js' -import chalk from 'chalk' - -global.logger = function (err) { - console.error(err) - err.stack && console.error(err.stack) - return err // routes.js is written in a way that depends on this returning the error -} - -const dontLog = { 'backend/server/broadcastEntry': true } - -function logSBP (domain, selector, data) { - if (!dontLog[selector]) { - console.log(chalk.bold(`[sbp] ${selector}`), data) - } -} - -;['backend'].forEach(domain => sbp('sbp/filters/domain/add', domain, logSBP)) -;[].forEach(sel => sbp('sbp/filters/selector/add', sel, logSBP)) - -module.exports = (new Promise((resolve, reject) => { - sbp('okTurtles.events/on', SERVER_RUNNING, function () { - console.log(chalk.bold('backend startup sequence complete.')) - resolve() - }) - // call this after we've registered listener for SERVER_RUNNING - require('./server.js') -}): Promise) - -const shutdownFn = function (message) { - sbp('okTurtles.data/apply', PUBSUB_INSTANCE, function (pubsub) { - console.log('message received in child, shutting down...', message) - pubsub.on('close', async function () { - try { - await sbp('backend/server/stop') - console.log('Hapi server down') - // await db.stop() - // console.log('database stopped') - process.send({}) // tell grunt we've successfully shutdown the server - process.nextTick(() => process.exit(0)) // triple-check we quit :P - } catch (err) { - console.error('Error during shutdown:', err) - process.exit(1) - } - }) - pubsub.close() - // Since `ws` v8.0, `WebSocketServer.close()` no longer closes remaining connections. - // See https://github.com/websockets/ws/commit/df7de574a07115e2321fdb5fc9b2d0fea55d27e8 - pubsub.clients.forEach(client => client.terminate()) - }) -} - -// sent by nodemon -process.on('SIGUSR2', shutdownFn) - -// when spawned via grunt, listen for message to cleanly shutdown and relinquish port -process.on('message', shutdownFn) - -process.on('uncaughtException', (err) => { - console.error('[server] Unhandled exception:', err, err.stack) - process.exit(1) -}) - -process.on('unhandledRejection', (reason, p) => { - console.error('[server] Unhandled promise rejection:', p, 'reason:', reason) - process.exit(1) -}) - -// TODO: should we use Bluebird to handle swallowed errors -// http://jamesknelson.com/are-es6-promises-swallowing-your-errors/ diff --git a/backend/index.ts b/backend/index.ts new file mode 100644 index 0000000000..8edcc0f6bd --- /dev/null +++ b/backend/index.ts @@ -0,0 +1,79 @@ +import { bold } from 'fmt/colors.ts' + +import sbp from '@sbp/sbp' +import '@sbp/okturtles.data' +import '@sbp/okturtles.events' +import { notFound } from 'pogo/lib/bang.ts' + +import '~/scripts/process-shim.ts' +import { SERVER_RUNNING } from './events.ts' +import { PUBSUB_INSTANCE } from './instance-keys.ts' +import type { PubsubServer } from './pubsub.ts' + +// @ts-expect-error TS7017 [ERROR]: Element implicitly has an 'any' type. +globalThis.logger = function (err: Error) { + console.error(err) + err.stack && console.error(err.stack) + if (err instanceof Deno.errors.NotFound) { + console.log('Returning notFound()', err.message) + return notFound(err.message) + } + return err // routes.ts is written in a way that depends on this returning the error +} + +const dontLog: Record = { 'backend/server/broadcastEntry': true } + +function logSBP (domain: string, selector: string, data: unknown) { + if (!(selector in dontLog)) { + console.log(bold(`[sbp] ${selector}`), data) + } +} + +['backend'].forEach(domain => sbp('sbp/filters/domain/add', domain, logSBP)) +;[].forEach(sel => sbp('sbp/filters/selector/add', sel, logSBP)) + +export default (new Promise((resolve, reject) => { + try { + sbp('okTurtles.events/on', SERVER_RUNNING, function () { + console.log(bold('backend startup sequence complete.')) + resolve(undefined) + }) + // Call this after we've registered listener for `SERVER_RUNNING`. + import('./server.ts') + } catch (err) { + reject(err) + } +})) + +const shutdownFn = (): void => { + sbp('okTurtles.data/apply', PUBSUB_INSTANCE, function (pubsub: PubsubServer) { + console.log('signal received in child, shutting down...') + pubsub.on('close', async function () { + try { + await sbp('backend/server/stop') + console.log('Backend server down') + Deno.exit(0) + } catch (err) { + console.error('Error during shutdown:', err) + Deno.exit(1) + } + }) + pubsub.close() + }) +} + +/* + On Windows only SIGINT (ctrl+c) and SIGBREAK (ctrl+break) are supported, but + SIGBREAK is not supported on Linux. +*/ +['SIGBREAK', 'SIGINT'].forEach(signal => { + try { + Deno.addSignalListener(signal as Deno.Signal, shutdownFn) + } catch {} +}) + +// Equivalent to the `uncaughtException` event in Nodejs. +addEventListener('error', (event) => { + console.error('[server] Unhandled exception:', event) + Deno.exit(1) +}) diff --git a/backend/instance-keys.js b/backend/instance-keys.ts similarity index 100% rename from backend/instance-keys.js rename to backend/instance-keys.ts diff --git a/backend/pubsub.js b/backend/pubsub.js deleted file mode 100644 index 78bb004a16..0000000000 --- a/backend/pubsub.js +++ /dev/null @@ -1,336 +0,0 @@ -/* globals logger */ -'use strict' - -/* - * Pub/Sub server implementation using the `ws` library. - * See https://github.com/websockets/ws#api-docs - */ - -import { - NOTIFICATION_TYPE, - REQUEST_TYPE, - RESPONSE_TYPE, - createClient, - createMessage, - messageParser -} from '~/shared/pubsub.js' - -import type { - Message, SubMessage, UnsubMessage, - NotificationTypeEnum, ResponseTypeEnum -} from '~/shared/pubsub.js' - -import type { JSONType } from '~/shared/types.js' - -const { bold } = require('chalk') -const WebSocket = require('ws') - -const { PING, PONG, PUB, SUB, UNSUB } = NOTIFICATION_TYPE -const { ERROR, SUCCESS } = RESPONSE_TYPE - -// Used to tag console output. -const tag = '[pubsub]' - -// ====== Helpers ====== // - -const generateSocketID = (() => { - let counter = 0 - - return (debugID) => String(counter++) + (debugID ? '-' + debugID : '') -})() - -const log = console.log.bind(console, tag) -log.bold = (...args) => console.log(bold(tag, ...args)) -log.debug = console.debug.bind(console, tag) -log.error = (...args) => console.error(bold.red(tag, ...args)) - -// ====== API ====== // - -// Re-export some useful things from the shared module. -export { createClient, createMessage, NOTIFICATION_TYPE, REQUEST_TYPE, RESPONSE_TYPE } - -export function createErrorResponse (data: JSONType): string { - return JSON.stringify({ type: ERROR, data }) -} - -export function createNotification (type: NotificationTypeEnum, data: JSONType): string { - return JSON.stringify({ type, data }) -} - -export function createResponse (type: ResponseTypeEnum, data: JSONType): string { - return JSON.stringify({ type, data }) -} - -/** - * Creates a pubsub server instance. - * - * @param {(http.Server|https.Server)} server - A Node.js HTTP/S server to attach to. - * @param {Object?} options - * {boolean?} logPingRounds - Whether to log ping rounds. - * {boolean?} logPongMessages - Whether to log received pong messages. - * {object?} messageHandlers - Custom handlers for different message types. - * {object?} serverHandlers - Custom handlers for server events. - * {object?} socketHandlers - Custom handlers for socket events. - * {number?} backlog=511 - The maximum length of the queue of pending connections. - * {Function?} handleProtocols - A function which can be used to handle the WebSocket subprotocols. - * {number?} maxPayload=6_291_456 - The maximum allowed message size in bytes. - * {string?} path - Accept only connections matching this path. - * {(boolean|object)?} perMessageDeflate - Enables/disables per-message deflate. - * {number?} pingInterval=30_000 - The time to wait between successive pings. - * @returns {Object} - */ -export function createServer (httpServer: Object, options?: Object = {}): Object { - const server = new WebSocket.Server({ - ...defaultOptions, - ...options, - ...{ clientTracking: true }, - server: httpServer - }) - server.customServerEventHandlers = { ...options.serverHandlers } - server.customSocketEventHandlers = { ...options.socketHandlers } - server.messageHandlers = { ...defaultMessageHandlers, ...options.messageHandlers } - server.pingIntervalID = undefined - server.subscribersByContractID = Object.create(null) - - // Add listeners for server events, i.e. events emitted on the server object. - Object.keys(defaultServerHandlers).forEach((name) => { - server.on(name, (...args) => { - try { - // Always call the default handler first. - defaultServerHandlers[name]?.call(server, ...args) - server.customServerEventHandlers[name]?.call(server, ...args) - } catch (error) { - server.emit('error', error) - } - }) - }) - // Setup a ping interval if required. - if (server.options.pingInterval > 0) { - server.pingIntervalID = setInterval(() => { - if (server.clients.length && server.options.logPingRounds) { - log.debug('Pinging clients') - } - server.clients.forEach((client) => { - if (client.pinged && !client.activeSinceLastPing) { - log(`Disconnecting irresponsive client ${client.id}`) - return client.terminate() - } - if (client.readyState === WebSocket.OPEN) { - client.send(createMessage(PING, Date.now()), () => { - client.activeSinceLastPing = false - client.pinged = true - }) - } - }) - }, server.options.pingInterval) - } - return Object.assign(server, publicMethods) -} - -const defaultOptions = { - logPingRounds: process.env.NODE_ENV === 'development' && !process.env.CI, - logPongMessages: false, - maxPayload: 6 * 1024 * 1024, - pingInterval: 30000 -} - -// Default handlers for server events. -// The `this` binding refers to the server object. -const defaultServerHandlers = { - close () { - log('Server closed') - }, - /** - * Emitted when a connection handshake completes. - * - * @see https://github.com/websockets/ws/blob/master/doc/ws.md#event-connection - * @param {ws.WebSocket} socket - The client socket that connected. - * @param {http.IncomingMessage} request - The underlying Node http GET request. - */ - connection (socket: Object, request: Object) { - const server = this - const url = request.url - const urlSearch = url.includes('?') ? url.slice(url.lastIndexOf('?')) : '' - const debugID = new URLSearchParams(urlSearch).get('debugID') || '' - socket.id = generateSocketID(debugID) - socket.activeSinceLastPing = true - socket.pinged = false - socket.server = server - socket.subscriptions = new Set() - - log.bold(`Socket ${socket.id} connected. Total: ${this.clients.size}`) - - // Add listeners for socket events, i.e. events emitted on a socket object. - ;['close', 'error', 'message', 'ping', 'pong'].forEach((eventName) => { - socket.on(eventName, (...args) => { - // Logging of 'message' events is handled in the default 'message' event handler. - if (eventName !== 'message') { - log(`Event '${eventName}' on socket ${socket.id}`, ...args.map(arg => String(arg))) - } - try { - (defaultSocketEventHandlers: Object)[eventName]?.call(socket, ...args) - socket.server.customSocketEventHandlers[eventName]?.call(socket, ...args) - } catch (error) { - socket.server.emit('error', error) - socket.terminate() - } - }) - }) - }, - error (error: Error) { - log.error('Server error:', error) - logger(error) - }, - headers () { - }, - listening () { - log('Server listening') - } -} - -// Default handlers for server-side client socket events. -// The `this` binding refers to the connected `ws` socket object. -const defaultSocketEventHandlers = { - close (code: string, reason: string) { - const socket = this - const { server, id: socketID } = this - - // Notify other client sockets that this one has left any room they shared. - for (const contractID of socket.subscriptions) { - const subscribers = server.subscribersByContractID[contractID] - // Remove this socket from the subscribers of the given contract. - subscribers.delete(socket) - const notification = createNotification(UNSUB, { contractID, socketID }) - server.broadcast(notification, { to: subscribers }) - } - socket.subscriptions.clear() - }, - - message (data: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) { - const socket = this - const { server } = this - const text = data.toString() - let msg: Message = { type: '' } - - try { - msg = messageParser(text) - } catch (error) { - log.error(`Malformed message: ${error.message}`) - server.rejectMessageAndTerminateSocket(msg, socket) - return - } - // Now that we have successfully parsed the message, we can log it. - if (msg.type !== 'pong' || server.options.logPongMessages) { - log(`Received '${msg.type}' on socket ${socket.id}`, text) - } - // The socket can be marked as active since it just received a message. - socket.activeSinceLastPing = true - const handler = server.messageHandlers[msg.type] - - if (handler) { - try { - handler.call(socket, msg) - } catch (error) { - // Log the error message and stack trace but do not send it to the client. - logger(error) - server.rejectMessageAndTerminateSocket(msg, socket) - } - } else { - log.error(`Unhandled message type: ${msg.type}`) - server.rejectMessageAndTerminateSocket(msg, socket) - } - } -} - -// These handlers receive the connected `ws` socket through the `this` binding. -const defaultMessageHandlers = { - [PONG] (msg: Message) { - const socket = this - // const timestamp = Number(msg.data) - // const latency = Date.now() - timestamp - socket.activeSinceLastPing = true - }, - - [PUB] (msg: Message) { - // Currently unused. - }, - - [SUB] ({ contractID, dontBroadcast }: SubMessage) { - const socket = this - const { server, id: socketID } = this - - if (!socket.subscriptions.has(contractID)) { - log('Already subscribed to', contractID) - // Add the given contract ID to our subscriptions. - socket.subscriptions.add(contractID) - if (!server.subscribersByContractID[contractID]) { - server.subscribersByContractID[contractID] = new Set() - } - const subscribers = server.subscribersByContractID[contractID] - // Add this socket to the subscribers of the given contract. - subscribers.add(socket) - if (!dontBroadcast) { - // Broadcast a notification to every other open subscriber. - const notification = createNotification(SUB, { contractID, socketID }) - server.broadcast(notification, { to: subscribers, except: socket }) - } - } - socket.send(createResponse(SUCCESS, { type: SUB, contractID })) - }, - - [UNSUB] ({ contractID, dontBroadcast }: UnsubMessage) { - const socket = this - const { server, id: socketID } = this - - if (socket.subscriptions.has(contractID)) { - // Remove the given contract ID from our subscriptions. - socket.subscriptions.delete(contractID) - if (server.subscribersByContractID[contractID]) { - const subscribers = server.subscribersByContractID[contractID] - // Remove this socket from the subscribers of the given contract. - subscribers.delete(socket) - if (!dontBroadcast) { - const notification = createNotification(UNSUB, { contractID, socketID }) - // Broadcast a notification to every other open subscriber. - server.broadcast(notification, { to: subscribers, except: socket }) - } - } - } - socket.send(createResponse(SUCCESS, { type: UNSUB, contractID })) - } -} - -const publicMethods = { - /** - * Broadcasts a message, ignoring clients which are not open. - * - * @param message - * @param to - The intended recipients of the message. Defaults to every open client socket. - * @param except - A recipient to exclude. Optional. - */ - broadcast ( - message: Message, - { to, except }: { to?: Iterable, except?: Object } - ) { - const server = this - - for (const client of to || server.clients) { - if (client.readyState === WebSocket.OPEN && client !== except) { - client.send(message) - } - } - }, - - // Enumerates the subscribers of a given contract. - * enumerateSubscribers (contractID: string): Iterable { - const server = this - - if (contractID in server.subscribersByContractID) { - yield * server.subscribersByContractID[contractID] - } - }, - - rejectMessageAndTerminateSocket (request: Message, socket: Object) { - socket.send(createErrorResponse({ ...request }), () => socket.terminate()) - } -} diff --git a/backend/pubsub.ts b/backend/pubsub.ts new file mode 100644 index 0000000000..a2df1c7dea --- /dev/null +++ b/backend/pubsub.ts @@ -0,0 +1,447 @@ +import { messageParser } from '~/shared/pubsub.ts' + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Callback = (...args: any[]) => void + +type JSONType = ReturnType + +interface Message { + [key: string]: JSONType + type: string +} + +type MessageHandler = (this: PubsubClient, msg: Message) => void + +type PubsubClientEventName = 'close' | 'message' +type PubsubServerEventName = 'close' | 'connection' | 'error' | 'headers' | 'listening' + +type PubsubServerOptions = { + clientHandlers?: Record + logPingRounds?: boolean + logPongMessages?: boolean + maxPayload?: number + messageHandlers?: Record + pingInterval?: number + rawHttpServer?: unknown + serverHandlers?: Record +} + +const emptySet = Object.freeze(new Set()) +// Used to tag console output. +const tag = '[pubsub]' + +// ====== Helpers ====== // + +const generateSocketID = (() => { + let counter = 0 + + return (debugID: string): string => String(counter++) + (debugID ? '-' + debugID : '') +})() + +const logger = { + log: console.log.bind(console, tag), + debug: console.debug.bind(console, tag), + error: console.error.bind(console, tag), + info: console.info.bind(console, tag), + warn: console.warn.bind(console, tag) +} + +// ====== API ====== // + +export function createErrorResponse (data: JSONType): string { + return JSON.stringify({ type: 'error', data }) +} + +export function createMessage (type: string, data: JSONType): string { + return JSON.stringify({ type, data }) +} + +export function createNotification (type: string, data: JSONType): string { + return JSON.stringify({ type, data }) +} + +export function createResponse (type: string, data: JSONType): string { + return JSON.stringify({ type, data }) +} + +export class PubsubClient { + activeSinceLastPing: boolean + id: string + pinged: boolean + server: PubsubServer + socket: WebSocket + subscriptions: Set + + constructor (socket: WebSocket, server: PubsubServer, debugID = '') { + this.id = generateSocketID(debugID) + this.activeSinceLastPing = true + this.pinged = false + this.server = server + this.socket = socket + this.subscriptions = new Set() + } + + get readyState () { + return this.socket.readyState + } + + rejectMessageAndTerminate (request: Message) { + // TODO: wait for response to be sent before terminating. + this.socket.send(createErrorResponse({ ...request })) + this.terminate() + } + + send (data: string | ArrayBufferLike | ArrayBufferView | Blob): void { + const { socket } = this + if (socket.readyState === WebSocket.OPEN) { + this.socket.send(data) + } else { + // TODO: enqueue pending data. + } + } + + terminate () { + const { server, socket } = this + internalClientEventHandlers.close.call(this, new CloseEvent('close', { code: 4001, reason: 'terminated' })) + // Remove listeners for socket events, i.e. events emitted on a socket object. + ;['close', 'error', 'message', 'ping', 'pong'].forEach((eventName: string) => { + socket.removeEventListener(eventName, internalClientEventHandlers[eventName as PubsubClientEventName] as EventListener) + if (typeof server.customClientHandlers[eventName] === 'function') { + socket.removeEventListener(eventName as keyof WebSocketEventMap, server.customClientHandlers[eventName] as EventListener) + } + }) + socket.close() + } +} + +export class PubsubServer { + clients: Set + customClientHandlers: Record + customServerHandlers: Record + messageHandlers: Record + options: typeof defaultOptions + pingIntervalID?: number + queuesByEventName: Map> + subscribersByContractID: Record> + + constructor (options: PubsubServerOptions = {}) { + this.clients = new Set() + this.customClientHandlers = options.clientHandlers ?? Object.create(null) + this.customServerHandlers = options.serverHandlers ?? Object.create(null) + this.messageHandlers = { ...defaultMessageHandlers, ...options.messageHandlers } + this.options = { ...defaultOptions, ...options } + this.queuesByEventName = new Map() + this.subscribersByContractID = Object.create(null) + } + + close () { + this.clients.forEach(client => client.terminate()) + this.emit('close') + } + + handleUpgradeableRequest (request: Request): Response { + const server = this + const { socket, response } = Deno.upgradeWebSocket(request) + + socket.onopen = () => { + server.emit('connection', socket, request) + } + return response + } + + emit (name: string, ...args: unknown[]) { + const server = this + const queue = server.queuesByEventName.get(name) ?? emptySet + try { + for (const callback of queue) { + Function.prototype.call.call(callback as Callback, server, ...args) + } + } catch (error) { + if (server.queuesByEventName.has('error')) { + server.emit('error', error) + } else { + throw error + } + } + } + + off (name: string, callback: Callback) { + const server = this + const queue = server.queuesByEventName.get(name) ?? emptySet + queue.delete(callback) + } + + on (name: string, callback: Callback) { + const server = this + if (!server.queuesByEventName.has(name)) { + server.queuesByEventName.set(name, new Set()) + } + const queue = server.queuesByEventName.get(name) + queue?.add(callback) + } + + /** + * Broadcasts a message, ignoring clients which are not open. + * + * @param message + * @param to - The intended recipients of the message. Defaults to every open client socket. + * @param except - A recipient to exclude. Optional. + */ + broadcast ( + message: string, + { to, except }: { to?: Iterable, except?: PubsubClient } + ) { + const server = this + for (const client of to || server.clients) { + if (client.readyState === WebSocket.OPEN && client !== except) { + client.send(message) + } + } + } + + // Enumerates the subscribers of a given contract. + * enumerateSubscribers (contractID: string): Iterable { + const server = this + if (contractID in server.subscribersByContractID) { + yield * server.subscribersByContractID[contractID] + } + } +} + +export function createServer (options: PubsubServerOptions = {}) { + const server = new PubsubServer(options) + + // Add listeners for server events, i.e. events emitted on the server object. + Object.keys(internalServerEventHandlers).forEach((name: string) => { + server.on(name, (...args: unknown[]) => { + try { + // Always call the default handler first. + // @ts-expect-error TS2556: A spread argument must either have a tuple type or be passed to a rest parameter. + internalServerEventHandlers[name as PubsubServerEventName]?.call(server, ...args) + // @ts-expect-error TS2556: A spread argument must either have a tuple type or be passed to a rest parameter. + server.customServerHandlers[name as PubsubServerEventName]?.call(server, ...args) + } catch (error) { + server.emit('error', error) + } + }) + }) + // Setup a ping interval if required. + if (server.options.pingInterval > 0) { + server.pingIntervalID = setInterval(() => { + if (server.clients.size && server.options.logPingRounds) { + logger.debug('Pinging clients') + } + server.clients.forEach((client: PubsubClient) => { + if (client.pinged && !client.activeSinceLastPing) { + logger.log(`Disconnecting irresponsive client ${client.id}`) + return client.terminate() + } + if (client.readyState === WebSocket.OPEN) { + client.send(createMessage(PING, Date.now())) + // TODO: wait for the message to be sent. + client.activeSinceLastPing = false + client.pinged = true + } + }) + }, server.options.pingInterval) + } + return server +} + +export function isUpgradeableRequest (request: Request): boolean { + const upgrade = request.headers.get('upgrade') + if (upgrade?.toLowerCase() === 'websocket') return true + return false +} + +// Internal default handlers for server events. +// The `this` binding refers to the server object. +const internalServerEventHandlers = { + close () { + logger.log('Server closed') + }, + /** + * Emitted when a connection handshake completes. + * + * @see https://github.com/websockets/ws/blob/master/doc/ws.md#event-connection + * @param {WebSocket} socket - The client socket that connected. + * @param {Request} request - The incoming http GET request. + */ + connection (this: PubsubServer, socket: WebSocket, request: Request) { + logger.log('connection:', request.url) + const server = this + const url = request.url + const urlSearch = url.includes('?') ? url.slice(url.lastIndexOf('?')) : '' + const debugID = new URLSearchParams(urlSearch).get('debugID') || '' + + const client = new PubsubClient(socket, server) + client.id = generateSocketID(debugID) + client.activeSinceLastPing = true + server.clients.add(client) + + logger.log(`Client ${client.id} connected. Total: ${this.clients.size}`) + + // Add listeners for socket events, i.e. events emitted on a socket object. + ;['close', 'error', 'message', 'ping', 'pong'].forEach((eventName: string) => { + socket.addEventListener(eventName, (...args) => { + // Logging of 'message' events is handled in the default 'message' event handler. + if (eventName !== 'message') { + logger.log(`Event '${eventName}' on client ${client.id}`, ...args.map(arg => String(arg))) + } + try { + // @ts-expect-error TS2554 [ERROR]: Expected 3 arguments, but got 2. + internalClientEventHandlers[eventName as PubsubClientEventName]?.call(client, ...args) + server.customClientHandlers[eventName]?.call(client, ...args) + } catch (error) { + server.emit('error', error) + client.terminate() + } + }) + }) + }, + error (error: Error) { + logger.error('Server error:', error) + }, + headers () { + }, + listening () { + logger.log('Server listening') + } +} + +// Default handlers for server-side client socket events. +// The `this` binding refers to the connected `ws` socket object. +const internalClientEventHandlers = Object.freeze({ + close (this: PubsubClient, event: CloseEvent) { + const client = this + const { server, id: socketID } = this + + // Notify other clients that this one has left any room they shared. + for (const contractID of client.subscriptions) { + const subscribers = server.subscribersByContractID[contractID] + // Remove this socket from the subscribers of the given contract. + subscribers.delete(client) + const notification = createNotification(UNSUB, { contractID, socketID }) + server.broadcast(notification, { to: subscribers }) + } + client.subscriptions.clear() + // Additional code. + server.clients.delete(client) + }, + + message (this: PubsubClient, event: MessageEvent) { + const client = this + const { server } = this + const { data } = event + const text = data + let msg: Message = { type: '' } + + try { + msg = messageParser(data) + } catch (error) { + logger.error(`Malformed message: ${error.message}`) + client.rejectMessageAndTerminate(msg) + return + } + // Now that we have successfully parsed the message, we can log it. + if (msg.type !== 'pong' || server.options.logPongMessages) { + logger.log(`Received '${msg.type}' on client ${client.id}`, text) + } + // The socket can be marked as active since it just received a message. + client.activeSinceLastPing = true + const handler = server.messageHandlers[msg.type] + + if (handler) { + try { + handler.call(client, msg) + } catch (error) { + // Log the error message and stack trace but do not send it to the client. + logger.error(error) + client.rejectMessageAndTerminate(msg) + } + } else { + logger.error(`Unhandled message type: ${msg.type}`) + client.rejectMessageAndTerminate(msg) + } + } +}) + +export const NOTIFICATION_TYPE = { + APP_VERSION: 'app-version', + ENTRY: 'entry' +} + +const PING = 'ping' +const PONG = 'pong' +const PUB = 'pub' +const SUB = 'sub' +const UNSUB = 'unsub' +const SUCCESS = 'success' + +// These handlers receive the connected PubsubClient instance through the `this` binding. +const defaultMessageHandlers = { + [PONG] (this: PubsubClient, msg: Message) { + const client = this + // const timestamp = Number(msg.data) + // const latency = Date.now() - timestamp + client.activeSinceLastPing = true + }, + + [PUB] (this: PubsubClient, msg: Message) { + // Currently unused. + }, + + [SUB] (this: PubsubClient, { contractID, dontBroadcast }: Message) { + const client = this + const { server, socket, id: socketID } = this + + if (!client.subscriptions.has(contractID)) { + // Add the given contract ID to our subscriptions. + client.subscriptions.add(contractID) + if (!server.subscribersByContractID[contractID]) { + server.subscribersByContractID[contractID] = new Set() + } + const subscribers = server.subscribersByContractID[contractID] + // Add this client to the subscribers of the given contract. + subscribers.add(client) + if (!dontBroadcast) { + // Broadcast a notification to every other open subscriber. + const notification = createNotification(SUB, { contractID, socketID }) + server.broadcast(notification, { to: subscribers, except: client }) + } + } else { + logger.log('Already subscribed to', contractID) + } + socket.send(createResponse(SUCCESS, { type: SUB, contractID })) + }, + + [UNSUB] (this: PubsubClient, { contractID, dontBroadcast }: Message) { + const client = this + const { server, socket, id: socketID } = this + + if (client.subscriptions.has(contractID)) { + // Remove the given contract ID from our subscriptions. + client.subscriptions.delete(contractID) + if (server.subscribersByContractID[contractID]) { + const subscribers = server.subscribersByContractID[contractID] + // Remove this socket from the subscribers of the given contract. + subscribers.delete(client) + if (!dontBroadcast) { + const notification = createNotification(UNSUB, { contractID, socketID }) + // Broadcast a notification to every other open subscriber. + server.broadcast(notification, { to: subscribers, except: client }) + } + } + } else { + logger.log('Was not subscribed to', contractID) + } + socket.send(createResponse(SUCCESS, { type: UNSUB, contractID })) + } +} + +const defaultOptions = { + logPingRounds: true, + logPongMessages: true, + maxPayload: 6 * 1024 * 1024, + pingInterval: 30000 +} diff --git a/backend/routes.js b/backend/routes.js deleted file mode 100644 index be6114b222..0000000000 --- a/backend/routes.js +++ /dev/null @@ -1,249 +0,0 @@ -/* globals logger */ - -'use strict' - -import sbp from '@sbp/sbp' -import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { blake32Hash } from '~/shared/functions.js' -import { SERVER_INSTANCE } from './instance-keys.js' -import path from 'path' -import chalk from 'chalk' -import './database.js' - -const Boom = require('@hapi/boom') -const Joi = require('@hapi/joi') - -const route = new Proxy({}, { - get: function (obj, prop) { - return function (path: string, options: Object, handler: Function | Object) { - sbp('okTurtles.data/apply', SERVER_INSTANCE, function (server: Object) { - server.route({ path, method: prop, options, handler }) - }) - } - } -}) - -// RESTful API routes - -// NOTE: We could get rid of this RESTful API and just rely on pubsub.js to do this -// —BUT HTTP2 might be better than websockets and so we keep this around. -// See related TODO in pubsub.js and the reddit discussion link. -route.POST('/event', { - auth: 'gi-auth', - validate: { payload: Joi.string().required() } -}, async function (request, h) { - try { - console.log('/event handler') - const entry = GIMessage.deserialize(request.payload) - await sbp('backend/server/handleEntry', entry) - return entry.hash() - } catch (err) { - if (err.name === 'ChelErrorDBBadPreviousHEAD') { - console.error(chalk.bold.yellow('ChelErrorDBBadPreviousHEAD'), err) - return Boom.conflict(err.message) - } - return logger(err) - } -}) - -route.GET('/eventsSince/{contractID}/{since}', {}, async function (request, h) { - try { - const { contractID, since } = request.params - const stream = await sbp('backend/db/streamEntriesSince', contractID, since) - // "On an HTTP server, make sure to manually close your streams if a request is aborted." - // From: http://knexjs.org/#Interfaces-Streams - // https://github.com/tgriesser/knex/wiki/Manually-Closing-Streams - // Plus: https://hapijs.com/api#request-events - // request.on('disconnect', stream.end.bind(stream)) - // NOTE: since rewriting database.js to remove objection.js and knex, - // we're currently returning a Readable stream, which doesn't have - // '.end'. If there are any issues we can try switching to returning a - // Writable stream. Both types however do have .destroy. - request.events.once('disconnect', stream.destroy.bind(stream)) - return stream - } catch (err) { - return logger(err) - } -}) - -route.GET('/eventsBefore/{before}/{limit}', {}, async function (request, h) { - try { - const { before, limit } = request.params - - if (!before) return Boom.badRequest('missing before') - if (!limit) return Boom.badRequest('missing limit') - if (isNaN(parseInt(limit)) || parseInt(limit) <= 0) return Boom.badRequest('invalid limit') - - const stream = await sbp('backend/db/streamEntriesBefore', before, parseInt(limit)) - request.events.once('disconnect', stream.destroy.bind(stream)) - return stream - } catch (err) { - return logger(err) - } -}) - -route.GET('/eventsBetween/{startHash}/{endHash}', {}, async function (request, h) { - try { - const { startHash, endHash } = request.params - const offset = parseInt(request.query.offset || '0') - - if (!startHash) return Boom.badRequest('missing startHash') - if (!endHash) return Boom.badRequest('missing endHash') - if (isNaN(offset) || offset < 0) return Boom.badRequest('invalid offset') - - const stream = await sbp('backend/db/streamEntriesBetween', startHash, endHash, offset) - request.events.once('disconnect', stream.destroy.bind(stream)) - return stream - } catch (err) { - return logger(err) - } -}) - -route.POST('/name', { - validate: { - payload: Joi.object({ - name: Joi.string().required(), - value: Joi.string().required() - }) - } -}, async function (request, h) { - try { - const { name, value } = request.payload - return await sbp('backend/db/registerName', name, value) - } catch (err) { - return logger(err) - } -}) - -route.GET('/name/{name}', {}, async function (request, h) { - try { - return await sbp('backend/db/lookupName', request.params.name) - } catch (err) { - return logger(err) - } -}) - -route.GET('/latestHash/{contractID}', { - cache: { otherwise: 'no-store' } -}, async function (request, h) { - try { - const { contractID } = request.params - const hash = await sbp('chelonia/db/latestHash', contractID) - if (!hash) { - console.warn(`[backend] latestHash not found for ${contractID}`) - return Boom.notFound() - } - return hash - } catch (err) { - return logger(err) - } -}) - -route.GET('/time', {}, function (request, h) { - return new Date().toISOString() -}) - -// file upload related - -// TODO: if the browser deletes our cache then not everyone -// has a complete copy of the data and can act as a -// new coordinating server... I don't like that. - -const MEGABTYE = 1048576 // TODO: add settings for these -const SECOND = 1000 - -route.POST('/file', { - // TODO: only allow uploads from registered users - payload: { - output: 'data', - multipart: true, - allow: 'multipart/form-data', - failAction: function (request, h, err) { - console.error('failAction error:', err) - return err - }, - maxBytes: 6 * MEGABTYE, // TODO: make this a configurable setting - timeout: 10 * SECOND // TODO: make this a configurable setting - } -}, async function (request, h) { - try { - console.log('FILE UPLOAD!') - console.log(request.payload) - const { hash, data } = request.payload - if (!hash) return Boom.badRequest('missing hash') - if (!data) return Boom.badRequest('missing data') - // console.log('typeof data:', typeof data) - const ourHash = blake32Hash(data) - if (ourHash !== hash) { - console.error(`hash(${hash}) != ourHash(${ourHash})`) - return Boom.badRequest('bad hash!') - } - await sbp('backend/db/writeFileOnce', hash, data) - return '/file/' + hash - } catch (err) { - return logger(err) - } -}) - -route.GET('/file/{hash}', { - cache: { - // Do not set other cache options here, to make sure the 'otherwise' option - // will be used so that the 'immutable' directive gets included. - otherwise: 'public,max-age=31536000,immutable' - }, - files: { - relativeTo: path.resolve('data') - } -}, function (request, h) { - const { hash } = request.params - console.debug(`GET /file/${hash}`) - // Reusing the given `hash` parameter to set the ETag should be faster than - // letting Hapi hash the file to compute an ETag itself. - return h.file(hash, { etagMethod: false }).etag(hash) -}) - -// SPA routes - -route.GET('/assets/{subpath*}', { - ext: { - onPostHandler: { - method (request, h) { - // since our JS is placed under /assets/ and since service workers - // have their scope limited by where they are, we must add this - // header to allow the service worker to function. Details: - // https://w3c.github.io/ServiceWorker/#service-worker-allowed - if (request.path.includes('assets/js/sw-')) { - console.debug('adding header: Service-Worker-Allowed /') - request.response.header('Service-Worker-Allowed', '/') - } - return h.continue - } - } - }, - files: { - relativeTo: path.resolve('dist/assets') - } -}, function (request, h) { - const { subpath } = request.params - const basename = path.basename(subpath) - console.debug(`GET /assets/${subpath}`) - // In the build config we told our bundler to use the `[name]-[hash]-cached` template - // to name immutable assets. This is useful because `dist/assets/` currently includes - // a few files without hash in their name. - if (basename.includes('-cached')) { - return h.file(subpath, { etagMethod: false }) - .etag(basename) - .header('Cache-Control', 'public,max-age=31536000,immutable') - } - // Files like `main.js` or `main.css` should be revalidated before use. Se we use the default headers. - // This should also be suitable for serving unversioned fonts and images. - return h.file(subpath) -}) - -route.GET('/app/{path*}', {}, { - file: path.resolve('./dist/index.html') -}) - -route.GET('/', {}, function (req, h) { - return h.redirect('/app/') -}) diff --git a/backend/routes.ts b/backend/routes.ts new file mode 100644 index 0000000000..148e1d7cb6 --- /dev/null +++ b/backend/routes.ts @@ -0,0 +1,230 @@ +import { bold, yellow } from 'fmt/colors.ts' + +import sbp from '@sbp/sbp' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.ts' +import { blake32Hash } from '~/shared/functions.ts' + +import { badRequest, notFound } from 'pogo/lib/bang.ts' +import { Router } from 'pogo' +import type { RouteHandler } from 'pogo/lib/types.ts' +import type ServerRequest from 'pogo/lib/request.ts' +import Toolkit from 'pogo/lib/toolkit.ts' + +import './database.ts' +import * as pathlib from 'path' + +declare const logger: (err: Error) => Error + +export const router = new Router() + +type RouterProxyTarget = Record Response> + +const route = new Proxy({} as RouterProxyTarget, { + get (obj: RouterProxyTarget, prop: string) { + return (path: string, handler: RouteHandler) => { + router.add({ path, method: prop, handler }) + } + } +}) + +// RESTful API routes + +// NOTE: We could get rid of this RESTful API and just rely on pubsub.js to do this +// —BUT HTTP2 might be better than websockets and so we keep this around. +// See related TODO in pubsub.js and the reddit discussion link. +route.POST('/event', async function (request: ServerRequest, h: Toolkit) { + try { + console.log('/event handler') + const payload = await request.raw.text() + + const entry = GIMessage.deserialize(payload) + await sbp('backend/server/handleEntry', entry) + return entry.hash() + } catch (err) { + if (err.name === 'ChelErrorDBBadPreviousHEAD') { + console.error(bold(yellow('ChelErrorDBBadPreviousHEAD')), err) + return badRequest(err.message) + } + return logger(err) + } +}) + +route.GET('/eventsBefore/{before}/{limit}', async function (request: ServerRequest, h: Toolkit) { + try { + const { before, limit } = request.params + console.log('/eventsBefore:', before, limit) + if (!before) return badRequest('missing before') + if (!limit) return badRequest('missing limit') + if (isNaN(parseInt(limit)) || parseInt(limit) <= 0) return badRequest('invalid limit') + + const json = await sbp('backend/db/streamEntriesBefore', before, parseInt(limit)) + // Make sure to close the stream in case of disconnection. + // request.events.once('disconnect', stream.cancel.bind(stream)) + return h.response(json).type('application/json') + } catch (err) { + return logger(err) + } +}) + +route.GET('/eventsBetween/{startHash}/{endHash}', async function (request: ServerRequest, h: Toolkit) { + try { + const { startHash, endHash } = request.params + console.log('/eventsBetween:', startHash, endHash) + const offset = parseInt(request.searchParams.get('offset') || '0') + + if (!startHash) return badRequest('missing startHash') + if (!endHash) return badRequest('missing endHash') + if (isNaN(offset) || offset < 0) return badRequest('invalid offset') + + const json = await sbp('backend/db/streamEntriesBetween', startHash, endHash, offset) + // Make sure to close the stream in case of disconnection. + // request.events.once('disconnect', stream.cancel.bind(stream)) + return h.response(json).type('application/json') + } catch (err) { + return logger(err) + } +}) + +route.GET('/eventsSince/{contractID}/{since}', async function (request: ServerRequest, h: Toolkit) { + try { + const { contractID, since } = request.params + console.log('/eventsSince:', contractID, since) + const json = await sbp('backend/db/streamEntriesSince', contractID, since) + // Make sure to close the stream in case of disconnection. + // request.events.once('disconnect', stream.cancel.bind(stream)) + return h.response(json).type('application/json') + } catch (err) { + return logger(err) + } +}) + +route.POST('/name', async function (request: ServerRequest, h: Toolkit) { + try { + console.debug('/name', request.body) + const payload = await request.raw.json() + + const { name, value } = payload + return await sbp('backend/db/registerName', name, value) + } catch (err) { + return logger(err) + } +}) + +route.GET('/name/{name}', async function (request: ServerRequest, h: Toolkit) { + console.debug('GET /name/{name}', request.params.name) + try { + return await sbp('backend/db/lookupName', request.params.name) + } catch (err) { + return logger(err) + } +}) + +route.GET('/latestHash/{contractID}', async function (request: ServerRequest, h: Toolkit) { + try { + const { contractID } = request.params + const hash = await sbp('chelonia/db/latestHash', contractID) + console.debug(`[backend] latestHash for ${contractID}: `, hash) + request.response.header('cache-control', 'no-store') + if (!hash) { + console.warn(`[backend] latestHash not found for ${contractID}`) + return notFound() + } + return hash + } catch (err) { + return logger(err) + } +}) + +route.GET('/time', function (request: ServerRequest, h: Toolkit) { + request.response.header('cache-control', 'no-store') + return new Date().toISOString() +}) + +// file upload related + +// TODO: if the browser deletes our cache then not everyone +// has a complete copy of the data and can act as a +// new coordinating server... I don't like that. + +route.POST('/file', async function (request: ServerRequest, h: Toolkit) { + try { + console.log('FILE UPLOAD!') + + const formData = await request.raw.formData() + const data = formData.get('data') as File + const hash = formData.get('hash') + if (!data) return badRequest('missing data') + if (!hash) return badRequest('missing hash') + + const fileData: ArrayBuffer = await new Promise((resolve, reject) => { + const fileReader = new FileReader() + fileReader.onload = (event) => { + resolve(fileReader.result as ArrayBuffer) + } + fileReader.onerror = (event) => { + reject(fileReader.error) + } + fileReader.readAsArrayBuffer(data) + }) + const ourHash = blake32Hash(new Uint8Array(fileData)) + if (ourHash !== hash) { + console.error(`hash(${hash}) != ourHash(${ourHash})`) + return badRequest('bad hash!') + } + await sbp('backend/db/writeFileOnce', hash, fileData) + console.log('/file/' + hash) + return '/file/' + hash + } catch (err) { + return logger(err) + } +}) + +route.GET('/file/{hash}', async function handler (request: ServerRequest, h: Toolkit) { + try { + const { hash } = request.params + const base = pathlib.resolve('data') + console.debug(`GET /file/${hash}`) + console.debug(base) + // Reusing the given `hash` parameter to set the ETag should be faster than + // letting Hapi hash the file to compute an ETag itself. + return (await h.file(pathlib.join(base, hash))) + .header('content-type', 'application/octet-stream') + .header('cache-control', 'public,max-age=31536000,immutable') + .header('etag', `"${hash}"`) + .header('last-modified', new Date().toUTCString()) + } catch (err) { + return logger(err) + } +}) + +// SPA routes + +route.GET('/assets/{subpath*}', async function handler (request: ServerRequest, h: Toolkit) { + try { + const { subpath } = request.params + console.debug(`GET /assets/${subpath}`) + const basename = pathlib.basename(subpath) + const base = pathlib.resolve('dist/assets') + // In the build config we told our bundler to use the `[name]-[hash]-cached` template + // to name immutable assets. This is useful because `dist/assets/` currently includes + // a few files without hash in their name. + if (basename.includes('-cached')) { + return (await h.file(pathlib.join(base, subpath))) + .header('etag', basename) + .header('cache-control', 'public,max-age=31536000,immutable') + } + // Files like `main.js` or `main.css` should be revalidated before use. Se we use the default headers. + // This should also be suitable for serving unversioned fonts and images. + return h.file(pathlib.join(base, subpath)) + } catch (err) { + return logger(err) + } +}) + +route.GET('/app/{path*}', function (request: ServerRequest, h: Toolkit) { + return h.file(pathlib.resolve('./dist/index.html')) +}) + +route.GET('/', function (request: ServerRequest, h: Toolkit) { + return h.redirect('/app/') +}) diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index 67c6efd2a0..0000000000 --- a/backend/server.js +++ /dev/null @@ -1,106 +0,0 @@ -'use strict' - -import sbp from '@sbp/sbp' -import './database.js' -import Hapi from '@hapi/hapi' -import GiAuth from './auth.js' -import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { SERVER_RUNNING } from './events.js' -import { SERVER_INSTANCE, PUBSUB_INSTANCE } from './instance-keys.js' -import { createMessage, createNotification, createServer, NOTIFICATION_TYPE } from './pubsub.js' -import chalk from 'chalk' - -const Inert = require('@hapi/inert') - -// NOTE: migration guides for Hapi v16 -> v17: -// https://github.com/hapijs/hapi/issues/3658 -// https://medium.com/yld-engineering-blog/so-youre-thinking-about-updating-your-hapi-js-server-to-v17-b5732ab5bdb8 -// https://futurestud.io/tutorials/hapi-v17-upgrade-guide-your-move-to-async-await - -const hapi = new Hapi.Server({ - // TODO: improve logging and base it on process.env.NODE_ENV - // https://github.com/okTurtles/group-income/issues/32 - // debug: false, // <- Hapi v16 was outputing too many unnecessary debug statements - // // v17 doesn't seem to do this anymore so I've re-enabled the logging - debug: { log: ['error'], request: ['error'] }, - port: process.env.API_PORT, - // See: https://github.com/hapijs/discuss/issues/262#issuecomment-204616831 - routes: { - cors: { - // TODO: figure out if we can live with '*' or if we need to restrict it - origin: ['*'] - // origin: [ - // process.env.API_URL, - // // improve support for browsersync proxy - // ...(process.env.NODE_ENV === 'development' && ['http://localhost:3000']) - // ] - } - } -}) - -// See https://stackoverflow.com/questions/26213255/hapi-set-header-before-sending-response -hapi.ext({ - type: 'onPreResponse', - method: function (request, h) { - try { - // Hapi Boom error responses don't have `.header()`, - // but custom headers can be manually added using `.output.headers`. - // See https://hapi.dev/module/boom/api/. - if (typeof request.response.header === 'function') { - request.response.header('X-Frame-Options', 'deny') - } else { - request.response.output.headers['X-Frame-Options'] = 'deny' - } - } catch (err) { - console.warn(chalk.yellow('[backend] Could not set X-Frame-Options header:', err.message)) - } - return h.continue - } -}) - -sbp('okTurtles.data/set', SERVER_INSTANCE, hapi) - -sbp('sbp/selectors/register', { - 'backend/server/broadcastEntry': async function (entry: GIMessage) { - const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) - const pubsubMessage = createMessage(NOTIFICATION_TYPE.ENTRY, entry.serialize()) - const subscribers = pubsub.enumerateSubscribers(entry.contractID()) - console.log(chalk.blue.bold(`[pubsub] Broadcasting ${entry.description()}`)) - await pubsub.broadcast(pubsubMessage, { to: subscribers }) - }, - 'backend/server/handleEntry': async function (entry: GIMessage) { - await sbp('chelonia/db/addEntry', entry) - await sbp('backend/server/broadcastEntry', entry) - }, - 'backend/server/stop': function () { - return hapi.stop() - } -}) - -if (process.env.NODE_ENV === 'development' && !process.env.CI) { - hapi.events.on('response', (request, event, tags) => { - console.debug(chalk`{grey ${request.info.remoteAddress}: ${request.method.toUpperCase()} ${request.path} --> ${request.response.statusCode}}`) - }) -} - -sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { - serverHandlers: { - connection (socket: Object, request: Object) { - if (process.env.NODE_ENV === 'production') { - socket.send(createNotification(NOTIFICATION_TYPE.APP_VERSION, process.env.GI_VERSION)) - } - } - } -})) - -;(async function () { - // https://hapi.dev/tutorials/plugins - await hapi.register([ - { plugin: GiAuth }, - { plugin: Inert } - ]) - require('./routes.js') - await hapi.start() - console.log('Backend server running at:', hapi.info.uri) - sbp('okTurtles.events/emit', SERVER_RUNNING, hapi) -})() diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 0000000000..0880f59faf --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,107 @@ +import { blue, bold, gray } from 'fmt/colors.ts' + +import pogo from 'pogo' +import Toolkit from 'pogo/lib/toolkit.ts' +import sbp from '@sbp/sbp' +import './database.ts' +import { SERVER_RUNNING } from './events.ts' +import { SERVER_INSTANCE, PUBSUB_INSTANCE } from './instance-keys.ts' +import { + createMessage, + createNotification, + createServer, + isUpgradeableRequest, + NOTIFICATION_TYPE +} from '~/backend/pubsub.ts' +import { router } from './routes.ts' + +import { GIMessage } from '../shared/domains/chelonia/GIMessage.ts' + +import applyPortShift from '~/scripts/applyPortShift.ts' +import packageJSON from '~/package.json' assert { type: 'json' } +const { version } = packageJSON + +for (const [key, value] of Object.entries(applyPortShift(Deno.env.toObject()))) { + Deno.env.set(key as string, value as string) +} + +Deno.env.set('GI_VERSION', `${version}@${new Date().toISOString()}`) + +const API_HOSTNAME = Deno.env.get('API_HOSTNAME') ?? '127.0.0.1' +const API_PORT = Deno.env.get('API_PORT') ?? '8000' +const CI = Deno.env.get('CI') +const GI_VERSION = Deno.env.get('GI_VERSION') as string +const NODE_ENV = Deno.env.get('NODE_ENV') ?? 'development' + +console.info('GI_VERSION:', GI_VERSION) +console.info('NODE_ENV:', NODE_ENV) + +const pubsub = createServer({ + serverHandlers: { + connection (socket: WebSocket, request: Request) { + if (NODE_ENV === 'production') { + socket.send(createNotification(NOTIFICATION_TYPE.APP_VERSION, GI_VERSION)) + } + } + } +}) + +const pogoServer = pogo.server({ + hostname: API_HOSTNAME, + port: Number.parseInt(API_PORT), + onPreResponse (response: Response, h: Toolkit) { + try { + response.headers.set('X-Frame-Options', 'deny') + } catch (err) { + console.warn('could not set X-Frame-Options header:', err.message) + } + } +}) + +// Patch the Pogo server to add WebSocket support. +{ + const originalInject = pogoServer.inject.bind(pogoServer) + + pogoServer.inject = async (request: Request, connInfo: Deno.Conn) => { + if (isUpgradeableRequest(request)) { + return pubsub.handleUpgradeableRequest(request) + } else { + const response = await originalInject(request, connInfo) + // This logging code has to be put here instead of inside onPreResponse + // because it requires access to the request object. + if (NODE_ENV === 'development' && !CI) { + const { hostname } = connInfo.remoteAddr as Deno.NetAddr + console.debug(gray(`${hostname}: ${request.method} ${request.url} --> ${response.status}`)) + } + return response + } + } +} +pogoServer.router = router + +sbp('okTurtles.data/set', PUBSUB_INSTANCE, pubsub) +sbp('okTurtles.data/set', SERVER_INSTANCE, pogoServer) + +sbp('sbp/selectors/register', { + 'backend/server/broadcastEntry': async function (entry: GIMessage) { + const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + const pubsubMessage = createMessage(NOTIFICATION_TYPE.ENTRY, entry.serialize()) + const subscribers = pubsub.enumerateSubscribers(entry.contractID()) + console.log(blue(bold(`[pubsub] Broadcasting ${entry.description()}`))) + await pubsub.broadcast(pubsubMessage, { to: subscribers }) + }, + 'backend/server/handleEntry': async function (entry: GIMessage) { + await sbp('chelonia/db/addEntry', entry) + await sbp('backend/server/broadcastEntry', entry) + }, + 'backend/server/stop': function () { + return pogoServer.stop() + } +}) + +try { + pogoServer.start() + sbp('okTurtles.events/emit', SERVER_RUNNING, pogoServer) +} catch (err) { + console.error('error in server.start():', err.message) +} diff --git a/cypress.json b/cypress.json index 72442e3c69..2a5d24440b 100644 --- a/cypress.json +++ b/cypress.json @@ -2,7 +2,7 @@ "baseUrl": "http://localhost:8000", "viewportWidth": 1201, "viewportHeight": 900, - "defaultCommandTimeout": 10000, + "defaultCommandTimeout": 20000, "fixturesFolder": "test/cypress/fixtures", "integrationFolder": "test/cypress/integration", "pluginsFile": "test/cypress/plugins", diff --git a/deno.json b/deno.json new file mode 100644 index 0000000000..09a1c3a7be --- /dev/null +++ b/deno.json @@ -0,0 +1,8 @@ +{ + "importMap": "import_map.json", + "tasks": { + "esm:add": "deno run -A https://esm.sh/v102 add", + "esm:update": "deno run -A https://esm.sh/v102 update", + "esm:remove": "deno run -A https://esm.sh/v102 remove" + } +} \ No newline at end of file diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000000..a29e8a8241 --- /dev/null +++ b/deno.lock @@ -0,0 +1,1271 @@ +{ + "version": "2", + "remote": { + "https://cdn.skypack.dev/-/@multiformats/base-x@v4.0.1-HOf8EJHEdcomAjAZnm4W/dist=es2020,mode=imports,min/optimized/@multiformats/base-x.js": "2fc188a4ee0b731d2798f9ba57e04e835676ff102618af4d34f8634d6b5132f7", + "https://cdn.skypack.dev/-/@multiformats/base-x@v4.0.1-HOf8EJHEdcomAjAZnm4W/dist=es2020,mode=imports/optimized/@multiformats/base-x.js": "62dc4fb6e2bbe47b55b5604452de1acf57ec31da42f2591405c489008fa39b0f", + "https://cdn.skypack.dev/-/@sbp/okturtles.data@v0.1.5-v1al2GcKE5KSXFIzrqLd/dist=es2019,mode=imports/optimized/@sbp/okturtles.data.js": "4ac41f262aa68ee24b0752eee20562e90e55920138e50c44f6cc7deb8e9a18f2", + "https://cdn.skypack.dev/-/@sbp/okturtles.eventqueue@v1.0.0-Nw6aGxhMZ6xvvD4rwwc4/dist=es2019,mode=imports/optimized/@sbp/okturtles.eventqueue.js": "920e6270810ffac5f395aff4dcbadd931cf1276dbef11565e8b8b2aa3117b3eb", + "https://cdn.skypack.dev/-/@sbp/okturtles.events@v0.1.5-JdoW88CtL8Em9mFw92MD/dist=es2019,mode=imports/optimized/@sbp/okturtles.events.js": "6aac51ae449c843428b3d34977ca853574fe6f6accaa4ed9c1d3fd60b908d23a", + "https://cdn.skypack.dev/-/@sbp/sbp@v2.2.0-jl4mKvQfWhGheFnqgIZX/dist=es2019,mode=imports/optimized/@sbp/sbp.js": "8a05013daa37c77bdd107aa9c3ffa247bb07f3a3e0fdf09fa3f67c75b6c19481", + "https://cdn.skypack.dev/-/@sinonjs/fake-timers@v9.1.2-KzX0ompb8odSUvoOapks/dist=es2019,mode=types/index.d.ts": "f83b320cceccfc48457a818d18fc9a006ab18d0bdd727aa2c2e73dc1b4a45e98", + "https://cdn.skypack.dev/-/acorn@v4.0.13-UCaQMWtBfNgvLStcD5YJ/dist=es2020,mode=imports,min/optimized/acorn.js": "7fcd99e8cd06b54cf00f9e81a49870d7341671ac6f70fbe4eb848cc932cf9247", + "https://cdn.skypack.dev/-/acorn@v4.0.13-UCaQMWtBfNgvLStcD5YJ/dist=es2020,mode=imports,min/unoptimized/dist/walk.js": "85137b0c1934e6a49bb713551d6b49bb1d02f123f569aa0ed8b0592948e834e3", + "https://cdn.skypack.dev/-/babel-types@v6.26.0-Rb31KymXk8MDxaZH0GDd/dist=es2020,mode=imports,min/optimized/babel-types.js": "4ab890a8e0c4d3f69ada2b6fa0d105253c80f7e18615894cd5b384373a747a7b", + "https://cdn.skypack.dev/-/babylon@v6.18.0-yJrkG1tky51pQeRmdnnq/dist=es2020,mode=imports,min/optimized/babylon.js": "82c3ddbddfa0061ca37d9a04fad1364ef20fe3f0be522acf3722a80fa37fa1a9", + "https://cdn.skypack.dev/-/balanced-match@v1.0.0-GXb6RM1DwdiUmKeCaGls/dist=es2020,mode=imports,min/optimized/balanced-match.js": "060d567eeae451d43d8fbb37ce2d849f765ac696c88930d1ed92da8f9d62a2df", + "https://cdn.skypack.dev/-/base64-js@v1.5.1-W9sqY0mF5INkRg7HAvxn/dist=es2019,mode=imports/optimized/base64-js.js": "4566964bb37bf2a057d872b75540c57b181afddb4d1a3360e8f25e9f98259f50", + "https://cdn.skypack.dev/-/base64-js@v1.5.1-W9sqY0mF5INkRg7HAvxn/dist=es2020,mode=imports,min/optimized/base64-js.js": "080d1530e02b320c761b02796e155bfcea2f90d40216bc0d305151465f01e5d0", + "https://cdn.skypack.dev/-/blakejs@v1.2.1-x2cUecvDoYCQ5VOei7tK/dist=es2019,mode=imports/optimized/blakejs.js": "a9488d940d455d6712865a84fa11cc981ebfb0df58a61bdc203aa6d31dd0e2f6", + "https://cdn.skypack.dev/-/blakejs@v1.2.1-x2cUecvDoYCQ5VOei7tK/dist=es2020,mode=imports,min/optimized/blakejs.js": "fb157398642c125de686590375435a12994310c17811cb5964f84040506a550b", + "https://cdn.skypack.dev/-/brace-expansion@v1.1.11-iVYFQGfQ9GpBwybCnZ6b/dist=es2020,mode=imports,min/optimized/brace-expansion.js": "06eee1f55964c990c71c37bcaf49dee0c9eaba5a347417d5a8e9e831ce4e6028", + "https://cdn.skypack.dev/-/buffer@v6.0.3-9TXtXoOPyENPVOx2wqZk/dist=es2019,mode=imports/optimized/buffer.js": "5a22e1261b940a44afc1294308fd878c87d44af792552cb3042cbbb0217461dd", + "https://cdn.skypack.dev/-/buffer@v6.0.3-9TXtXoOPyENPVOx2wqZk/dist=es2020,mode=imports,min/optimized/buffer.js": "a7c1f1b687b254eb633daba3370e6b2e61ce808bcfbad8dda1b504a8b09912d1", + "https://cdn.skypack.dev/-/call-bind@v1.0.2-BEOaaQMxeoEQE7FpCDRq/dist=es2020,mode=imports,min/optimized/call-bind/callBound.js": "d6886be4dd314dfbb02c857894e707d311e1481dab4d071413f2da482a9a8d25", + "https://cdn.skypack.dev/-/call-bind@v1.0.2-BEOaaQMxeoEQE7FpCDRq/dist=es2020,mode=imports,min/optimized/common/index-46ba46fb.js": "d9757972b7e50f65d0d455f34d850edec43f4b991ba8461751e551ca8bbc7667", + "https://cdn.skypack.dev/-/character-parser@v2.2.0-qZhEplUnDXdUdJzCiH4x/dist=es2020,mode=imports,min/optimized/character-parser.js": "eada0e1c89333262800152b8da9af784e4e0bca6d34317ee103bed54834482b7", + "https://cdn.skypack.dev/-/concat-map@v0.0.1-dTVjRgm2JBSbtFR8kL9J/dist=es2020,mode=imports,min/optimized/concat-map.js": "b52db711bf245c80daed8fa3b215cd447773c8a231b2a5e677825df696f2679f", + "https://cdn.skypack.dev/-/constantinople@v3.1.2-yU0yDu39XnyR8cWcXkrr/dist=es2020,mode=imports,min/optimized/constantinople.js": "dcca6a310d9e126d5d9f7869f84985b0f5c1f0ec6af68fd0d9db1c5e6b52f860", + "https://cdn.skypack.dev/-/dompurify@v2.4.0-v17nByMVzL2lE2lRHgyo/dist=es2019,mode=imports/optimized/dompurify.js": "20a6a9ffc69e4c1090c567c5d7f266f15ae3f019c678783a8b19ee258a388eca", + "https://cdn.skypack.dev/-/dompurify@v2.4.0-v17nByMVzL2lE2lRHgyo/dist=es2020,mode=imports,min/optimized/dompurify.js": "3d98a7680e594ce35b236eb32a138995010a649e2e347ce02801fd63620f6f91", + "https://cdn.skypack.dev/-/esutils@v2.0.3-FLGVALxsiAyytI4JV227/dist=es2020,mode=imports,min/optimized/esutils.js": "f623a9988fe4f501bf04840b04c2569bc6a96f33eaa69c317cbc3eab4cfa6349", + "https://cdn.skypack.dev/-/find-line-column@v0.5.2-ACJmxdmpIsiJga0atDEQ/dist=es2020,mode=imports,min/optimized/find-line-column.js": "e24ee8183c78435ec69e1646cb8b8a4d7086da662dc0aeea8d0bbc33eeabd65b", + "https://cdn.skypack.dev/-/fs.realpath@v1.0.0-VGzhwXUtu6fjhX6Bde6K/dist=es2020,mode=imports,min/optimized/fs.realpath.js": "549f9ce551e07c064abfb90112a2b7bb115128d45d59c9ea77b5f64662244251", + "https://cdn.skypack.dev/-/function-bind@v1.1.1-I2U4xSizU1p8sDZSqt3X/dist=es2020,mode=imports,min/optimized/function-bind.js": "764ee60e703fb99b21e5390ca3429808705d7d17bec053af0422fe52a0b66e69", + "https://cdn.skypack.dev/-/get-intrinsic@v1.1.1-RY1bP62jmAAOk5PnPF2o/dist=es2020,mode=imports,min/optimized/get-intrinsic.js": "7f4e23f80e182b40becac6344bd28d29ace930ba4d6bf52e4fae3b8a768b6163", + "https://cdn.skypack.dev/-/glob@v7.1.7-opQEBTsY2SmyDnLAz28j/dist=es2020,mode=imports,min/optimized/glob.js": "1754a0d110029b8bf37c331cc5345222b87db538a782fb310df893f36ae824b4", + "https://cdn.skypack.dev/-/has-symbols@v1.0.2-eL6Wz1oCLdrkXpiz9WGA/dist=es2020,mode=imports,min/optimized/has-symbols.js": "0367aa1ebfa552a8750d54a3686e15798342ca8cfb3e8f7992025f11000bf789", + "https://cdn.skypack.dev/-/has@v1.0.3-Od4aggPz2zCm2qUbOWra/dist=es2020,mode=imports,min/optimized/has.js": "2625eafa43ac411afafab9bfdcaa1eddcf4350e23f7e713d704e621dda3647ab", + "https://cdn.skypack.dev/-/ieee754@v1.2.1-wxdRuKvQQOTpW1dpWzFI/dist=es2019,mode=imports/optimized/ieee754.js": "d2f73784b95c354f399103302c65c5d0fe52bc869379d322a0b2e1185d2ad3f8", + "https://cdn.skypack.dev/-/ieee754@v1.2.1-wxdRuKvQQOTpW1dpWzFI/dist=es2020,mode=imports,min/optimized/ieee754.js": "762bd8faf33fa43c36d97808058cb1220476ef0efe5cdf964487385424ad5389", + "https://cdn.skypack.dev/-/inflight@v1.0.6-c14aVNr8f0cAffLbBpDz/dist=es2020,mode=imports,min/optimized/inflight.js": "27f61dbe566e218192c3d3c0192a3498bc51a1ddd68222da40144b61a3297217", + "https://cdn.skypack.dev/-/inherits@v2.0.4-KMaJYACWZTuZGLpDmPB5/dist=es2020,mode=imports,min/optimized/inherits.js": "e9be3aaf2078682d6d8ff0044c616f1cc0aa546a86ec715938d916b6d4e69c7c", + "https://cdn.skypack.dev/-/is-core-module@v2.8.1-pFsYXAIRTCUS7uFCjJh3/dist=es2020,mode=imports,min/optimized/is-core-module.js": "e68f5e9e18c6e137360d960dd5cd4475ff97b310e69edb76e0bea9d062e7a9a8", + "https://cdn.skypack.dev/-/is-expression@v3.0.0-KGG5L9WwZaj3D3JNiO6Y/dist=es2020,mode=imports,min/optimized/is-expression.js": "ddc9f378e8cb797bc228b5dda4675a59e1021c7925a67d849f21ee1a550de59e", + "https://cdn.skypack.dev/-/is-regex@v1.1.2-EZzfL0bbHgcTTDidAgQH/dist=es2020,mode=imports,min/optimized/is-regex.js": "13dc72c2071708d3b629b12cdc65cb19b5753df61c2b45fc4321d11c22a6b80f", + "https://cdn.skypack.dev/-/js-stringify@v1.0.2-11tU6c0frpn7dXFFDPOm/dist=es2020,mode=imports,min/optimized/js-stringify.js": "957e4f6984adcb4684021634ab92613a7e5a6e670f0279ae88cd946f254094d9", + "https://cdn.skypack.dev/-/lru-cache@v7.14.0-2D6bOfAhBDZrjELxYska/dist=es2019,mode=imports/optimized/lru-cache.js": "9ca5924a85011d34eaca228eaac56bb71444e391cddd599d7c103d0ea6e936dc", + "https://cdn.skypack.dev/-/lru-cache@v7.14.0-2D6bOfAhBDZrjELxYska/dist=es2020,mode=imports,min/optimized/lru-cache.js": "7f2bff99bc17e35d2c8f90888218a1df6f6b90eb452013ef0c918d38f03ac64e", + "https://cdn.skypack.dev/-/minimatch@v3.0.4-Ab73EJBz424PqgmskEfA/dist=es2020,mode=imports,min/optimized/minimatch.js": "2e102d37263905945ed308950e9131f8777f471025e0796c02570415637cf6c8", + "https://cdn.skypack.dev/-/multibase@v4.0.6-MsXe8MYddDTp578OLQg1/dist=es2020,mode=imports,min/optimized/multibase.js": "d9992ab9abfd5fda4defa254c81044c04709578b92649750fefa7be30fc5bccf", + "https://cdn.skypack.dev/-/multibase@v4.0.6-MsXe8MYddDTp578OLQg1/dist=es2020,mode=imports/optimized/multibase.js": "79e06d9bf43012886bfae40a824de9a685867a0f18b3dedee9dd931ed581ea9b", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base-02e849e3.js": "1a3d14ddfd52f3021287097d95d3d0a7dd9662f6ef6db6c8a9f017d45553de2f", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base10-77081692.js": "77fb73810064f941e83d6e753ae38ede5250cf8b39fb21a0ce7633f86f2326a9", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base16-52d41797.js": "5fe214e015fbbd6b1ebd21b7bc2c0d400f1cfff3690efcb20354655d12d3558f", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base2-27303b24.js": "0fd82cb27ca2491ddedd35874235397a922d727d2b38234d223a2b26fc02a1b6", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base32-e2c0c468.js": "1851b3ed5e093ea64a26dd4ec64dc47a2c309ef1b50ac409b7a0163c1f9410b9", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base36-1e69c38b.js": "be635fc699a136d5c50c1ba87e1f2337adc9e24f5243fa7dc9c09b7ada44c930", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base58-43b8b5d5.js": "6ba6a6f3c304c1dccd811fb4721c7475dec8697522fc3bd5d63b973da7a9dd6f", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base64-5f71d826.js": "d8ad4e1eb36e44cc865ba3c7897332ceb6e2316ec3862f58bdead62ed672a8a6", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/base8-e31a600e.js": "59b2ff812b8fc7bf5dfed5bf63e0d52f86b403ead9eac62433d5bc9649443ca6", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/bytes-a418276b.js": "49e560a6687bce9437f5ccbc4657b9057c3819ebbddb88c5d9bbf20b50c0a442", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/digest-09afbc83.js": "dbc6d8107725b1ece5fef957f90ab1357204bbacfbd8c5b6d4c9dccd93dc2954", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/hasher-9ad42423.js": "a133f47918312591dbf9d32a18fbd4bdb6bf7945ae4720c9935345be316c8cab", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/identity-15afdff5.js": "f3efc25365d0e0c831bf0ce76f90002f418357d1258544a09536b641dcad515f", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/identity-b8ddc54b.js": "d6a42778b311e66a79039f0df14199aee84af6a9446fc467d3f3a10e5f660f2e", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/json-e770afa4.js": "681e74359f146ae60c1d87bf4422e8c9d49fbd20138a54603d4bb4eb45315ae3", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/raw-7df3098b.js": "e5cfd58d59281d3b028efd9d9a6cd0d0bd095eea6568ef593e41cd54702584cb", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/common/sha2-browser-099b45d1.js": "19489b0c980dcce1c3f9c7640598c244424b232e658b8aa81761e3136a645594", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/multiformats/basics.js": "e30da1390ad247ee4608f62cc167c014da31762eb7420b1c46e98e433439b662", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports,min/optimized/multiformats/cid.js": "dced56789859a4a41bece9fc721b9e783bc83b2fd6259f70121cce6c7deb047a", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base-02e849e3.js": "6e1afe5d3379f7bc29ae18bdc1d3f1121586f666abf06f026e1685c8ca4714a9", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base10-77081692.js": "8b215201e4777cf80da3db933929afd569172669ee6cd13afea653ddda2ff56c", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base16-52d41797.js": "06c1f6418a58e0e3f0c520de25ebff5f29d36b02e039f7caa4a8103933c0e3f3", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base2-27303b24.js": "b4d16fafedc3fb85f65450e4e9b019447b3323e5748a0d6fc2d44c2f6c6163fe", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base32-e2c0c468.js": "3aa65e16f8e7937a7c6417bfa47b96814eacdba61861c0b0d95b469d670fd707", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base36-1e69c38b.js": "8bd83d9d5d1dd1993daec0724d84653f131038a7d4584cdcbf63ef4c8116d1db", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base58-43b8b5d5.js": "38fde8a09b64fd1be95978b21b19f8ff9938ab89b80ff57bfff0b1c224348976", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base64-5f71d826.js": "587c586be497e2769e303c8543662416cdf08ebdaefd13dcd176e99ebf351610", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/base8-e31a600e.js": "d1a83889f007ea0145930500a2e4cb01d7f318afa04c57984ea610246c425743", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/bytes-a418276b.js": "f692d7ee0374ce1f414d7d3d94f8c76d83ac3affdf91c240559272dfd516b8fc", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/digest-09afbc83.js": "f2ee8884b3e58a76b837112278e0bc6f740b1fccc61bc037fbeaa486511b430a", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/hasher-9ad42423.js": "53a21044418b13ace73951de0b12e6247547672ef71ea51e02a0a3101c1343e9", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/identity-15afdff5.js": "71ac3c0d437948584e483c8989fe64df575279fe017bd839b58805a37cc966f8", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/identity-b8ddc54b.js": "187bed001a83bbaeba806b7480fd662941854a060ed4901cd3b58d68d10d20b2", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/json-e770afa4.js": "7e623d15683a2c724f22eda0a96d87a084b7cba8245789f46b2da693f2def2d8", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/raw-7df3098b.js": "d9d17189d8804e8ce5d1e142ffcc6ff0b70875c0e86d2f3455a95f68c0f606e0", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/common/sha2-browser-099b45d1.js": "94bd6ac26feecf16c2a4ab176be1f93644837e23f15247a69b05126ae9912503", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/multiformats/basics.js": "33979f5193ac84c0ea23cfa0c37fe18b7e6c844c8c38a31c7168d4e7246c31e1", + "https://cdn.skypack.dev/-/multiformats@v9.6.1-n10NsvauAj3Pzpyn1iiC/dist=es2020,mode=imports/optimized/multiformats/cid.js": "b4e78c3304e6f75828b9b12c66928fe448b6e757215e874770b8c9d810747d2e", + "https://cdn.skypack.dev/-/multihashes@v4.0.3-umrnytB1mrkdVTIXtTSm/dist=es2020,mode=imports,min/optimized/multihashes.js": "22b554d886e827ce41ca54e12c73b73b30dfeb511f7c5655c105ae6220b2b516", + "https://cdn.skypack.dev/-/multihashes@v4.0.3-umrnytB1mrkdVTIXtTSm/dist=es2020,mode=imports/optimized/multihashes.js": "de2be9ef040f75fdc5c48ddc43e48e11d2eb6d96c554e7bd3a95d9af7227c2c5", + "https://cdn.skypack.dev/-/object-assign@v4.1.1-LbCnB3r2y2yFmhmiCfPn/dist=es2020,mode=imports,min/optimized/object-assign.js": "c7ddc3ebf4fc4ec63fb91f05743204b593f1e603709efe6cea5441df81510729", + "https://cdn.skypack.dev/-/once@v1.4.0-dZva3nt1fLBY6vpXF5Hj/dist=es2020,mode=imports,min/optimized/once.js": "fc48ff2422efb76ee6d76d72cc8c46d8487af5f214f2f68fa2b5d55e614893b4", + "https://cdn.skypack.dev/-/path-is-absolute@v1.0.1-2fI8aAhPmekqaMLne7oF/dist=es2020,mode=imports,min/optimized/path-is-absolute.js": "18ce1f7ee98c2b0e688abe6a92fc09ab68ea2e819396c42376263ac269167fe5", + "https://cdn.skypack.dev/-/path-parse@v1.0.7-0HfA9jxso2qpPn64IpUu/dist=es2020,mode=imports,min/optimized/path-parse.js": "0004f4ea76f7fa5577031a7e2850e30a2ec92015be9e150ebaeb7dcc838e06de", + "https://cdn.skypack.dev/-/pug-attrs@v2.0.4-nksgPjXhh15g1ES9tBkD/dist=es2020,mode=imports,min/optimized/pug-attrs.js": "8a11c8c408c58de12961836661b0c5c382bbd3cc7442c2efc2b4e2a9ead6737c", + "https://cdn.skypack.dev/-/pug-error@v1.3.3-ZoWexKcCluGSOKJOOgqz/dist=es2020,mode=imports,min/optimized/pug-error.js": "e2260fcfbbd6781ee9bf777b631bc1150441a5f1f07abe3763620b66bf4ca26d", + "https://cdn.skypack.dev/-/pug-lexer@v4.1.0-A3d1HabmzQ9Axuu1R2BF/dist=es2020,mode=imports,min/optimized/pug-lexer.js": "b710b5cfd7cf7149cc1e6c41be9afce90564946d09f0e0779e7dcaed8d0bf6e2", + "https://cdn.skypack.dev/-/pug-lint@v2.6.0-XZxyaZ0gPkoOj1sUprv2/dist=es2020,mode=imports,min/optimized/pug-lint.js": "fa861657d17505e1bddfadf5f9a55c7a9c26a7996e93b7aa6879ce45dd138e2c", + "https://cdn.skypack.dev/-/pug-runtime@v2.0.5-yEyikDYdirNhDFSwPRhF/dist=es2020,mode=imports,min/optimized/pug-runtime.js": "bd6b194bd4db4160b6744a13c081fd48acc396fc31d04cf4da5ef83067d25d9a", + "https://cdn.skypack.dev/-/resolve@v1.20.0-DqKDjgDuNRSZFMXpMDnz/dist=es2020,mode=imports,min/optimized/resolve.js": "70dff3f3e2a4fe2ed044532bb59adce6344d69a51a6854c806b6ad4b008c1fcb", + "https://cdn.skypack.dev/-/sinon@v14.0.1-R4Dmc6jwDhTlpW3nZ87W/dist=es2019,mode=imports/optimized/sinon.js": "1588952d6d326c66bb21c29a8ee10c6ec458c5d09442533af094a6218bd1528c", + "https://cdn.skypack.dev/-/sinon@v14.0.1-R4Dmc6jwDhTlpW3nZ87W/dist=es2019,mode=types/index.d.ts": "0f2e3eac43c7fe1f475144a590bfc0a7e589f478b09e399a1a1cd36c97de38d1", + "https://cdn.skypack.dev/-/strip-json-comments@v2.0.1-KTkstFahhNrg0gtWpyN8/dist=es2020,mode=imports,min/optimized/strip-json-comments.js": "37905ef3202475d691ce6fa827259bf241ddbff75dfb961389fc39ce9b43da93", + "https://cdn.skypack.dev/-/to-fast-properties@v1.0.3-ety39NWrbdggjHO8xQ3y/dist=es2020,mode=imports,min/optimized/to-fast-properties.js": "5b86892e265637541601399b1d193d2ba7a2981a4f272717c58e8260ae0c64e3", + "https://cdn.skypack.dev/-/tweetnacl@v1.0.3-G4yM3nQ8lnXXlGGQADqJ/dist=es2019,mode=imports/optimized/tweetnacl.js": "d26554516df57e5cb58954e90c633c8871b4e66016b9fe4e07a36db5430bc8c7", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports,min/optimized/common/bases-af280048.js": "7d727cad349547fc0b196e79b3c462bb4e3b78c27d2a7498c5935e1add795511", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports,min/optimized/uint8arrays/concat.js": "47ec29fb928c5edff21eee563bef0372c313c20dd510fe8b42ec670c1581321a", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports,min/optimized/uint8arrays/from-string.js": "4173201ff20ad6c58118f967d4f3f6d7f11d0aee6bfa1ade7629d223178b6510", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports,min/optimized/uint8arrays/to-string.js": "98e6e4879198f3676891c0fc8e70cbf37b268ebe88f066cb02bf37cdd96f2b29", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports/optimized/common/bases-af280048.js": "06b5f1197f64e24e5081bb7946782b081779d51fc0ff6e3ad9a43a4933fc5484", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports/optimized/uint8arrays/concat.js": "660ee6bc1487b079bcb728409f5fe947bba81470e1eddf1bed58d6501740a8dc", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports/optimized/uint8arrays/from-string.js": "de7dc5c2cf17c206f4745cf6c6f3640f11751a46ca12c7f4da069aa79076b3e7", + "https://cdn.skypack.dev/-/uint8arrays@v3.0.0-9yQnXlZCiqboVhYLdGtU/dist=es2020,mode=imports/optimized/uint8arrays/to-string.js": "388602abed92b78c61d151f40e116b9cdeb12ef8907091c4fb2151a9589abd68", + "https://cdn.skypack.dev/-/varint@v5.0.2-HaXdTQq1mLP1UBycs0FV/dist=es2020,mode=imports,min/optimized/varint.js": "e0d8b4e26111c2f20f4876afc316e59cfb9d6046d2c4b07280c9760dac760493", + "https://cdn.skypack.dev/-/varint@v5.0.2-HaXdTQq1mLP1UBycs0FV/dist=es2020,mode=imports/optimized/varint.js": "cfbfaf7702658578581541b449c8b89a000159254d3d1137b2a2bdeb6e0bfdf6", + "https://cdn.skypack.dev/-/wrappy@v1.0.2-e8nLh7Qms0NRhbAbUpJP/dist=es2020,mode=imports,min/optimized/wrappy.js": "07565015c9a857364b38f947dcba1149f8f89c995d4826394721760bedf3375a", + "https://cdn.skypack.dev/@sbp/okturtles.data": "8baacdb1bf46163d106c22bdffd173ebccc6dfd13be5f3abc866acaaf0f20f88", + "https://cdn.skypack.dev/@sbp/okturtles.eventqueue": "3962f97919356fdeeaa80445d768078d9d99be15ddd73d4d8e865d9ff13be8a4", + "https://cdn.skypack.dev/@sbp/okturtles.events": "417eb7bd0c65e925ffd5e0dcbc1e022f11bd593e9d998f26f48d3c89b9bbc542", + "https://cdn.skypack.dev/@sbp/sbp": "3ece521dca5c8e747f43f1a33fec7a47fc4b11f369dcb5cb5617f31a6322caa4", + "https://cdn.skypack.dev/blakejs@1.2.1": "77a1074ab1e5b1daac6d8340b598fe30d2e544cd0eaa55b795cbb904265747f2", + "https://cdn.skypack.dev/buffer@6.0.3": "b71c2b55bdd772c372bd8d089e843057fd89ae0c49eb31ed173171d52c54a7c2", + "https://cdn.skypack.dev/dompurify@2.4.0": "b18dad748eab188808e3b44691d28c8229af07c3ca215e992f3ee05eece1d0cb", + "https://cdn.skypack.dev/error/node:fs?from=fs.realpath": "7c72cfd66ae494f30369f60bc4dcfcf2742c17e325ed02ff643f8c0133ad4e6f", + "https://cdn.skypack.dev/error/node:fs?from=glob": "0bc161618d3195788ccefa33adf80f9ee8ace230bced8a98dbb73a6c126e80c9", + "https://cdn.skypack.dev/error/node:fs?from=pug-lint": "18a6539e68bc0411c8dc6c1e97d902ed13bcd00908938198826a981c2cabdab7", + "https://cdn.skypack.dev/error/node:fs?from=resolve": "96d955307264cb65f57112203f291235e2085c4c58bd574ffcce07292a4c816d", + "https://cdn.skypack.dev/error/node:node:fs?from=glob": "4073e07ababe46a3d93b86af253ba5a6e10b5b297d37dbe6f69f1cc5cf00ae11", + "https://cdn.skypack.dev/error/node:node:fs?from=pug-lint": "bce9fa10a6f301076f4d8f5edd89cc9e6cd477a86bb0b8d78ad493bc79579c21", + "https://cdn.skypack.dev/lru-cache@7.14.0": "316be73fb40a6f0df4600c8a0fa11b5a9c9cfdfffeb0cd1fccb4eca45b574161", + "https://cdn.skypack.dev/pin/blakejs@v1.2.1-x2cUecvDoYCQ5VOei7tK/mode=imports,min/optimized/blakejs.js": "f9d8c65104893c9fff112991c55c92499b59045b0690b93296383765388332d4", + "https://cdn.skypack.dev/pin/buffer@v6.0.3-9TXtXoOPyENPVOx2wqZk/mode=imports,min/optimized/buffer.js": "a373418ddf104978f75b7cf4e64284b9a17395f23065746855a6283a8b9689e7", + "https://cdn.skypack.dev/pin/dompurify@v2.4.0-v17nByMVzL2lE2lRHgyo/mode=imports,min/optimized/dompurify.js": "758f42f16d87d0e6318f01bc94a12446a4fda5eb98652ad6952e0a123ccdb119", + "https://cdn.skypack.dev/pin/lru-cache@v7.14.0-2D6bOfAhBDZrjELxYska/mode=imports,min/optimized/lru-cache.js": "1ba4d957a6ccf9dda5dd910d2cad5ffbb753146776d2e47514794372eb947368", + "https://cdn.skypack.dev/pin/multihashes@v4.0.3-umrnytB1mrkdVTIXtTSm/mode=imports,min/optimized/multihashes.js": "2dadfd2c69e87d53576fd5a3760d59201fe625fc4d83d78ee955df21d4ba10f3", + "https://cdn.skypack.dev/pin/multihashes@v4.0.3-umrnytB1mrkdVTIXtTSm/mode=imports/optimized/multihashes.js": "d31e2260e5a4bbe4eed84fe830970b7b61f16850c3d0d6954cbd318ecc2d06d6", + "https://cdn.skypack.dev/pin/pug-lint@v2.6.0-XZxyaZ0gPkoOj1sUprv2/mode=imports,min/optimized/pug-lint.js": "4767b2a38ab89e27b85d79eac58fbd16f817251cfbf1771ea4e574b32fd6a566", + "https://cdn.skypack.dev/sinon@14.0.1?dts": "4fb5201e70d4a4333fb18773921b55d1d2839fc291d39f43d0e24e982b85cfc3", + "https://cdn.skypack.dev/tweetnacl@1.0.3": "6610aad2ac175c2d575995fc7de8ed552c2e5e05aef80ed8588cf3c6e2db61d7", + "https://deno.land/std@0.153.0/_deno_unstable.ts": "4ddb8672d49d58b5bbc4a5a7a2f1b3bce4fd06aa4c8b8476728334391667de7b", + "https://deno.land/std@0.153.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.153.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", + "https://deno.land/std@0.153.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06", + "https://deno.land/std@0.153.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0", + "https://deno.land/std@0.153.0/async/debounce.ts": "de5433bff08a2bb61416fc53b3bd2d5867090c8a815465e5b4a10a77495b1051", + "https://deno.land/std@0.153.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae", + "https://deno.land/std@0.153.0/async/delay.ts": "d5a169caede8e1c5d46b3f25eab97db5fd1ab193fc82a53bafd3642ac42ca3c7", + "https://deno.land/std@0.153.0/async/mod.ts": "dd0a8ed4f3984ffabe2fcca7c9f466b7932d57b1864ffee148a5d5388316db6b", + "https://deno.land/std@0.153.0/async/mux_async_iterator.ts": "3447b28a2a582224a3d4d3596bccbba6e85040da3b97ed64012f7decce98d093", + "https://deno.land/std@0.153.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239", + "https://deno.land/std@0.153.0/async/tee.ts": "d27680d911816fcb3d231e16d690e7588079e66a9b2e5ce8cc354db94fdce95f", + "https://deno.land/std@0.153.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4", + "https://deno.land/std@0.153.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a", + "https://deno.land/std@0.153.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", + "https://deno.land/std@0.153.0/crypto/timing_safe_equal.ts": "82a29b737bc8932d75d7a20c404136089d5d23629e94ba14efa98a8cc066c73e", + "https://deno.land/std@0.153.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2", + "https://deno.land/std@0.153.0/encoding/base64url.ts": "a5f82a9fa703bd85a5eb8e7c1296bc6529e601ebd9642cc2b5eaa6b38fa9e05a", + "https://deno.land/std@0.153.0/flags/mod.ts": "d920e6675c31d5ef7efcb2087958923caf252e28fee724d7f7444c6b7b235204", + "https://deno.land/std@0.153.0/fmt/colors.ts": "ff7dc9c9f33a72bd48bc24b21bbc1b4545d8494a431f17894dbc5fe92a938fc4", + "https://deno.land/std@0.153.0/fmt/printf.ts": "111a4df40a81799da778421ae0260f278e5d79877e30e92403cd57d197a8d1b0", + "https://deno.land/std@0.153.0/fs/eol.ts": "b92f0b88036de507e7e6fbedbe8f666835ea9dcbf5ac85917fa1fadc919f83a5", + "https://deno.land/std@0.153.0/fs/exists.ts": "cb734d872f8554ea40b8bff77ad33d4143c1187eac621a55bf37781a43c56f6d", + "https://deno.land/std@0.153.0/io/buffer.ts": "8232dcd8c4c7f14b95f28444454ad63750267be23e0fd7f88320c68b0f87a3b5", + "https://deno.land/std@0.153.0/io/types.d.ts": "0cae3a62da7a37043661746c65c021058bae020b54e50c0e774916e5d4baee43", + "https://deno.land/std@0.153.0/node/_buffer.d.ts": "90f674081428a61978b6d481c5f557ff743a3f4a85d7ae113caab48fdf5b8a63", + "https://deno.land/std@0.153.0/node/_buffer.mjs": "4e3e6b0f0613300340705a99a1998c655ba22bf08644c49df21f4aaa44e951d0", + "https://deno.land/std@0.153.0/node/_core.ts": "83860ad91022fe1ed759acc3f012dd7e2a4258bd4ee7eab8332110cba0d0be10", + "https://deno.land/std@0.153.0/node/_events.d.ts": "3899ee9c37055fbb750e32cb43d7c435077c04446af948300080e1a590c6edf0", + "https://deno.land/std@0.153.0/node/_events.mjs": "303e8aa60ace559e4ca0d19e8475f87311bee9e8330b4b497644d70f2002fc27", + "https://deno.land/std@0.153.0/node/_fs/_fs_access.ts": "f8c629c99777c46a780db3acdf8132ba82decf365085a503263e46ec08c40901", + "https://deno.land/std@0.153.0/node/_fs/_fs_appendFile.ts": "f9d83bce0d3eae04246916da5b048313c24d88dfaf063f779c3434f0399d9042", + "https://deno.land/std@0.153.0/node/_fs/_fs_chmod.ts": "c1a65080458afbeba8ad0824664f824b167c39ee02ef6b0d04bcbc123a3f897e", + "https://deno.land/std@0.153.0/node/_fs/_fs_chown.ts": "e072f87628cfea956e945787f0e8af47be325b2e3183ae095279472b1fb9d085", + "https://deno.land/std@0.153.0/node/_fs/_fs_close.ts": "b23915f763addce9d96116c1e3179d9d33c596903f57e20afa46c4333072a460", + "https://deno.land/std@0.153.0/node/_fs/_fs_common.ts": "6a373d1583d9ec5cc7a8ff1072d77dc999e35282a320b7477038a2b209c304d3", + "https://deno.land/std@0.153.0/node/_fs/_fs_constants.ts": "5c20b190fc6b7cfdaf12a30ba545fc787db2c7bbe87ed5b890da99578116a339", + "https://deno.land/std@0.153.0/node/_fs/_fs_copy.ts": "692b9235a9c267e4c2604b4a228da5ef1c0ad0977ed086187fc86bc978c82913", + "https://deno.land/std@0.153.0/node/_fs/_fs_dir.ts": "0e63c76b756c370465521114428f759ef6e528b7f90102f7c380953eb9e2f23e", + "https://deno.land/std@0.153.0/node/_fs/_fs_dirent.ts": "649c0a794e7b8d930cdd7e6a168b404aa0448bf784e0cfbe1bd6d25b99052273", + "https://deno.land/std@0.153.0/node/_fs/_fs_exists.ts": "87b063b7b1a59b5d2302ba2de2204fbccc1bfbe7fafede8678694cae45b77682", + "https://deno.land/std@0.153.0/node/_fs/_fs_fdatasync.ts": "bbd078fea6c62c64d898101d697aefbfbb722797a75e328a82c2a4f2e7eb963d", + "https://deno.land/std@0.153.0/node/_fs/_fs_fstat.ts": "559ff6ff094337db37b0f3108aeaecf42672795af45b206adbd90105afebf9c6", + "https://deno.land/std@0.153.0/node/_fs/_fs_fsync.ts": "590be69ce5363dd4f8867f244cfabe8df89d40f86bbbe44fd00d69411d0b798e", + "https://deno.land/std@0.153.0/node/_fs/_fs_ftruncate.ts": "8eb2a9fcf026bd9b85dc07a22bc452c48db4be05ab83f5f2b6a0549e15c1f75f", + "https://deno.land/std@0.153.0/node/_fs/_fs_futimes.ts": "a3aff00e57144fd60bb82e3208bca53915173223d95f4172bd60a940e11ca258", + "https://deno.land/std@0.153.0/node/_fs/_fs_link.ts": "f7c60f989a60becd6cdc1c553122be34f7c2ed83a900448757982683cebc0ffd", + "https://deno.land/std@0.153.0/node/_fs/_fs_lstat.ts": "c26a406ccdbc95dd7dab75aca0019b45b41edc07bebd40d9de183780d647a064", + "https://deno.land/std@0.153.0/node/_fs/_fs_mkdir.ts": "0949fcfbca0fe505c286140cd9fc82dcc0e7d95b6eead1adb14292179750def6", + "https://deno.land/std@0.153.0/node/_fs/_fs_mkdtemp.ts": "280ecc74cc08e6f5425bcae0f719ddcd7f4bc2b0f292fed6bec88a31e698b5aa", + "https://deno.land/std@0.153.0/node/_fs/_fs_open.ts": "092af7646bc2e915dfa529906c2f0d0f9a014f66df8423885f0956708ec0e431", + "https://deno.land/std@0.153.0/node/_fs/_fs_read.ts": "cb3efc238117b39d3f79aeaa94aad28442d5bfd810b45ad0906fa406844516d4", + "https://deno.land/std@0.153.0/node/_fs/_fs_readFile.ts": "7c42f8cb4bad2e37a53314de7831c0735bae010712fd914471850caa4d322ffd", + "https://deno.land/std@0.153.0/node/_fs/_fs_readdir.ts": "8d186b470aea8411c794687b20effaf1f478abb59956c67b671691d9444b7786", + "https://deno.land/std@0.153.0/node/_fs/_fs_readlink.ts": "a5582656af6f09361ecb408ed3c0ad3cc3afd683403539e4c22aa06deab90fc0", + "https://deno.land/std@0.153.0/node/_fs/_fs_realpath.ts": "0bf961c7b13d83e39b21255237b7ef352beb778a8274d03f2419907a8cd5c09c", + "https://deno.land/std@0.153.0/node/_fs/_fs_rename.ts": "9aa3cf6643499a38ccbfb14435239c7fc0da6b4cb5b5ab1c9e676d42daf27b71", + "https://deno.land/std@0.153.0/node/_fs/_fs_rm.ts": "82e926fe3e11e2a7f56116d3b7005372c339010cc1a7566a37a5591d19d680c6", + "https://deno.land/std@0.153.0/node/_fs/_fs_rmdir.ts": "109fb603373cf5318f8d2e19b3704b8493b4de954873df28a0d066afd0b0f5e0", + "https://deno.land/std@0.153.0/node/_fs/_fs_stat.ts": "4ccc93cd1938e5dcc5298feb9c6f0bc9f444fa8565c726854ea40210b93d254c", + "https://deno.land/std@0.153.0/node/_fs/_fs_streams.ts": "b06c3c441ac73b82ec787807e5ab5f5b8b39de84f823954f223fe04cb5ee0271", + "https://deno.land/std@0.153.0/node/_fs/_fs_symlink.ts": "a9fe02e745a8ab28e152f37e316cb204382f86ebafc3bcf32a9374cf9d369181", + "https://deno.land/std@0.153.0/node/_fs/_fs_truncate.ts": "1fe9cba3a54132426927639c024a7a354455e5a13b3b3143ad1c25ed0b5fc288", + "https://deno.land/std@0.153.0/node/_fs/_fs_unlink.ts": "d845c8067a2ba55c443e04d2706e6a4e53735488b30fc317418c9f75127913b0", + "https://deno.land/std@0.153.0/node/_fs/_fs_utimes.ts": "0e371d1dd361c5204f3a0a511ad58ffa4b793f555a91c10495e7ef48e8c11802", + "https://deno.land/std@0.153.0/node/_fs/_fs_watch.ts": "aef811e78e04cff3da30dcd334af8d85018f915d1ec7b95f05b2e4c48a7b7a4f", + "https://deno.land/std@0.153.0/node/_fs/_fs_write.d.ts": "deb5c1a98b6cb1aa79f773f3b8fc9410463f0e30fede1ff9df2652fc11b69d35", + "https://deno.land/std@0.153.0/node/_fs/_fs_write.mjs": "a1f2aae3c64cc86ea8acc83972a07459b4ad0b305fe75e24f1754eacc9d2c537", + "https://deno.land/std@0.153.0/node/_fs/_fs_writeFile.ts": "550b43b6223cf2feb8c3a829d2e0ca836b67da8688599408edf4fdd97bfbcbb6", + "https://deno.land/std@0.153.0/node/_fs/_fs_writev.d.ts": "7d41505383522b8fe27d13e4495f0b8621ff514d96b038c53d512dbded42718d", + "https://deno.land/std@0.153.0/node/_fs/_fs_writev.mjs": "274df0a109010862c8f8b320dc7784de9bd9425fe2a6afd05f1f06f547a25cba", + "https://deno.land/std@0.153.0/node/_global.d.ts": "6dadaf8cec2a0c506b22170617286e0bdc80be53dd0673e67fc7dd37a1130c68", + "https://deno.land/std@0.153.0/node/_next_tick.ts": "ec6772f13b0b78edd1b679502501210abdcdd5b170aa6b90ec28dfd67dcd30ae", + "https://deno.land/std@0.153.0/node/_process/exiting.ts": "bc9694769139ffc596f962087155a8bfef10101d03423b9dcbc51ce6e1f88fce", + "https://deno.land/std@0.153.0/node/_process/process.ts": "71c22bd237ce970460dfd1e4f14aba42e6762febc31df9f3c505c3d7328b8232", + "https://deno.land/std@0.153.0/node/_process/stdio.mjs": "971c3b086040d8521562155db13f22f9971d5c42c852b2081d4d2f0d8b6ab6bd", + "https://deno.land/std@0.153.0/node/_process/streams.mjs": "da4dc14ad62a72d3efbaec6a660567a778653cb1b8d7a0d17fedba909574263d", + "https://deno.land/std@0.153.0/node/_stream.d.ts": "379e9bbfc59aa8d768f067cc18286c44755255e7e7a1981f8ab6dabc0f3076b3", + "https://deno.land/std@0.153.0/node/_stream.mjs": "07f6cbabaad0382fb4b9a25e70ac3093a44022b859247f64726746e6373f1c91", + "https://deno.land/std@0.153.0/node/_util/_util_callbackify.ts": "a71353d5fde3dc785cfdf6b6bcad1379a9c78b374720af4aaa7f88ffab2bac0e", + "https://deno.land/std@0.153.0/node/_utils.ts": "6a695598008a7bdf820b0785f3bc2fcbedbb48803365ae787e523e05b182cf73", + "https://deno.land/std@0.153.0/node/assert.ts": "25c4383a4aa6f953e1c04b8c6def66b910c040ffebff82fa24fc1f8f36ebd99b", + "https://deno.land/std@0.153.0/node/assertion_error.ts": "43415df2537ff825b89e42ee89511c9cd285ec5d57e38383ef12a245cbe19fd6", + "https://deno.land/std@0.153.0/node/buffer.ts": "0e4e409bc03754e99c3e9fbdd10ff50834fd5d6e8654f70efeff054afde58821", + "https://deno.land/std@0.153.0/node/events.ts": "f848398d3591534ca94ac6b852a9f3c4dbb2da310c3a26059cf4ff06b7eae088", + "https://deno.land/std@0.153.0/node/fs.ts": "81bc08977c29725ae0fb88acb22b4c77d73b2e376697614e53fe218565339226", + "https://deno.land/std@0.153.0/node/internal/assert.mjs": "118327c8866266534b30d3a36ad978204af7336dc2db3158b8167192918d4e06", + "https://deno.land/std@0.153.0/node/internal/blob.mjs": "52080b2f40b114203df67f8a6650f9fe3c653912b8b3ef2f31f029853df4db53", + "https://deno.land/std@0.153.0/node/internal/buffer.mjs": "6662fe7fe517329453545be34cea27a24f8ccd6d09afd4f609f11ade2b6dfca7", + "https://deno.land/std@0.153.0/node/internal/crypto/_keys.ts": "7f993ece8c8e94a292944518cf4173521c6bf01785e75be014cd45a9cc2e4ad5", + "https://deno.land/std@0.153.0/node/internal/crypto/constants.ts": "d2c8821977aef55e4d66414d623c24a2447791a8b49b6404b8db32d81e20c315", + "https://deno.land/std@0.153.0/node/internal/error_codes.ts": "ac03c4eae33de3a69d6c98e8678003207eecf75a6900eb847e3fea3c8c9e6d8f", + "https://deno.land/std@0.153.0/node/internal/errors.ts": "ef73e8d8da8fa5b979f0dee10474e75a7cf581852be04cde68b707332d9aa1e2", + "https://deno.land/std@0.153.0/node/internal/fixed_queue.ts": "455b3c484de48e810b13bdf95cd1658ecb1ba6bcb8b9315ffe994efcde3ba5f5", + "https://deno.land/std@0.153.0/node/internal/fs/streams.ts": "c925db185efdf56c35cde8270c07d61698b80603a90e07caf1cb4ff80abf195b", + "https://deno.land/std@0.153.0/node/internal/fs/utils.mjs": "358c6437d404a1d4acefdade89e91da2002f6f07d8b13b8041434f3ffdc0d928", + "https://deno.land/std@0.153.0/node/internal/hide_stack_frames.ts": "a91962ec84610bc7ec86022c4593cdf688156a5910c07b5bcd71994225c13a03", + "https://deno.land/std@0.153.0/node/internal/idna.ts": "3aed89919e3078160733b6e6ac60fdb06052cf0418acbabcf86f90017d102b78", + "https://deno.land/std@0.153.0/node/internal/net.ts": "1239886cd2508a68624c2dae8abf895e8aa3bb15a748955349f9ac5539032238", + "https://deno.land/std@0.153.0/node/internal/normalize_encoding.mjs": "3779ec8a7adf5d963b0224f9b85d1bc974a2ec2db0e858396b5d3c2c92138a0a", + "https://deno.land/std@0.153.0/node/internal/options.ts": "a23c285975e058cb26a19abcb048cd8b46ab12d21cfb028868ac8003fffb43ac", + "https://deno.land/std@0.153.0/node/internal/process/per_thread.mjs": "bc1be72a6a662bf81573c20fe74893374847a7302065ddf52fb3fb2af505f31f", + "https://deno.land/std@0.153.0/node/internal/querystring.ts": "c3b23674a379f696e505606ddce9c6feabe9fc497b280c56705c340f4028fe74", + "https://deno.land/std@0.153.0/node/internal/readline/callbacks.mjs": "1aa7d97dbc10ed85474ca046518793cfe6490ec008aac875bb437ded32f9254e", + "https://deno.land/std@0.153.0/node/internal/readline/utils.mjs": "a93ebb99f85e0dbb4f05f4aff5583d12a15150e45c335e4ecf925e1879dc9c84", + "https://deno.land/std@0.153.0/node/internal/streams/_utils.ts": "77fceaa766679847e4d4c3c96b2573c00a790298d90551e8e4df1d5e0fdaad3b", + "https://deno.land/std@0.153.0/node/internal/streams/add-abort-signal.mjs": "5623b83fa64d439cc4a1f09ae47ec1db29512cc03479389614d8f62a37902f5e", + "https://deno.land/std@0.153.0/node/internal/streams/buffer_list.mjs": "c6a7b29204fae025ff5e9383332acaea5d44bc7c522a407a79b8f7a6bc6c312d", + "https://deno.land/std@0.153.0/node/internal/streams/compose.mjs": "b522daab35a80ae62296012a4254fd7edfc0366080ffe63ddda4e38fe6b6803e", + "https://deno.land/std@0.153.0/node/internal/streams/destroy.mjs": "9c9bbeb172a437041d529829f433df72cf0b63ae49f3ee6080a55ffbef7572ad", + "https://deno.land/std@0.153.0/node/internal/streams/duplex.mjs": "9ee2cc5d6d23f9be3432a50a7bdd7a15408bac2bfbf9ccd7424342388be6150c", + "https://deno.land/std@0.153.0/node/internal/streams/end-of-stream.mjs": "38be76eaceac231dfde643e72bc0940625446bf6d1dbd995c91c5ba9fd59b338", + "https://deno.land/std@0.153.0/node/internal/streams/from.mjs": "134255c698ed63b33199911eb8e042f8f67e9682409bb11552e6120041ed1872", + "https://deno.land/std@0.153.0/node/internal/streams/legacy.mjs": "6ea28db95d4503447473e62f0b23ff473bfe1751223c33a3c5816652e93b257a", + "https://deno.land/std@0.153.0/node/internal/streams/passthrough.mjs": "a51074193b959f3103d94de41e23a78dfcff532bdba53af9146b86340d85eded", + "https://deno.land/std@0.153.0/node/internal/streams/pipeline.mjs": "9890b121759ede869174ef70c011fde964ca94d81f2ed97b8622d7cb17b49285", + "https://deno.land/std@0.153.0/node/internal/streams/readable.mjs": "d566a47ad3bbda2fbd9e9d77acec729b63813b2f31c7a229526f849619d3dc28", + "https://deno.land/std@0.153.0/node/internal/streams/state.mjs": "9ef917392a9d8005a6e038260c5fd31518d2753aea0bc9e39824c199310434cb", + "https://deno.land/std@0.153.0/node/internal/streams/transform.mjs": "3b361abad2ac78f7ccb6f305012bafdc0e983dfa4bb6ecddb4626e34a781a5f5", + "https://deno.land/std@0.153.0/node/internal/streams/utils.mjs": "06c21d0db0d51f1bf1e3225a661c3c29909be80355d268e64ee5922fc5eb6c5e", + "https://deno.land/std@0.153.0/node/internal/streams/writable.mjs": "5133c66237bf31043aec98092d1589a14483077424bba862511846e9287d2eae", + "https://deno.land/std@0.153.0/node/internal/url.ts": "eacef0ace4f4c5394e9818a81499f4871b2a993d1bd3b902047e44a381ef0e22", + "https://deno.land/std@0.153.0/node/internal/util.mjs": "98b26aa4eb6ca70fe6c140eb296759cea54fe1b46a87a91288073dbc88d46eb0", + "https://deno.land/std@0.153.0/node/internal/util/comparisons.ts": "666e75e01a85b5d3410e43625ab9fc165811439aa1298c14054acb64b670dc48", + "https://deno.land/std@0.153.0/node/internal/util/debuglog.ts": "6f12a764f5379e9d2675395d15d2fb48bd7376921ef64006ffb022fc7f44ab82", + "https://deno.land/std@0.153.0/node/internal/util/inspect.mjs": "1ddace0c97719d2cc0869ba177d375e96051301352ec235cbfb2ecbfcd4e8fba", + "https://deno.land/std@0.153.0/node/internal/util/types.ts": "de6e2b7f9b9985ab881b1e78f05ae51d1fc829ae1584063df21e57b35312f3c4", + "https://deno.land/std@0.153.0/node/internal/validators.mjs": "a7e82eafb7deb85c332d5f8d9ffef052f46a42d4a121eada4a54232451acc49a", + "https://deno.land/std@0.153.0/node/internal_binding/_libuv_winerror.ts": "801e05c2742ae6cd42a5f0fd555a255a7308a65732551e962e5345f55eedc519", + "https://deno.land/std@0.153.0/node/internal_binding/_listen.ts": "c15a356ef4758770fc72d3ca4db33f0cc321016df1aafb927c027c0d73ac2c42", + "https://deno.land/std@0.153.0/node/internal_binding/_node.ts": "e4075ba8a37aef4eb5b592c8e3807c39cb49ca8653faf8e01a43421938076c1b", + "https://deno.land/std@0.153.0/node/internal_binding/_timingSafeEqual.ts": "80640f055101071cb3680a2d8a1fead5fd260ca8bf183efb94296b69463e06cd", + "https://deno.land/std@0.153.0/node/internal_binding/_utils.ts": "1c50883b5751a9ea1b38951e62ed63bacfdc9d69ea665292edfa28e1b1c5bd94", + "https://deno.land/std@0.153.0/node/internal_binding/_winerror.ts": "8811d4be66f918c165370b619259c1f35e8c3e458b8539db64c704fbde0a7cd2", + "https://deno.land/std@0.153.0/node/internal_binding/ares.ts": "33ff8275bc11751219af8bd149ea221c442d7e8676e3e9f20ccb0e1f0aac61b8", + "https://deno.land/std@0.153.0/node/internal_binding/async_wrap.ts": "b83e4021a4854b2e13720f96d21edc11f9905251c64c1bc625a361f574400959", + "https://deno.land/std@0.153.0/node/internal_binding/buffer.ts": "781e1d13adc924864e6e37ecb5152e8a4e994cf394695136e451c47f00bda76c", + "https://deno.land/std@0.153.0/node/internal_binding/cares_wrap.ts": "720e6d5cff7018bb3d00e1a49dd4c31f0fc6af3a593ab68cd39e3592ed163d61", + "https://deno.land/std@0.153.0/node/internal_binding/config.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/connection_wrap.ts": "9debd4210d29c658054476fcb640c900725f564ef35412c56dc79eb07213a7c1", + "https://deno.land/std@0.153.0/node/internal_binding/constants.ts": "f4afc504137fb21f3908ab549931604968dfa62432b285a0874f41c4cade9ed2", + "https://deno.land/std@0.153.0/node/internal_binding/contextify.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/credentials.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/crypto.ts": "d7f39700dc020364edf7f4785e5026bb91f099ce1bd02734182451b1af300c8c", + "https://deno.land/std@0.153.0/node/internal_binding/errors.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/fs.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/fs_dir.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/fs_event_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/handle_wrap.ts": "9a969e85d9997d4194f4cf6282d7d5a45d7daeb85201a44067de66dccb97eaec", + "https://deno.land/std@0.153.0/node/internal_binding/heap_utils.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/http_parser.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/icu.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/inspector.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/js_stream.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/messaging.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/mod.ts": "f68e74e8eed84eaa6b0de24f0f4c47735ed46866d7ee1c5a5e7c0667b4f0540f", + "https://deno.land/std@0.153.0/node/internal_binding/module_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/native_module.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/natives.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/node_file.ts": "c96ee0b2af319a3916de950a6c4b0d5fb00d09395c51cd239c54d95d62567aaf", + "https://deno.land/std@0.153.0/node/internal_binding/node_options.ts": "3cd5706153d28a4f5944b8b162c1c61b7b8e368a448fb1a2cff9f7957d3db360", + "https://deno.land/std@0.153.0/node/internal_binding/options.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/os.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/performance.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/pipe_wrap.ts": "cd36df85afde429027b1a7399d6b74ca79e4f9bb4d52b3359cdacfd6416ce13d", + "https://deno.land/std@0.153.0/node/internal_binding/process_methods.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/report.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/serdes.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/signal_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/spawn_sync.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/stream_wrap.ts": "ecbd50a6c6ff7f6fea9bdfdc7b3977637cd854814c812b59296458ca2f0fc209", + "https://deno.land/std@0.153.0/node/internal_binding/string_decoder.ts": "5cb1863763d1e9b458bc21d6f976f16d9c18b3b3f57eaf0ade120aee38fba227", + "https://deno.land/std@0.153.0/node/internal_binding/symbols.ts": "51cfca9bb6132d42071d4e9e6b68a340a7f274041cfcba3ad02900886e972a6c", + "https://deno.land/std@0.153.0/node/internal_binding/task_queue.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/tcp_wrap.ts": "3092cfa8186273f3e409162172f628215096c7d1befd76db2b21adef3a0483b9", + "https://deno.land/std@0.153.0/node/internal_binding/timers.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/tls_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/trace_events.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/tty_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/types.ts": "4c26fb74ba2e45de553c15014c916df6789529a93171e450d5afb016b4c765e7", + "https://deno.land/std@0.153.0/node/internal_binding/udp_wrap.ts": "1ed1758ba000e124228026cd37d41f0a156da3c435489786e5fadfc5c3abd951", + "https://deno.land/std@0.153.0/node/internal_binding/url.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/util.ts": "faf5146c3cc3b2d6c26026a818b4a16e91488ab26e63c069f36ba3c3ae24c97b", + "https://deno.land/std@0.153.0/node/internal_binding/uv.ts": "aa1db842936e77654522d9136bb2ae191bf334423f58962a8a7404b6635b5b49", + "https://deno.land/std@0.153.0/node/internal_binding/v8.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/worker.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/internal_binding/zlib.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.153.0/node/os.ts": "037c3a2d78791ac01db6152ac99ecfe907a96a5c8140bd473d3878805b6827f2", + "https://deno.land/std@0.153.0/node/path.ts": "c65858e9cbb52dbc0dd348eefcdc41e82906c39cfa7982f2d4d805e828414b8c", + "https://deno.land/std@0.153.0/node/path/_constants.ts": "591787ca44a55859644a2d5dbaef43698ab29e72e58fd498ea5e8f78a341ba20", + "https://deno.land/std@0.153.0/node/path/_interface.ts": "6034ee29f6f295460ec82db1a94df9269aecbb0eceb81be72e9d843f8e8a97e6", + "https://deno.land/std@0.153.0/node/path/_util.ts": "70b4b58098c4638f3bf719fa700c95e308e3984a3f9aca551fab713426ba3cbe", + "https://deno.land/std@0.153.0/node/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4", + "https://deno.land/std@0.153.0/node/path/glob.ts": "d6b64a24f148855a6e8057a171a2f9910c39e492e4ccec482005205b28eb4533", + "https://deno.land/std@0.153.0/node/path/mod.ts": "f9125e20031aac43eef8baa58d852427c762541574513f6870d1d0abd8103252", + "https://deno.land/std@0.153.0/node/path/posix.ts": "8d92e4e7a9257eebe13312623a89fa0bdc8f75f81adc66641e10edc0a03bc3dd", + "https://deno.land/std@0.153.0/node/path/separator.ts": "c908c9c28ebe7f1fea67daaccf84b63af90d882fe986f9fa03af9563a852723a", + "https://deno.land/std@0.153.0/node/path/win32.ts": "06878dde1d89232c8c0dedd4ebe0420cc0ca73696f6028e4c57f802d9f7998a3", + "https://deno.land/std@0.153.0/node/process.ts": "92c89735072d9b851a23d449d1f0eb4729248a0ef2633588fb4686e62ec16674", + "https://deno.land/std@0.153.0/node/querystring.ts": "dadf881a61d95eb8b6930a00dc4e297e27b8e79961e65ab3e7f5ed34f150ba82", + "https://deno.land/std@0.153.0/node/stream.ts": "d127faa074a9e3886e4a01dcfe9f9a6a4b5641f76f6acc356e8ded7da5dc2c81", + "https://deno.land/std@0.153.0/node/stream/promises.mjs": "b263c09f2d6bd715dc514fab3f99cca84f442e2d23e87adbe76e32ea46fc87e6", + "https://deno.land/std@0.153.0/node/string_decoder.ts": "51ce85a173d2e36ac580d418bb48b804adb41732fc8bd85f7d5d27b7accbc61f", + "https://deno.land/std@0.153.0/node/url.ts": "67d9f31d041dfbd7720e94d714dc27550d0448869d9a1b03200044ab563401c2", + "https://deno.land/std@0.153.0/node/util.ts": "1bc9e881521b024818dc8727c746b54f835fe1b4c20ff049c5aa932f6c387593", + "https://deno.land/std@0.153.0/node/util/types.ts": "5948b43e834f73a4becf85b02049632560c65da9c1127e5c533c83d200d3dfcd", + "https://deno.land/std@0.153.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.153.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.153.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", + "https://deno.land/std@0.153.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.153.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", + "https://deno.land/std@0.153.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac", + "https://deno.land/std@0.153.0/path/posix.ts": "c1f7afe274290ea0b51da07ee205653b2964bd74909a82deb07b69a6cc383aaa", + "https://deno.land/std@0.153.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.153.0/path/win32.ts": "bd7549042e37879c68ff2f8576a25950abbfca1d696d41d82c7bca0b7e6f452c", + "https://deno.land/std@0.153.0/streams/conversion.ts": "fc4eb76a14148c43f0b85e903a5a1526391aa40ed9434dc21e34f88304eb823e", + "https://deno.land/std@0.153.0/testing/_diff.ts": "141f978a283defc367eeee3ff7b58aa8763cf7c8e0c585132eae614468e9d7b8", + "https://deno.land/std@0.153.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", + "https://deno.land/std@0.153.0/testing/asserts.ts": "d6595cfc330b4233546a047a0d7d57940771aa9d97a172ceb91e84ae6200b3af", + "https://deno.land/std@0.154.0/_deno_unstable.ts": "4ddb8672d49d58b5bbc4a5a7a2f1b3bce4fd06aa4c8b8476728334391667de7b", + "https://deno.land/std@0.154.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.154.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", + "https://deno.land/std@0.154.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06", + "https://deno.land/std@0.154.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0", + "https://deno.land/std@0.154.0/async/debounce.ts": "de5433bff08a2bb61416fc53b3bd2d5867090c8a815465e5b4a10a77495b1051", + "https://deno.land/std@0.154.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae", + "https://deno.land/std@0.154.0/async/delay.ts": "d5a169caede8e1c5d46b3f25eab97db5fd1ab193fc82a53bafd3642ac42ca3c7", + "https://deno.land/std@0.154.0/async/mod.ts": "dd0a8ed4f3984ffabe2fcca7c9f466b7932d57b1864ffee148a5d5388316db6b", + "https://deno.land/std@0.154.0/async/mux_async_iterator.ts": "3447b28a2a582224a3d4d3596bccbba6e85040da3b97ed64012f7decce98d093", + "https://deno.land/std@0.154.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239", + "https://deno.land/std@0.154.0/async/tee.ts": "d27680d911816fcb3d231e16d690e7588079e66a9b2e5ce8cc354db94fdce95f", + "https://deno.land/std@0.154.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4", + "https://deno.land/std@0.154.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a", + "https://deno.land/std@0.154.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", + "https://deno.land/std@0.154.0/datetime/formatter.ts": "7c8e6d16a0950f400aef41b9f1eb9168249869776ec520265dfda785d746589e", + "https://deno.land/std@0.154.0/datetime/mod.ts": "701f01f0a3c9d4a6e7802ad245ac6dbef1118bdb104ef7965107d6dd8a0ebaf9", + "https://deno.land/std@0.154.0/datetime/tokenizer.ts": "7381e28f6ab51cb504c7e132be31773d73ef2f3e1e50a812736962b9df1e8c47", + "https://deno.land/std@0.154.0/http/cookie.ts": "7a61e920f19c9c3ee8e07befe5fe5a530114d6babefd9ba2c50594cab724a822", + "https://deno.land/std@0.154.0/http/http_status.ts": "897575a7d6bc2b9123f6a38ecbc0f03d95a532c5d92029315dc9f508e12526b8", + "https://deno.land/std@0.154.0/http/server.ts": "e2e16f0b124ffef022ad04797d4250d6e4fc0d9579780999091949b17789dd1a", + "https://deno.land/std@0.154.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289", + "https://deno.land/std@0.154.0/io/types.d.ts": "0cae3a62da7a37043661746c65c021058bae020b54e50c0e774916e5d4baee43", + "https://deno.land/std@0.154.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.154.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.154.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", + "https://deno.land/std@0.154.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.154.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", + "https://deno.land/std@0.154.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac", + "https://deno.land/std@0.154.0/path/posix.ts": "c1f7afe274290ea0b51da07ee205653b2964bd74909a82deb07b69a6cc383aaa", + "https://deno.land/std@0.154.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.154.0/path/win32.ts": "bd7549042e37879c68ff2f8576a25950abbfca1d696d41d82c7bca0b7e6f452c", + "https://deno.land/std@0.154.0/streams/conversion.ts": "fc4eb76a14148c43f0b85e903a5a1526391aa40ed9434dc21e34f88304eb823e", + "https://deno.land/std@0.159.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.159.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", + "https://deno.land/std@0.159.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06", + "https://deno.land/std@0.159.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0", + "https://deno.land/std@0.159.0/async/debounce.ts": "de5433bff08a2bb61416fc53b3bd2d5867090c8a815465e5b4a10a77495b1051", + "https://deno.land/std@0.159.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae", + "https://deno.land/std@0.159.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699", + "https://deno.land/std@0.159.0/async/mod.ts": "dd0a8ed4f3984ffabe2fcca7c9f466b7932d57b1864ffee148a5d5388316db6b", + "https://deno.land/std@0.159.0/async/mux_async_iterator.ts": "3447b28a2a582224a3d4d3596bccbba6e85040da3b97ed64012f7decce98d093", + "https://deno.land/std@0.159.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239", + "https://deno.land/std@0.159.0/async/tee.ts": "d27680d911816fcb3d231e16d690e7588079e66a9b2e5ce8cc354db94fdce95f", + "https://deno.land/std@0.159.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4", + "https://deno.land/std@0.159.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a", + "https://deno.land/std@0.159.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", + "https://deno.land/std@0.159.0/collections/map_values.ts": "7e73685397409f2a1bc5356d89a58ce0249faf9e38db29434a8733144c877a2f", + "https://deno.land/std@0.159.0/crypto/_wasm_crypto/lib/deno_std_wasm_crypto.generated.mjs": "581c7204c9d731cab6ec1525d1be99dc69d8a2362c9862dfc822c0132a75a5b0", + "https://deno.land/std@0.159.0/crypto/_wasm_crypto/mod.ts": "6c60d332716147ded0eece0861780678d51b560f533b27db2e15c64a4ef83665", + "https://deno.land/std@0.159.0/crypto/timing_safe_equal.ts": "82a29b737bc8932d75d7a20c404136089d5d23629e94ba14efa98a8cc066c73e", + "https://deno.land/std@0.159.0/encoding/base64.ts": "c57868ca7fa2fbe919f57f88a623ad34e3d970d675bdc1ff3a9d02bba7409db2", + "https://deno.land/std@0.159.0/encoding/base64url.ts": "a5f82a9fa703bd85a5eb8e7c1296bc6529e601ebd9642cc2b5eaa6b38fa9e05a", + "https://deno.land/std@0.159.0/encoding/hex.ts": "4cc5324417cbb4ac9b828453d35aed45b9cc29506fad658f1f138d981ae33795", + "https://deno.land/std@0.159.0/flags/mod.ts": "5ecf064f28471b455be61a71b68172236151f3362c8b4a663d5aedee93179ded", + "https://deno.land/std@0.159.0/fmt/colors.ts": "ff7dc9c9f33a72bd48bc24b21bbc1b4545d8494a431f17894dbc5fe92a938fc4", + "https://deno.land/std@0.159.0/fmt/printf.ts": "111a4df40a81799da778421ae0260f278e5d79877e30e92403cd57d197a8d1b0", + "https://deno.land/std@0.159.0/fs/eol.ts": "b92f0b88036de507e7e6fbedbe8f666835ea9dcbf5ac85917fa1fadc919f83a5", + "https://deno.land/std@0.159.0/fs/exists.ts": "6a447912e49eb79cc640adacfbf4b0baf8e17ede6d5bed057062ce33c4fa0d68", + "https://deno.land/std@0.159.0/http/http_status.ts": "897575a7d6bc2b9123f6a38ecbc0f03d95a532c5d92029315dc9f508e12526b8", + "https://deno.land/std@0.159.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289", + "https://deno.land/std@0.159.0/io/types.d.ts": "0cae3a62da7a37043661746c65c021058bae020b54e50c0e774916e5d4baee43", + "https://deno.land/std@0.159.0/node/_core.ts": "83860ad91022fe1ed759acc3f012dd7e2a4258bd4ee7eab8332110cba0d0be10", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/base/buffer.js": "73beb8294eb29bd61458bbaaeeb51dfad4ec9c9868a62207a061d908f1637261", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/base/node.js": "4b777980d2a23088698fd2ff065bb311a2c713497d359e674cb6ef6baf267a0f", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/base/reporter.js": "8e4886e8ae311c9a92caf58bbbd8670326ceeae97430f4884e558e4acf8e8598", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/constants/der.js": "354b255479bff22a31d25bf08b217a295071700e37d0991cc05cac9f95e5e7ca", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/decoders/der.js": "c6faf66761daa43fbf79221308443893587c317774047b508a04c570713b76fb", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/decoders/pem.js": "8316ef7ce2ce478bc3dc1e9df1b75225d1eb8fb5d1378f8adf0cf19ecea5b501", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/encoders/der.js": "408336c88d17c5605ea64081261cf42267d8f9fda90098cb560aa6635bb00877", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/encoders/pem.js": "42a00c925b68c0858d6de0ba41ab89935b39fae9117bbf72a9abb2f4b755a2e7", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/asn1.js/mod.js": "7b78859707be10a0a1e4faccdd28cd5a4f71ad74a3e7bebda030757da97cd232", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/bn.js/bn.js": "abd1badd659fd0ae54e6a421a573a25aef4e795edc392178360cf716b144286d", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/aes.js": "1cf4c354c5bb341ffc9ab7207f471229835b021947225bce2e1642f26643847a", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/auth_cipher.js": "19b4dbb903e8406eb733176e6318d5e1a3bd382b67b72f7cf8e1c46cc6321ba4", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/decrypter.js": "05c1676942fd8e95837115bc2d1371bcf62e9bf19f6c3348870961fc64ddad0b", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/encrypter.js": "93ec98ab26fbeb5969eae2943e42fb66780f377b9b0ff0ecc32a9ed11201b142", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/ghash.js": "667b64845764a84f0096ef8cf7debed1a5f15ac9af26b379848237be57da399a", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/incr32.js": "4a7f0107753e4390b4ccc4dbd5200c5527d43f894f768e131903df30a09dfd67", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/mod.js": "d8eb88e7a317467831473621f32e60d7db9d981f6a2ae45d2fb2af170eab2d22", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/cbc.js": "9790799cff181a074686c885708cb8eb473aeb3c86ff2e8d0ff911ae6c1e4431", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb.js": "a4e36ede6f26d8559d8f0528a134592761c706145a641bd9ad1100763e831cdb", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb1.js": "c6372f4973a68ca742682e81d1165e8869aaabf0091a8b963d4d60e5ee8e6f6a", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb8.js": "bd29eebb89199b056ff2441f7fb5e0300f458e13dcaaddbb8bc00cbdb199db67", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/ctr.js": "9c2cbac1fc8f9b58334faacb98e6c57e8c3712f673ea4cf2d528a2894998ab2f", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/ecb.js": "9629d193433688f0cfc432eca52838db0fb28d9eb4f45563df952bde50b59763", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/mod.js": "7d8516ef8a20565539eb17cad5bb70add02ac06d1891e8f47cb981c22821787e", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/modes/ofb.js": "c23abaa6f1ec5343e9d7ba61d702acb3d81a0bd3d34dd2004e36975dc043d6ff", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/stream_cipher.js": "a533a03a2214c6b5934ce85a59eb1e04239fd6f429017c7ca3c443ec7e07e68f", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_aes/xor.ts": "4417711c026eb9a07475067cd31fa601e88c2d6ababd606d33d1e74da6fcfd09", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/browserify_rsa.js": "de8c98d2379a70d8c239b4886e2b3a11c7204eec39ae6b65d978d0d516ee6b08", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/cipher_base.js": "f565ad9daf3b3dd3b68381bed848da94fb093a9e4e5a48c92f47e26cc229df39", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/evp_bytes_to_key.ts": "006ecde1cc428b16f2d214637c35296279710b3a8d211dcf91d561c2cd692125", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/parse_asn1/asn1.js": "4f33b0197ffbe9cff62e5bad266e6b40d55874ea653552bb32ed251ad091f70a", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/parse_asn1/certificate.js": "aab306870830a81ad188db8fa8e037d7f5dd6c5abdabbd9739558245d1a12224", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/parse_asn1/fix_proc.js": "af3052b76f441878e102ffcfc7420692e65777af765e96f786310ae1acf7f76a", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/parse_asn1/mod.js": "9d445baecb55d4abbe6434be55d5bb7976ea752c3976a75b7e2684a7d440a576", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/public_encrypt/mgf.js": "46cf72f2b9aa678a15daef8ed551241f2d0c1ca38b8b6c8e5226a4853540d7b2", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/public_encrypt/mod.js": "eb8b64d7a58ee3823c1b642e799cc7ed1257d99f4d4aefa2b4796dd112ec094a", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/public_encrypt/private_decrypt.js": "220de06bf9edcfa4def18c30a16aa0482b1ddc24f8712f46509e82b1e3be122c", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/public_encrypt/public_encrypt.js": "4679188b9b38502ac21b5e1a5bcfbcd278f51854f5385aac8d9962d7c21b2a6e", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/public_encrypt/with_public.js": "7373dac9b53b8331ccf3521c854a131dcb304a2e4d34cd116649118f7919ed0c", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/public_encrypt/xor.js": "900c6fc8b95e1861d796193c41988f5f70a09c7059e42887a243d0113ecaf0fd", + "https://deno.land/std@0.159.0/node/_crypto/crypto_browserify/randombytes.ts": "414e2a13f1ea79980acba7b4634821b7a636f1856eedbba7cce8fa528c11e9a0", + "https://deno.land/std@0.159.0/node/_events.d.ts": "3899ee9c37055fbb750e32cb43d7c435077c04446af948300080e1a590c6edf0", + "https://deno.land/std@0.159.0/node/_events.mjs": "303e8aa60ace559e4ca0d19e8475f87311bee9e8330b4b497644d70f2002fc27", + "https://deno.land/std@0.159.0/node/_fs/_fs_access.ts": "d53ad247ad04974fd8b5baa03442af09ce2842da1f35f9685b50b6c3bb030735", + "https://deno.land/std@0.159.0/node/_fs/_fs_appendFile.ts": "f9d83bce0d3eae04246916da5b048313c24d88dfaf063f779c3434f0399d9042", + "https://deno.land/std@0.159.0/node/_fs/_fs_chmod.ts": "c1a65080458afbeba8ad0824664f824b167c39ee02ef6b0d04bcbc123a3f897e", + "https://deno.land/std@0.159.0/node/_fs/_fs_chown.ts": "e072f87628cfea956e945787f0e8af47be325b2e3183ae095279472b1fb9d085", + "https://deno.land/std@0.159.0/node/_fs/_fs_close.ts": "b23915f763addce9d96116c1e3179d9d33c596903f57e20afa46c4333072a460", + "https://deno.land/std@0.159.0/node/_fs/_fs_common.ts": "11de407c3037a132628f7ceea5f26843307a0980b6755c695c2741fe2483921c", + "https://deno.land/std@0.159.0/node/_fs/_fs_constants.ts": "66a07e0c0279ec118851cf30570d25bce3ac13dedb269d53d04e342bbad28cca", + "https://deno.land/std@0.159.0/node/_fs/_fs_copy.ts": "692b9235a9c267e4c2604b4a228da5ef1c0ad0977ed086187fc86bc978c82913", + "https://deno.land/std@0.159.0/node/_fs/_fs_dir.ts": "1d1ecd45779fe90778afe808daa295ffddfd64568c58dfdacadc2b7b18c5dfb8", + "https://deno.land/std@0.159.0/node/_fs/_fs_dirent.ts": "649c0a794e7b8d930cdd7e6a168b404aa0448bf784e0cfbe1bd6d25b99052273", + "https://deno.land/std@0.159.0/node/_fs/_fs_exists.ts": "87b063b7b1a59b5d2302ba2de2204fbccc1bfbe7fafede8678694cae45b77682", + "https://deno.land/std@0.159.0/node/_fs/_fs_fdatasync.ts": "bbd078fea6c62c64d898101d697aefbfbb722797a75e328a82c2a4f2e7eb963d", + "https://deno.land/std@0.159.0/node/_fs/_fs_fstat.ts": "559ff6ff094337db37b0f3108aeaecf42672795af45b206adbd90105afebf9c6", + "https://deno.land/std@0.159.0/node/_fs/_fs_fsync.ts": "590be69ce5363dd4f8867f244cfabe8df89d40f86bbbe44fd00d69411d0b798e", + "https://deno.land/std@0.159.0/node/_fs/_fs_ftruncate.ts": "8eb2a9fcf026bd9b85dc07a22bc452c48db4be05ab83f5f2b6a0549e15c1f75f", + "https://deno.land/std@0.159.0/node/_fs/_fs_futimes.ts": "89c71d38dd6b8e0a97470f3545d739ba04f6e3ca981a664404a825fd772ac2a2", + "https://deno.land/std@0.159.0/node/_fs/_fs_link.ts": "f7c60f989a60becd6cdc1c553122be34f7c2ed83a900448757982683cebc0ffd", + "https://deno.land/std@0.159.0/node/_fs/_fs_lstat.ts": "c26a406ccdbc95dd7dab75aca0019b45b41edc07bebd40d9de183780d647a064", + "https://deno.land/std@0.159.0/node/_fs/_fs_mkdir.ts": "0949fcfbca0fe505c286140cd9fc82dcc0e7d95b6eead1adb14292179750def6", + "https://deno.land/std@0.159.0/node/_fs/_fs_mkdtemp.ts": "a037c457d6542eb16df903f7126a4ddb174e37c7d7a58c6fbff86a2147b78509", + "https://deno.land/std@0.159.0/node/_fs/_fs_open.ts": "5f2a940453c363b401ab1586f76e412f3e69bb32c3576fa9ffca46316b9f7599", + "https://deno.land/std@0.159.0/node/_fs/_fs_opendir.ts": "6092198e34be65c9436d5cae1d781e3d3b7a3a5090ecf4691febeef2c1df6c57", + "https://deno.land/std@0.159.0/node/_fs/_fs_read.ts": "6fda8778a0a9bbe87cf1a617fc5447b9ba7658ced0c87565c8e7c5e49a0821f0", + "https://deno.land/std@0.159.0/node/_fs/_fs_readFile.ts": "7c42f8cb4bad2e37a53314de7831c0735bae010712fd914471850caa4d322ffd", + "https://deno.land/std@0.159.0/node/_fs/_fs_readdir.ts": "8d186b470aea8411c794687b20effaf1f478abb59956c67b671691d9444b7786", + "https://deno.land/std@0.159.0/node/_fs/_fs_readlink.ts": "a5582656af6f09361ecb408ed3c0ad3cc3afd683403539e4c22aa06deab90fc0", + "https://deno.land/std@0.159.0/node/_fs/_fs_realpath.ts": "0bf961c7b13d83e39b21255237b7ef352beb778a8274d03f2419907a8cd5c09c", + "https://deno.land/std@0.159.0/node/_fs/_fs_rename.ts": "9aa3cf6643499a38ccbfb14435239c7fc0da6b4cb5b5ab1c9e676d42daf27b71", + "https://deno.land/std@0.159.0/node/_fs/_fs_rm.ts": "82e926fe3e11e2a7f56116d3b7005372c339010cc1a7566a37a5591d19d680c6", + "https://deno.land/std@0.159.0/node/_fs/_fs_rmdir.ts": "109fb603373cf5318f8d2e19b3704b8493b4de954873df28a0d066afd0b0f5e0", + "https://deno.land/std@0.159.0/node/_fs/_fs_stat.ts": "4ccc93cd1938e5dcc5298feb9c6f0bc9f444fa8565c726854ea40210b93d254c", + "https://deno.land/std@0.159.0/node/_fs/_fs_symlink.ts": "a9fe02e745a8ab28e152f37e316cb204382f86ebafc3bcf32a9374cf9d369181", + "https://deno.land/std@0.159.0/node/_fs/_fs_truncate.ts": "1fe9cba3a54132426927639c024a7a354455e5a13b3b3143ad1c25ed0b5fc288", + "https://deno.land/std@0.159.0/node/_fs/_fs_unlink.ts": "d845c8067a2ba55c443e04d2706e6a4e53735488b30fc317418c9f75127913b0", + "https://deno.land/std@0.159.0/node/_fs/_fs_utimes.ts": "194eeb8dab1ebdf274f784a38241553cc440305461b30c987ecef1a24dfc01ca", + "https://deno.land/std@0.159.0/node/_fs/_fs_watch.ts": "aef811e78e04cff3da30dcd334af8d85018f915d1ec7b95f05b2e4c48a7b7a4f", + "https://deno.land/std@0.159.0/node/_fs/_fs_write.d.ts": "deb5c1a98b6cb1aa79f773f3b8fc9410463f0e30fede1ff9df2652fc11b69d35", + "https://deno.land/std@0.159.0/node/_fs/_fs_write.mjs": "265f1291a2e908fd2da9fc3cb541be09a592119a29767708354a3bec18645b04", + "https://deno.land/std@0.159.0/node/_fs/_fs_writeFile.ts": "7bd32ee95836bb04f71c63f3f055de147953f431d3afa5596915c22cf33e41ed", + "https://deno.land/std@0.159.0/node/_fs/_fs_writev.d.ts": "2cc02fc9ed20292e09a22bf215635804f397d9b8ed2df62067a42f3be1b31b76", + "https://deno.land/std@0.159.0/node/_fs/_fs_writev.mjs": "e500af8857779d404302658225c249b89f20fa4c40058c645b555dd70ca6b54f", + "https://deno.land/std@0.159.0/node/_global.d.ts": "6dadaf8cec2a0c506b22170617286e0bdc80be53dd0673e67fc7dd37a1130c68", + "https://deno.land/std@0.159.0/node/_http_agent.mjs": "a8b519a9a375a15d17c1c1fbd732576b24f7427a5ef75c58dbcb7242168b46f8", + "https://deno.land/std@0.159.0/node/_http_common.ts": "73a9ec85e8d62a783b2a52577ebeafc3d69f9be21ba12e654dfbde455cf58162", + "https://deno.land/std@0.159.0/node/_http_outgoing.ts": "dc400fca921c09bca194380a338eccc9559f7cc95cecf92a7427513eb280eb98", + "https://deno.land/std@0.159.0/node/_next_tick.ts": "81c1826675493b76f90c646fb1274a4c84b5cc913a80ca4526c32cd7c46a0b06", + "https://deno.land/std@0.159.0/node/_pako.mjs": "3a9980e5b5e8705de0672911e85dc74587b19a8bac95295eee45cd105db5569b", + "https://deno.land/std@0.159.0/node/_process/exiting.ts": "bc9694769139ffc596f962087155a8bfef10101d03423b9dcbc51ce6e1f88fce", + "https://deno.land/std@0.159.0/node/_process/process.ts": "d5bf113a4b62f4abe4cb7ec5a9d00d44dac0ad9e82a23d00cc27d71e05ae8b66", + "https://deno.land/std@0.159.0/node/_process/stdio.mjs": "971c3b086040d8521562155db13f22f9971d5c42c852b2081d4d2f0d8b6ab6bd", + "https://deno.land/std@0.159.0/node/_process/streams.mjs": "d0787350275a89b1719e9300ce6ad3d489488e785e3cf0265e705abf29cfb08b", + "https://deno.land/std@0.159.0/node/_readline.d.ts": "5215c4b15108e586ddda5670f1b4bacc0d82b502aca3ac132b660581925087f1", + "https://deno.land/std@0.159.0/node/_readline.mjs": "07a98aeadf2fb2ae155d197064ba157f848f3fe29a1807a08b1270504ba9e4ce", + "https://deno.land/std@0.159.0/node/_stream.d.ts": "83e9da2f81de3205926f1e86ba54442aa5a3caf4c5e84a4c8699402ad340142b", + "https://deno.land/std@0.159.0/node/_stream.mjs": "4e8470f048f317c71cf4ba4894ca286a1ac2dc99e526a0e129c9a172c1079b7d", + "https://deno.land/std@0.159.0/node/_tls_common.ts": "ba2ccfca08b77a4913ff83f3cad675c1c44a7d5da9721b635338aa13b95d322d", + "https://deno.land/std@0.159.0/node/_tls_wrap.ts": "0ce40d6ea36220b2cf5d757a2e0d366c52ce9d0e79b641fea485eeb4ff9940aa", + "https://deno.land/std@0.159.0/node/_util/_util_callbackify.ts": "a71353d5fde3dc785cfdf6b6bcad1379a9c78b374720af4aaa7f88ffab2bac0e", + "https://deno.land/std@0.159.0/node/_utils.ts": "c86e0728dc99d46b1d590b677616b8c92b7e42e7a060a1975da77552d8cf8441", + "https://deno.land/std@0.159.0/node/_zlib.mjs": "8d765ea047fe24538bcee393873a75771c1ad70518dd338c6234aa1049a4daf0", + "https://deno.land/std@0.159.0/node/_zlib_binding.mjs": "18f2c312e50b8d5b1585f9428c107ed07d62c9b78b96752962b6d5872d657f22", + "https://deno.land/std@0.159.0/node/assert.ts": "25c4383a4aa6f953e1c04b8c6def66b910c040ffebff82fa24fc1f8f36ebd99b", + "https://deno.land/std@0.159.0/node/assert/strict.ts": "5c1e2b79e129d3dae40a1d51760e384c652d4b428b722815bf12a9ca493c41b5", + "https://deno.land/std@0.159.0/node/assertion_error.ts": "333c1073bf383f10334368816cbb992a7b4aa6d7a7a1e08186378698306bc332", + "https://deno.land/std@0.159.0/node/async_hooks.ts": "d0b31c0ca74169782da1d804e4e1bfa344187f072c33c9b66b37721f0fba8f63", + "https://deno.land/std@0.159.0/node/buffer.ts": "43f07b2d1523345bf35b7deb7fcdad6e916020a631a7bc1b5efcaff556db4e1d", + "https://deno.land/std@0.159.0/node/child_process.ts": "2ce8176b7dbcb6c867869ba09201e9e715393d89b24ecc458c18f8cc59f07dda", + "https://deno.land/std@0.159.0/node/cluster.ts": "d1141a52343c7fc11a0236ac731bf0e3387ddcb745486a694b94ede35e874fba", + "https://deno.land/std@0.159.0/node/console.ts": "b50164e184dceaca844e2084dad5b9a0def9700cdba6a579e744406ee973ad43", + "https://deno.land/std@0.159.0/node/constants.ts": "66ea87f72344b9e13031585941867b110b8a269827f7acaffc38467b74fb6e7b", + "https://deno.land/std@0.159.0/node/crypto.ts": "0bc90f0550c7d00ee11d8a086d57b2367ab3a083f4156ef68ae28582cd3cfcc2", + "https://deno.land/std@0.159.0/node/dgram.ts": "da2ed48bb66438c15b0251019d9364c25d423a96cdabdb5f4a9b18fe372c7c88", + "https://deno.land/std@0.159.0/node/diagnostics_channel.ts": "b5f3f6343868598beb5c33a4e4d63df34a99504149accc8953027c0e58570451", + "https://deno.land/std@0.159.0/node/dns.ts": "d2e6c33902cae784ce24d2e869eb87fa9a14cba0e2d1d4c197939fa4f0739803", + "https://deno.land/std@0.159.0/node/dns/promises.ts": "c8c87cb82c9e45f4dfa3c1ff4d45b87342b105a23c1dabd5cc15d8e76e737835", + "https://deno.land/std@0.159.0/node/domain.ts": "9a9a5081a083fcfb70cafc581d323adc5124f922d1a5011a6ee6aca3b020cc99", + "https://deno.land/std@0.159.0/node/events.ts": "f848398d3591534ca94ac6b852a9f3c4dbb2da310c3a26059cf4ff06b7eae088", + "https://deno.land/std@0.159.0/node/fs.ts": "f948e8365914c122f8402e216ec10adf49bd60dd0da68e772f6fe083d4936186", + "https://deno.land/std@0.159.0/node/fs/promises.ts": "a437b457b4e6c4fbe903bd9f619392b3b988a4e3521dade6c0974885d6d702a2", + "https://deno.land/std@0.159.0/node/global.ts": "c05ebf6903eaf51c8ec70b62a2f634caaec6fa3fa787fadd9f4ca67b5be6a187", + "https://deno.land/std@0.159.0/node/http.ts": "ad4c305d7adc8fb7ea473c1fbb40bb3966aac4c720f3beca7579ae3298b2a852", + "https://deno.land/std@0.159.0/node/http2.ts": "3e6d7a1d3555022139ccb254698115c43bc511d5eacbd1abbbe336bd965cf626", + "https://deno.land/std@0.159.0/node/https.ts": "8a4fa81ff1785479b6bdbc638d57f8474328110e7b96dc29210319e81ed8f2d2", + "https://deno.land/std@0.159.0/node/inspector.ts": "a377989dcaa690f76008d5baf83d4ed8c8936c9c52cc9773775b70315040b28f", + "https://deno.land/std@0.159.0/node/internal/assert.mjs": "118327c8866266534b30d3a36ad978204af7336dc2db3158b8167192918d4e06", + "https://deno.land/std@0.159.0/node/internal/async_hooks.ts": "8eca5b80f58ffb259e9b3a73536dc2fe2e67d07fd24bfe2aee325a4aa435edb3", + "https://deno.land/std@0.159.0/node/internal/buffer.d.ts": "90f674081428a61978b6d481c5f557ff743a3f4a85d7ae113caab48fdf5b8a63", + "https://deno.land/std@0.159.0/node/internal/buffer.mjs": "70b74b34f1617b3492aee6dec35c7bdabbf26e034bfcf640aa61b3d63c5c850f", + "https://deno.land/std@0.159.0/node/internal/child_process.ts": "d8165f306bfd641cc4c51596ec449712df42411c20182ee43359c0a8f7f879b2", + "https://deno.land/std@0.159.0/node/internal/cli_table.ts": "e4c3f2afde05bb6c4a7583e5dea004dfe62b1d2c1d408b09ccbbfbacf03a2044", + "https://deno.land/std@0.159.0/node/internal/console/constructor.mjs": "2e9689b9db8f9905cf2fb3fe4d4faf35baa485c23f8c0f7b38717739a77336ca", + "https://deno.land/std@0.159.0/node/internal/constants.ts": "b3ffbd87f2d18ac01e2a9ded592841545cea0da05f956109e4b9d3619d73fe27", + "https://deno.land/std@0.159.0/node/internal/crypto/_keys.ts": "7f993ece8c8e94a292944518cf4173521c6bf01785e75be014cd45a9cc2e4ad5", + "https://deno.land/std@0.159.0/node/internal/crypto/_randomBytes.ts": "0d80254f7be71aa3212956071d22f2cb2193aa1f957e48286f83ccf092abe4e5", + "https://deno.land/std@0.159.0/node/internal/crypto/_randomFill.ts": "4d9157fd8a4894f98c125c5870bd93e0203442ec0e09908158ebff2a71b4872e", + "https://deno.land/std@0.159.0/node/internal/crypto/_randomInt.ts": "fddc02fb68e94f12a2023b0f98a8bf3a9eecfd7118633eebb752b6387e8f3baf", + "https://deno.land/std@0.159.0/node/internal/crypto/certificate.ts": "e0660db45a9ff176c6f6cdf1b8ccceca535790afe37901e4c8abcbf419b1f68d", + "https://deno.land/std@0.159.0/node/internal/crypto/cipher.ts": "68887462421f4f3e63762206b5e88ad88e77ae652e3a785263bb20cee8b80634", + "https://deno.land/std@0.159.0/node/internal/crypto/constants.ts": "d2c8821977aef55e4d66414d623c24a2447791a8b49b6404b8db32d81e20c315", + "https://deno.land/std@0.159.0/node/internal/crypto/diffiehellman.ts": "cccd7359947dfbc495bdf23f90c6cd94dbd0dfe70baa0f31e1fd0440b4319e92", + "https://deno.land/std@0.159.0/node/internal/crypto/hash.ts": "2f930d1756c8d16914180b1bb188677c6442dcdc3ab17b50ac1dfe4beafd2611", + "https://deno.land/std@0.159.0/node/internal/crypto/hkdf.ts": "4e4221437b95fea095a2b9d869565e8d545a135f8bbaa6a8667998185b67fc1e", + "https://deno.land/std@0.159.0/node/internal/crypto/keygen.ts": "067c4e23b0f50b12522edea98db8de83434f0aa344bc23c3d07297c5d62483e4", + "https://deno.land/std@0.159.0/node/internal/crypto/keys.ts": "9a985a04f813cdc600afb8437e06a57abc2ac4bb82783557f65af5ebeb780c2f", + "https://deno.land/std@0.159.0/node/internal/crypto/pbkdf2.ts": "bed16fa96c93a4ac397473590c21f58c50e4333a96c85bed1cff00431bf2f33c", + "https://deno.land/std@0.159.0/node/internal/crypto/random.ts": "6e3fe98367b3715a1f9039f7291a75840f1b903d4b411ce7b44ff671e64d3572", + "https://deno.land/std@0.159.0/node/internal/crypto/scrypt.ts": "120ae83f72ddcee6c6af8db3593a86aa0c926f9e563f66b0ccc303526887f337", + "https://deno.land/std@0.159.0/node/internal/crypto/sig.ts": "9d86ae6d210a23ce379b1f85a2226d9ba0279699b643a43070d1bd94c3c452e8", + "https://deno.land/std@0.159.0/node/internal/crypto/types.ts": "51201006692f584f860cad16ce20f48e74c0a4a9d3e26e31fda1b48791033ae1", + "https://deno.land/std@0.159.0/node/internal/crypto/util.ts": "818299136d987248d4dd65ecbc825244ad46cae81d4f67131c7f671459af8ca4", + "https://deno.land/std@0.159.0/node/internal/crypto/x509.ts": "8fcc114549ee0e9aca22fbddaf2f59cf7feeeb74c0189b1035d3d223a01dbd09", + "https://deno.land/std@0.159.0/node/internal/dgram.ts": "9bcc30c8dcdc420471e76b6ceed66c4bb1b93cf015c5197cd5f9b5de4b5b8f02", + "https://deno.land/std@0.159.0/node/internal/dns/promises.ts": "acd0cb07f55b0be9b24ffba86060aab63c5b5eabee400ded28445b7e2bd8590d", + "https://deno.land/std@0.159.0/node/internal/dns/utils.ts": "9de48245fa5dee62273c9aa4f0054f23abb023d5d46b165ae57198af2b92cf73", + "https://deno.land/std@0.159.0/node/internal/dtrace.ts": "50dd0e77b0269e47ff673bdb9ad0ef0ea3a3c53ac30c1695883ce4748e04ca14", + "https://deno.land/std@0.159.0/node/internal/error_codes.ts": "ac03c4eae33de3a69d6c98e8678003207eecf75a6900eb847e3fea3c8c9e6d8f", + "https://deno.land/std@0.159.0/node/internal/errors.ts": "0f66861ffd7a4ee3732098f758800a94154ad5d4bfa56aeb5480855cb3d7a8c6", + "https://deno.land/std@0.159.0/node/internal/event_target.mjs": "8da1ce027e71e59287430c3260c1ad31f1d6f5c5fc67aa6d1b906bb8fbd6d672", + "https://deno.land/std@0.159.0/node/internal/fixed_queue.ts": "455b3c484de48e810b13bdf95cd1658ecb1ba6bcb8b9315ffe994efcde3ba5f5", + "https://deno.land/std@0.159.0/node/internal/fs/streams.ts": "e68430e65f42a8d8b793babae87dfaa406d61d3c54f985dcb0e3ee532eae8d4c", + "https://deno.land/std@0.159.0/node/internal/fs/utils.mjs": "7b1bb3f46a676303d2a873bb9c36f199bd2c253451d4bd013ac906a6accea5bd", + "https://deno.land/std@0.159.0/node/internal/hide_stack_frames.ts": "a91962ec84610bc7ec86022c4593cdf688156a5910c07b5bcd71994225c13a03", + "https://deno.land/std@0.159.0/node/internal/http.ts": "deb5b3db113ea502cf01044d9f3e299e5faca2789f27f6a894947603b62c45ad", + "https://deno.land/std@0.159.0/node/internal/idna.ts": "3aed89919e3078160733b6e6ac60fdb06052cf0418acbabcf86f90017d102b78", + "https://deno.land/std@0.159.0/node/internal/net.ts": "1239886cd2508a68624c2dae8abf895e8aa3bb15a748955349f9ac5539032238", + "https://deno.land/std@0.159.0/node/internal/normalize_encoding.mjs": "3779ec8a7adf5d963b0224f9b85d1bc974a2ec2db0e858396b5d3c2c92138a0a", + "https://deno.land/std@0.159.0/node/internal/options.ts": "a23c285975e058cb26a19abcb048cd8b46ab12d21cfb028868ac8003fffb43ac", + "https://deno.land/std@0.159.0/node/internal/primordials.mjs": "7cf5afe471583e4a384eeea109bdff276b6b7f3a3af830f99f951fb7d57ef423", + "https://deno.land/std@0.159.0/node/internal/process/per_thread.mjs": "bc1be72a6a662bf81573c20fe74893374847a7302065ddf52fb3fb2af505f31f", + "https://deno.land/std@0.159.0/node/internal/querystring.ts": "c3b23674a379f696e505606ddce9c6feabe9fc497b280c56705c340f4028fe74", + "https://deno.land/std@0.159.0/node/internal/readline/callbacks.mjs": "17d9270a54fb5dceea8f894669e3401e5c6260bab075a1e9675a62f1fef50d8c", + "https://deno.land/std@0.159.0/node/internal/readline/emitKeypressEvents.mjs": "a8cff1322c779d8431cf4b82419e002077eae603325d671d2a963d5c278cb180", + "https://deno.land/std@0.159.0/node/internal/readline/interface.mjs": "51015c1afc0476d21efe7b667de46c9f5f14ca95748cbc2d3ffb980cd7202e13", + "https://deno.land/std@0.159.0/node/internal/readline/symbols.mjs": "0f0d16b7d64dfb953bee5575cbb7f69db8ad036ab0f3e640b357715daab2501d", + "https://deno.land/std@0.159.0/node/internal/readline/utils.mjs": "a93ebb99f85e0dbb4f05f4aff5583d12a15150e45c335e4ecf925e1879dc9c84", + "https://deno.land/std@0.159.0/node/internal/stream_base_commons.ts": "07c8d367cf7d0f6f48a248d39fbe611af6dad02a33510fcc3ef23f9a9c898b41", + "https://deno.land/std@0.159.0/node/internal/streams/add-abort-signal.mjs": "5623b83fa64d439cc4a1f09ae47ec1db29512cc03479389614d8f62a37902f5e", + "https://deno.land/std@0.159.0/node/internal/streams/buffer_list.mjs": "c6a7b29204fae025ff5e9383332acaea5d44bc7c522a407a79b8f7a6bc6c312d", + "https://deno.land/std@0.159.0/node/internal/streams/destroy.mjs": "9c9bbeb172a437041d529829f433df72cf0b63ae49f3ee6080a55ffbef7572ad", + "https://deno.land/std@0.159.0/node/internal/streams/duplex.mjs": "92a81a46e56c8aa7d9581f047db99ed677882c9f20cba0dd68abd668a38f161a", + "https://deno.land/std@0.159.0/node/internal/streams/end-of-stream.mjs": "38be76eaceac231dfde643e72bc0940625446bf6d1dbd995c91c5ba9fd59b338", + "https://deno.land/std@0.159.0/node/internal/streams/lazy_transform.mjs": "eb08aa34414427ffd06cbed094917200c6bf87d8d31bf32285f8bee024d58de1", + "https://deno.land/std@0.159.0/node/internal/streams/passthrough.mjs": "3fe5f38e68270d0948ea2731a821cbdc4cc3aea275aa74c3a15f92d10fac088b", + "https://deno.land/std@0.159.0/node/internal/streams/readable.mjs": "b537dbed44d357a8867829f9937291ecfbb725b99cdd6e21708b8807d9a5788a", + "https://deno.land/std@0.159.0/node/internal/streams/state.mjs": "ec61f2fb46f58ab77b9dccd5ab10ad86753a26f858f7b4b7aaf85e9359c77557", + "https://deno.land/std@0.159.0/node/internal/streams/transform.mjs": "cd4fdb3281fcb43d93746103efa6ade76d573c62c87d499f412eb60cb1aa8567", + "https://deno.land/std@0.159.0/node/internal/streams/utils.mjs": "a0a6b93a7e68ef52bef4ed00b0c82bb7e335abf360af57335b07c6a3fcdde717", + "https://deno.land/std@0.159.0/node/internal/streams/writable.mjs": "b4d2069f0403dd4a0008d315de924b1d78e5135107a2eb41c41d04c3981791fc", + "https://deno.land/std@0.159.0/node/internal/test/binding.ts": "d0b90030dc2186cccd8696efb7edc57ba7389851338608da2509aa7ab19c0f59", + "https://deno.land/std@0.159.0/node/internal/timers.mjs": "b43e24580cec2dd50f795e4342251a79515c0db21630c25b40fdc380a78b74e7", + "https://deno.land/std@0.159.0/node/internal/url.ts": "eacef0ace4f4c5394e9818a81499f4871b2a993d1bd3b902047e44a381ef0e22", + "https://deno.land/std@0.159.0/node/internal/util.mjs": "35d24fb775468cd24443bcf0ec68904b8aa44e5b53845491a5e3382421917f9a", + "https://deno.land/std@0.159.0/node/internal/util/comparisons.ts": "4093f52f05d84842b46496e448fa8d708e25c6d6b8971505fd4913a9d7075934", + "https://deno.land/std@0.159.0/node/internal/util/debuglog.ts": "570c399f0a066b81f0836eeb926b9142ae7f1738cee9abd85cd12ce32092d5af", + "https://deno.land/std@0.159.0/node/internal/util/inspect.mjs": "1ddace0c97719d2cc0869ba177d375e96051301352ec235cbfb2ecbfcd4e8fba", + "https://deno.land/std@0.159.0/node/internal/util/types.ts": "de6e2b7f9b9985ab881b1e78f05ae51d1fc829ae1584063df21e57b35312f3c4", + "https://deno.land/std@0.159.0/node/internal/validators.mjs": "67deae0f488d013c8bf485742a5478112b8e48837cb2458c4a8b2669cf7017db", + "https://deno.land/std@0.159.0/node/internal_binding/_libuv_winerror.ts": "801e05c2742ae6cd42a5f0fd555a255a7308a65732551e962e5345f55eedc519", + "https://deno.land/std@0.159.0/node/internal_binding/_listen.ts": "c15a356ef4758770fc72d3ca4db33f0cc321016df1aafb927c027c0d73ac2c42", + "https://deno.land/std@0.159.0/node/internal_binding/_node.ts": "e4075ba8a37aef4eb5b592c8e3807c39cb49ca8653faf8e01a43421938076c1b", + "https://deno.land/std@0.159.0/node/internal_binding/_timingSafeEqual.ts": "80640f055101071cb3680a2d8a1fead5fd260ca8bf183efb94296b69463e06cd", + "https://deno.land/std@0.159.0/node/internal_binding/_utils.ts": "1c50883b5751a9ea1b38951e62ed63bacfdc9d69ea665292edfa28e1b1c5bd94", + "https://deno.land/std@0.159.0/node/internal_binding/_winerror.ts": "8811d4be66f918c165370b619259c1f35e8c3e458b8539db64c704fbde0a7cd2", + "https://deno.land/std@0.159.0/node/internal_binding/ares.ts": "33ff8275bc11751219af8bd149ea221c442d7e8676e3e9f20ccb0e1f0aac61b8", + "https://deno.land/std@0.159.0/node/internal_binding/async_wrap.ts": "b83e4021a4854b2e13720f96d21edc11f9905251c64c1bc625a361f574400959", + "https://deno.land/std@0.159.0/node/internal_binding/buffer.ts": "781e1d13adc924864e6e37ecb5152e8a4e994cf394695136e451c47f00bda76c", + "https://deno.land/std@0.159.0/node/internal_binding/cares_wrap.ts": "720e6d5cff7018bb3d00e1a49dd4c31f0fc6af3a593ab68cd39e3592ed163d61", + "https://deno.land/std@0.159.0/node/internal_binding/config.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/connection_wrap.ts": "9debd4210d29c658054476fcb640c900725f564ef35412c56dc79eb07213a7c1", + "https://deno.land/std@0.159.0/node/internal_binding/constants.ts": "f4afc504137fb21f3908ab549931604968dfa62432b285a0874f41c4cade9ed2", + "https://deno.land/std@0.159.0/node/internal_binding/contextify.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/credentials.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/crypto.ts": "d7f39700dc020364edf7f4785e5026bb91f099ce1bd02734182451b1af300c8c", + "https://deno.land/std@0.159.0/node/internal_binding/errors.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/fs.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/fs_dir.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/fs_event_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/handle_wrap.ts": "3767a610b7b12c42635d7100f843981f0454ee3be7da249f85187e45043c3acd", + "https://deno.land/std@0.159.0/node/internal_binding/heap_utils.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/http_parser.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/icu.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/inspector.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/js_stream.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/messaging.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/mod.ts": "f68e74e8eed84eaa6b0de24f0f4c47735ed46866d7ee1c5a5e7c0667b4f0540f", + "https://deno.land/std@0.159.0/node/internal_binding/module_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/native_module.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/natives.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/node_file.ts": "c96ee0b2af319a3916de950a6c4b0d5fb00d09395c51cd239c54d95d62567aaf", + "https://deno.land/std@0.159.0/node/internal_binding/node_options.ts": "3cd5706153d28a4f5944b8b162c1c61b7b8e368a448fb1a2cff9f7957d3db360", + "https://deno.land/std@0.159.0/node/internal_binding/options.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/os.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/performance.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/pipe_wrap.ts": "ef88498ce6ff185966b6e0ba2aa0880e88eb92e8815e02e8e23fef9e711353e1", + "https://deno.land/std@0.159.0/node/internal_binding/process_methods.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/report.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/serdes.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/signal_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/spawn_sync.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/stream_wrap.ts": "ecbd50a6c6ff7f6fea9bdfdc7b3977637cd854814c812b59296458ca2f0fc209", + "https://deno.land/std@0.159.0/node/internal_binding/string_decoder.ts": "5cb1863763d1e9b458bc21d6f976f16d9c18b3b3f57eaf0ade120aee38fba227", + "https://deno.land/std@0.159.0/node/internal_binding/symbols.ts": "51cfca9bb6132d42071d4e9e6b68a340a7f274041cfcba3ad02900886e972a6c", + "https://deno.land/std@0.159.0/node/internal_binding/task_queue.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/tcp_wrap.ts": "555eb9e05099c051c3e330829209668142555a1c90411e2e97a0385e33bb8bf2", + "https://deno.land/std@0.159.0/node/internal_binding/timers.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/tls_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/trace_events.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/tty_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/types.ts": "4c26fb74ba2e45de553c15014c916df6789529a93171e450d5afb016b4c765e7", + "https://deno.land/std@0.159.0/node/internal_binding/udp_wrap.ts": "c4d98462a713e77a4bfa58cbc62207fae3fbe0b3e288cdbb9f7ff829ea12ad21", + "https://deno.land/std@0.159.0/node/internal_binding/url.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/util.ts": "faf5146c3cc3b2d6c26026a818b4a16e91488ab26e63c069f36ba3c3ae24c97b", + "https://deno.land/std@0.159.0/node/internal_binding/uv.ts": "8c5b971a7e5e66584274fcb8aab958a6b3d5a6232ee8b3dec09b24047d6c008d", + "https://deno.land/std@0.159.0/node/internal_binding/v8.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/worker.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/internal_binding/zlib.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.159.0/node/module.ts": "5fff51c9f39fc459cd539c45b9d3fb1e666500a81340db7ef5051f6ee0092f06", + "https://deno.land/std@0.159.0/node/module_all.ts": "8613f8bb1de2b4f0a7dd78b73dfb1938f419eba23b016d1aa6ad457c98fda681", + "https://deno.land/std@0.159.0/node/module_esm.ts": "942177862e534a18b9dd16e7f555685161823fb3b394c01af9380a1101d2400f", + "https://deno.land/std@0.159.0/node/net.ts": "aa331d3a047e2c8d1301fdd0906c86e49c8aa38a5721fd5bf71cab71486d1ee8", + "https://deno.land/std@0.159.0/node/os.ts": "96b191f0de80cdc914aa3a78de3827a8658387b1b17ccccc6ed58db3e029ec76", + "https://deno.land/std@0.159.0/node/path.ts": "c65858e9cbb52dbc0dd348eefcdc41e82906c39cfa7982f2d4d805e828414b8c", + "https://deno.land/std@0.159.0/node/path/_constants.ts": "591787ca44a55859644a2d5dbaef43698ab29e72e58fd498ea5e8f78a341ba20", + "https://deno.land/std@0.159.0/node/path/_interface.ts": "6034ee29f6f295460ec82db1a94df9269aecbb0eceb81be72e9d843f8e8a97e6", + "https://deno.land/std@0.159.0/node/path/_util.ts": "70b4b58098c4638f3bf719fa700c95e308e3984a3f9aca551fab713426ba3cbe", + "https://deno.land/std@0.159.0/node/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4", + "https://deno.land/std@0.159.0/node/path/glob.ts": "d6b64a24f148855a6e8057a171a2f9910c39e492e4ccec482005205b28eb4533", + "https://deno.land/std@0.159.0/node/path/mod.ts": "f9125e20031aac43eef8baa58d852427c762541574513f6870d1d0abd8103252", + "https://deno.land/std@0.159.0/node/path/posix.ts": "8d92e4e7a9257eebe13312623a89fa0bdc8f75f81adc66641e10edc0a03bc3dd", + "https://deno.land/std@0.159.0/node/path/separator.ts": "c908c9c28ebe7f1fea67daaccf84b63af90d882fe986f9fa03af9563a852723a", + "https://deno.land/std@0.159.0/node/path/win32.ts": "06878dde1d89232c8c0dedd4ebe0420cc0ca73696f6028e4c57f802d9f7998a3", + "https://deno.land/std@0.159.0/node/perf_hooks.ts": "521454a3e74f0af6a2aad91fa59a22a2c7be19698106a0fbbbcf22ac33764105", + "https://deno.land/std@0.159.0/node/process.ts": "77271a8077885c9b322cd2a90145d03ccf4d329090aff173d96f35764221ec69", + "https://deno.land/std@0.159.0/node/punycode.ts": "11b5821448d9e815058a18e5a86ea7abfacc55dca9d444ff27af81069068e2e3", + "https://deno.land/std@0.159.0/node/querystring.ts": "ec6d8bd8b138a5c53fe8bc25a37bdd654d5fb76918fb6543864d62474b3860a8", + "https://deno.land/std@0.159.0/node/readline.ts": "9ec1f376b48be96caa06ba91f8700f9f16e0cc423f742163337cb77193fbb204", + "https://deno.land/std@0.159.0/node/repl.ts": "c44c0cdd9ecd749d23cc7e80c76a2a63388e206e68e117955a677f56362635fe", + "https://deno.land/std@0.159.0/node/stream.ts": "2c6d5d207d0ad295f396b34fd03a908c1638beb1754bc9c1fccd9a4cdcace8be", + "https://deno.land/std@0.159.0/node/stream/consumers.mjs": "2a8bd472d67bbbbf4da4dc9b1d5ed0cf3c5ce33f91f47f7461145224ed967dd6", + "https://deno.land/std@0.159.0/node/stream/promises.mjs": "def278cdd69aac41190a8fcbe782a9bbcfea3780106a81393a484e9c77c5bae2", + "https://deno.land/std@0.159.0/node/stream/web.ts": "7abcc6cdb9103ae4cff3c6c17e9f7befbbebab1a857d4887c96d9547b2bf6ea0", + "https://deno.land/std@0.159.0/node/string_decoder.ts": "51ce85a173d2e36ac580d418bb48b804adb41732fc8bd85f7d5d27b7accbc61f", + "https://deno.land/std@0.159.0/node/sys.ts": "c57c09c22d4aae822e81129cd8da693bffc7e13d6fe9c3cb9d6d60e60d18f83b", + "https://deno.land/std@0.159.0/node/timers.ts": "ce7574023960d85c6d884d4f5b109a4b96bb58435cb97b46fe047714f4fafc7b", + "https://deno.land/std@0.159.0/node/timers/promises.ts": "44bb1cc745eeefc59c059b4b9f2771e78231b7bf874e0362d7b2bb1480a3452b", + "https://deno.land/std@0.159.0/node/tls.ts": "68b9f801654f04664215c2f682d2ce32271c9e2a770cd91285ee864c6c56e0eb", + "https://deno.land/std@0.159.0/node/tty.ts": "ccc2f899c1813ce29e6059319b423008125d2b0b7db816fa4cbee3df4f68325b", + "https://deno.land/std@0.159.0/node/upstream_modules.ts": "31b3829a13b919edd970808dae2050dce3bee963393bd5e20a34eae5f2dcb81f", + "https://deno.land/std@0.159.0/node/url.ts": "79e7f78678301df8cf15c5f0d4446a7e4754a9e641a99da41b8c0568587e9b84", + "https://deno.land/std@0.159.0/node/util.ts": "e926d996318017b41812983cacde63e39c92385de7a19a0cb788fc68f73318db", + "https://deno.land/std@0.159.0/node/util/types.ts": "5948b43e834f73a4becf85b02049632560c65da9c1127e5c533c83d200d3dfcd", + "https://deno.land/std@0.159.0/node/v8.ts": "238e7214b2c5e8dff3fa8aae7f215b312099728611a1d5c8c5fd25f12d9dd004", + "https://deno.land/std@0.159.0/node/vm.ts": "4043eafffa3fff0668d3a59162e9b0fbd4b5b43a793ba553fe787a2cae6e149b", + "https://deno.land/std@0.159.0/node/wasi.ts": "26217f6bfeba85543af0c6aa18ed7202561d8d18c797409b1bb9840e99447297", + "https://deno.land/std@0.159.0/node/worker_threads.ts": "e8ac2bc93e4d1d775e21737fefd0f44bf95d60d477cbd3e47fbe8e541eebdcaf", + "https://deno.land/std@0.159.0/node/zlib.ts": "048c59e4c928be22146f3418c6a2fdb5417eb1e756213659c2a92ab5a865d2d1", + "https://deno.land/std@0.159.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.159.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.159.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", + "https://deno.land/std@0.159.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.159.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", + "https://deno.land/std@0.159.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac", + "https://deno.land/std@0.159.0/path/posix.ts": "c1f7afe274290ea0b51da07ee205653b2964bd74909a82deb07b69a6cc383aaa", + "https://deno.land/std@0.159.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.159.0/path/win32.ts": "bd7549042e37879c68ff2f8576a25950abbfca1d696d41d82c7bca0b7e6f452c", + "https://deno.land/std@0.159.0/streams/conversion.ts": "cc6ead659fd851f2461415e6a1b63844e36d8f467a31d600963d317c452cdeec", + "https://deno.land/std@0.159.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", + "https://deno.land/std@0.159.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", + "https://deno.land/std@0.159.0/testing/asserts.ts": "9ff3259f6cdc2908af478f9340f4e470d23234324bd33e7f74c683a00ed4d211", + "https://deno.land/std@0.159.0/wasi/snapshot_preview1.ts": "f6d40600d5d479ed031bcde234f750fd88761657b51686ce20162d9d651a0563", + "https://deno.land/std@0.161.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", + "https://deno.land/std@0.161.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", + "https://deno.land/std@0.161.0/fmt/colors.ts": "9e36a716611dcd2e4865adea9c4bec916b5c60caad4cdcdc630d4974e6bb8bd4", + "https://deno.land/std@0.161.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.161.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.161.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", + "https://deno.land/std@0.161.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.161.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", + "https://deno.land/std@0.161.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac", + "https://deno.land/std@0.161.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24", + "https://deno.land/std@0.161.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.161.0/path/win32.ts": "ee8826dce087d31c5c81cd414714e677eb68febc40308de87a2ce4b40e10fb8d", + "https://deno.land/std@0.161.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", + "https://deno.land/std@0.161.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", + "https://deno.land/std@0.161.0/testing/asserts.ts": "1e340c589853e82e0807629ba31a43c84ebdcdeca910c4a9705715dfdb0f5ce8", + "https://deno.land/std@0.170.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", + "https://deno.land/std@0.170.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", + "https://deno.land/std@0.170.0/async/abortable.ts": "80b2ac399f142cc528f95a037a7d0e653296352d95c681e284533765961de409", + "https://deno.land/std@0.170.0/async/deadline.ts": "2c2deb53c7c28ca1dda7a3ad81e70508b1ebc25db52559de6b8636c9278fd41f", + "https://deno.land/std@0.170.0/async/debounce.ts": "60301ffb37e730cd2d6f9dadfd0ecb2a38857681bd7aaf6b0a106b06e5210a98", + "https://deno.land/std@0.170.0/async/deferred.ts": "77d3f84255c3627f1cc88699d8472b664d7635990d5358c4351623e098e917d6", + "https://deno.land/std@0.170.0/async/delay.ts": "5a9bfba8de38840308a7a33786a0155a7f6c1f7a859558ddcec5fe06e16daf57", + "https://deno.land/std@0.170.0/async/mod.ts": "7809ad4bb223e40f5fdc043e5c7ca04e0e25eed35c32c3c32e28697c553fa6d9", + "https://deno.land/std@0.170.0/async/mux_async_iterator.ts": "770a0ff26c59f8bbbda6b703a2235f04e379f73238e8d66a087edc68c2a2c35f", + "https://deno.land/std@0.170.0/async/pool.ts": "6854d8cd675a74c73391c82005cbbe4cc58183bddcd1fbbd7c2bcda42b61cf69", + "https://deno.land/std@0.170.0/async/retry.ts": "e8e5173623915bbc0ddc537698fa418cf875456c347eda1ed453528645b42e67", + "https://deno.land/std@0.170.0/async/tee.ts": "3a47cc4e9a940904fd4341f0224907e199121c80b831faa5ec2b054c6d2eff5e", + "https://deno.land/std@0.170.0/bytes/index_of_needle.ts": "19db73583cf6e038ca7763dd9fe9b9f2733ffa8be530d7b42f52bc9f3c2ee989", + "https://deno.land/std@0.170.0/crypto/_wasm/lib/deno_std_wasm_crypto.generated.mjs": "71c1ac20f32fdbdc9b31a14917779c7fa392dbc8b050059cbb2c35b400b975b1", + "https://deno.land/std@0.170.0/crypto/_wasm/mod.ts": "b49ec171049bbbaaed3c5a5a71dfcb3d09f880607c8d9c517638d0443bd0f874", + "https://deno.land/std@0.170.0/crypto/timing_safe_equal.ts": "3784958e40a5fe10429a68b75cc5f8d34356bf0bc2eb93c80c3033e2a6f17821", + "https://deno.land/std@0.170.0/encoding/base64.ts": "8605e018e49211efc767686f6f687827d7f5fd5217163e981d8d693105640d7a", + "https://deno.land/std@0.170.0/encoding/base64url.ts": "0283b12fcd306c11e3cf26fc022fecc800c6acc19704ea8bdb3908898fcd06d6", + "https://deno.land/std@0.170.0/encoding/hex.ts": "b51e99b684486a3ad2406807a8be953f5ef8bac95af202774a759f9fcf0d87a6", + "https://deno.land/std@0.170.0/flags/mod.ts": "4f50ec6383c02684db35de38b3ffb2cd5b9fcfcc0b1147055d1980c49e82521c", + "https://deno.land/std@0.170.0/node/_core.ts": "92e000441742387f7ded7cc582ca4089c0bc13aa5f00cecdfa4876dc832dfd10", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/base/buffer.js": "73beb8294eb29bd61458bbaaeeb51dfad4ec9c9868a62207a061d908f1637261", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/base/node.js": "88f931593f8a560a8b15acfb7878a7e21d3619d6ea29d5d1a77f3dc2f69bc969", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/base/reporter.js": "8e4886e8ae311c9a92caf58bbbd8670326ceeae97430f4884e558e4acf8e8598", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/constants/der.js": "354b255479bff22a31d25bf08b217a295071700e37d0991cc05cac9f95e5e7ca", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/decoders/der.js": "c6faf66761daa43fbf79221308443893587c317774047b508a04c570713b76fb", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/decoders/pem.js": "8316ef7ce2ce478bc3dc1e9df1b75225d1eb8fb5d1378f8adf0cf19ecea5b501", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/encoders/der.js": "408336c88d17c5605ea64081261cf42267d8f9fda90098cb560aa6635bb00877", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/encoders/pem.js": "42a00c925b68c0858d6de0ba41ab89935b39fae9117bbf72a9abb2f4b755a2e7", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/asn1.js/mod.js": "7b78859707be10a0a1e4faccdd28cd5a4f71ad74a3e7bebda030757da97cd232", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/bn.js/bn.js": "abd1badd659fd0ae54e6a421a573a25aef4e795edc392178360cf716b144286d", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/aes.js": "1cf4c354c5bb341ffc9ab7207f471229835b021947225bce2e1642f26643847a", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/auth_cipher.js": "19b4dbb903e8406eb733176e6318d5e1a3bd382b67b72f7cf8e1c46cc6321ba4", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/decrypter.js": "05c1676942fd8e95837115bc2d1371bcf62e9bf19f6c3348870961fc64ddad0b", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/encrypter.js": "93ec98ab26fbeb5969eae2943e42fb66780f377b9b0ff0ecc32a9ed11201b142", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/ghash.js": "667b64845764a84f0096ef8cf7debed1a5f15ac9af26b379848237be57da399a", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/incr32.js": "4a7f0107753e4390b4ccc4dbd5200c5527d43f894f768e131903df30a09dfd67", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/mod.js": "d8eb88e7a317467831473621f32e60d7db9d981f6a2ae45d2fb2af170eab2d22", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/cbc.js": "9790799cff181a074686c885708cb8eb473aeb3c86ff2e8d0ff911ae6c1e4431", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb.js": "a4e36ede6f26d8559d8f0528a134592761c706145a641bd9ad1100763e831cdb", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb1.js": "c6372f4973a68ca742682e81d1165e8869aaabf0091a8b963d4d60e5ee8e6f6a", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/cfb8.js": "bd29eebb89199b056ff2441f7fb5e0300f458e13dcaaddbb8bc00cbdb199db67", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/ctr.js": "9c2cbac1fc8f9b58334faacb98e6c57e8c3712f673ea4cf2d528a2894998ab2f", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/ecb.js": "9629d193433688f0cfc432eca52838db0fb28d9eb4f45563df952bde50b59763", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/mod.js": "7d8516ef8a20565539eb17cad5bb70add02ac06d1891e8f47cb981c22821787e", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/modes/ofb.js": "c23abaa6f1ec5343e9d7ba61d702acb3d81a0bd3d34dd2004e36975dc043d6ff", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/stream_cipher.js": "a533a03a2214c6b5934ce85a59eb1e04239fd6f429017c7ca3c443ec7e07e68f", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_aes/xor.ts": "4417711c026eb9a07475067cd31fa601e88c2d6ababd606d33d1e74da6fcfd09", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/browserify_rsa.js": "de8c98d2379a70d8c239b4886e2b3a11c7204eec39ae6b65d978d0d516ee6b08", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/cipher_base.js": "f565ad9daf3b3dd3b68381bed848da94fb093a9e4e5a48c92f47e26cc229df39", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/evp_bytes_to_key.ts": "006ecde1cc428b16f2d214637c35296279710b3a8d211dcf91d561c2cd692125", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/parse_asn1/asn1.js": "4f33b0197ffbe9cff62e5bad266e6b40d55874ea653552bb32ed251ad091f70a", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/parse_asn1/certificate.js": "aab306870830a81ad188db8fa8e037d7f5dd6c5abdabbd9739558245d1a12224", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/parse_asn1/fix_proc.js": "af3052b76f441878e102ffcfc7420692e65777af765e96f786310ae1acf7f76a", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/parse_asn1/mod.js": "9d445baecb55d4abbe6434be55d5bb7976ea752c3976a75b7e2684a7d440a576", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/public_encrypt/mgf.js": "46cf72f2b9aa678a15daef8ed551241f2d0c1ca38b8b6c8e5226a4853540d7b2", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/public_encrypt/mod.js": "eb8b64d7a58ee3823c1b642e799cc7ed1257d99f4d4aefa2b4796dd112ec094a", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/public_encrypt/private_decrypt.js": "220de06bf9edcfa4def18c30a16aa0482b1ddc24f8712f46509e82b1e3be122c", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/public_encrypt/public_encrypt.js": "4679188b9b38502ac21b5e1a5bcfbcd278f51854f5385aac8d9962d7c21b2a6e", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/public_encrypt/with_public.js": "7373dac9b53b8331ccf3521c854a131dcb304a2e4d34cd116649118f7919ed0c", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/public_encrypt/xor.js": "900c6fc8b95e1861d796193c41988f5f70a09c7059e42887a243d0113ecaf0fd", + "https://deno.land/std@0.170.0/node/_crypto/crypto_browserify/randombytes.ts": "414e2a13f1ea79980acba7b4634821b7a636f1856eedbba7cce8fa528c11e9a0", + "https://deno.land/std@0.170.0/node/_events.d.ts": "3899ee9c37055fbb750e32cb43d7c435077c04446af948300080e1a590c6edf0", + "https://deno.land/std@0.170.0/node/_events.mjs": "303e8aa60ace559e4ca0d19e8475f87311bee9e8330b4b497644d70f2002fc27", + "https://deno.land/std@0.170.0/node/_global.d.ts": "6dadaf8cec2a0c506b22170617286e0bdc80be53dd0673e67fc7dd37a1130c68", + "https://deno.land/std@0.170.0/node/_next_tick.ts": "81c1826675493b76f90c646fb1274a4c84b5cc913a80ca4526c32cd7c46a0b06", + "https://deno.land/std@0.170.0/node/_process/exiting.ts": "bc9694769139ffc596f962087155a8bfef10101d03423b9dcbc51ce6e1f88fce", + "https://deno.land/std@0.170.0/node/_process/process.ts": "d5bf113a4b62f4abe4cb7ec5a9d00d44dac0ad9e82a23d00cc27d71e05ae8b66", + "https://deno.land/std@0.170.0/node/_process/stdio.mjs": "971c3b086040d8521562155db13f22f9971d5c42c852b2081d4d2f0d8b6ab6bd", + "https://deno.land/std@0.170.0/node/_process/streams.mjs": "3ce63d9eb24a8a8ec45eeebf5c184b43d888064f663f87e8f453888368e00f90", + "https://deno.land/std@0.170.0/node/_stream.d.ts": "83e9da2f81de3205926f1e86ba54442aa5a3caf4c5e84a4c8699402ad340142b", + "https://deno.land/std@0.170.0/node/_stream.mjs": "9a80217d9734f6e4284aae0ea55dd82b243f5517bc1814e983ad41b01732f712", + "https://deno.land/std@0.170.0/node/_utils.ts": "1085a229e910b3a6672f3c3be05507591811dc67be2ae2188e9fc9ef4376b172", + "https://deno.land/std@0.170.0/node/buffer.ts": "43f07b2d1523345bf35b7deb7fcdad6e916020a631a7bc1b5efcaff556db4e1d", + "https://deno.land/std@0.170.0/node/crypto.ts": "1cdf38d3712a37f9d13ee85f535f664a7ce4aca970215f91328d310c9f59f922", + "https://deno.land/std@0.170.0/node/events.ts": "f848398d3591534ca94ac6b852a9f3c4dbb2da310c3a26059cf4ff06b7eae088", + "https://deno.land/std@0.170.0/node/internal/buffer.d.ts": "90f674081428a61978b6d481c5f557ff743a3f4a85d7ae113caab48fdf5b8a63", + "https://deno.land/std@0.170.0/node/internal/buffer.mjs": "50320a6bcf770f03428e77e5ba46b19f69842539c6493b89c6515ba1b0def6ee", + "https://deno.land/std@0.170.0/node/internal/crypto/_keys.ts": "63229ff3d8d15b5bd0a1d2ebc19313cbb8ac969875bf16df1ce4f2b497d74fb5", + "https://deno.land/std@0.170.0/node/internal/crypto/_randomBytes.ts": "0d80254f7be71aa3212956071d22f2cb2193aa1f957e48286f83ccf092abe4e5", + "https://deno.land/std@0.170.0/node/internal/crypto/_randomFill.ts": "4d9157fd8a4894f98c125c5870bd93e0203442ec0e09908158ebff2a71b4872e", + "https://deno.land/std@0.170.0/node/internal/crypto/_randomInt.ts": "fddc02fb68e94f12a2023b0f98a8bf3a9eecfd7118633eebb752b6387e8f3baf", + "https://deno.land/std@0.170.0/node/internal/crypto/certificate.ts": "e0660db45a9ff176c6f6cdf1b8ccceca535790afe37901e4c8abcbf419b1f68d", + "https://deno.land/std@0.170.0/node/internal/crypto/cipher.ts": "68887462421f4f3e63762206b5e88ad88e77ae652e3a785263bb20cee8b80634", + "https://deno.land/std@0.170.0/node/internal/crypto/constants.ts": "d2c8821977aef55e4d66414d623c24a2447791a8b49b6404b8db32d81e20c315", + "https://deno.land/std@0.170.0/node/internal/crypto/diffiehellman.ts": "cccd7359947dfbc495bdf23f90c6cd94dbd0dfe70baa0f31e1fd0440b4319e92", + "https://deno.land/std@0.170.0/node/internal/crypto/hash.ts": "80bae847bf409dc0f7e0bb1645b776956d70a5b26c2daf7eba4e629a4aebfde3", + "https://deno.land/std@0.170.0/node/internal/crypto/hkdf.ts": "4e4221437b95fea095a2b9d869565e8d545a135f8bbaa6a8667998185b67fc1e", + "https://deno.land/std@0.170.0/node/internal/crypto/keygen.ts": "067c4e23b0f50b12522edea98db8de83434f0aa344bc23c3d07297c5d62483e4", + "https://deno.land/std@0.170.0/node/internal/crypto/keys.ts": "e3cf124162b0e116544b7c3741cff39ea8e935314bc26e08d6e02414392fd8bb", + "https://deno.land/std@0.170.0/node/internal/crypto/pbkdf2.ts": "bed16fa96c93a4ac397473590c21f58c50e4333a96c85bed1cff00431bf2f33c", + "https://deno.land/std@0.170.0/node/internal/crypto/random.ts": "6e3fe98367b3715a1f9039f7291a75840f1b903d4b411ce7b44ff671e64d3572", + "https://deno.land/std@0.170.0/node/internal/crypto/scrypt.ts": "120ae83f72ddcee6c6af8db3593a86aa0c926f9e563f66b0ccc303526887f337", + "https://deno.land/std@0.170.0/node/internal/crypto/sig.ts": "9d86ae6d210a23ce379b1f85a2226d9ba0279699b643a43070d1bd94c3c452e8", + "https://deno.land/std@0.170.0/node/internal/crypto/types.ts": "51201006692f584f860cad16ce20f48e74c0a4a9d3e26e31fda1b48791033ae1", + "https://deno.land/std@0.170.0/node/internal/crypto/util.ts": "ac3533ebc4a0136495d95f20dc3b5ea209e6693b973373f6c13ce021b42fed57", + "https://deno.land/std@0.170.0/node/internal/crypto/x509.ts": "8fcc114549ee0e9aca22fbddaf2f59cf7feeeb74c0189b1035d3d223a01dbd09", + "https://deno.land/std@0.170.0/node/internal/error_codes.ts": "ac03c4eae33de3a69d6c98e8678003207eecf75a6900eb847e3fea3c8c9e6d8f", + "https://deno.land/std@0.170.0/node/internal/errors.ts": "b9aec7d1fe3eaf21322d0ea9dc2fcb344055d6b0c7a1bd0f62a0c379a5baa799", + "https://deno.land/std@0.170.0/node/internal/fixed_queue.ts": "455b3c484de48e810b13bdf95cd1658ecb1ba6bcb8b9315ffe994efcde3ba5f5", + "https://deno.land/std@0.170.0/node/internal/hide_stack_frames.ts": "a91962ec84610bc7ec86022c4593cdf688156a5910c07b5bcd71994225c13a03", + "https://deno.land/std@0.170.0/node/internal/net.ts": "1239886cd2508a68624c2dae8abf895e8aa3bb15a748955349f9ac5539032238", + "https://deno.land/std@0.170.0/node/internal/normalize_encoding.mjs": "3779ec8a7adf5d963b0224f9b85d1bc974a2ec2db0e858396b5d3c2c92138a0a", + "https://deno.land/std@0.170.0/node/internal/options.ts": "a23c285975e058cb26a19abcb048cd8b46ab12d21cfb028868ac8003fffb43ac", + "https://deno.land/std@0.170.0/node/internal/primordials.mjs": "7cf5afe471583e4a384eeea109bdff276b6b7f3a3af830f99f951fb7d57ef423", + "https://deno.land/std@0.170.0/node/internal/process/per_thread.mjs": "bc1be72a6a662bf81573c20fe74893374847a7302065ddf52fb3fb2af505f31f", + "https://deno.land/std@0.170.0/node/internal/readline/callbacks.mjs": "17d9270a54fb5dceea8f894669e3401e5c6260bab075a1e9675a62f1fef50d8c", + "https://deno.land/std@0.170.0/node/internal/readline/utils.mjs": "a93ebb99f85e0dbb4f05f4aff5583d12a15150e45c335e4ecf925e1879dc9c84", + "https://deno.land/std@0.170.0/node/internal/streams/destroy.mjs": "9c9bbeb172a437041d529829f433df72cf0b63ae49f3ee6080a55ffbef7572ad", + "https://deno.land/std@0.170.0/node/internal/streams/end-of-stream.mjs": "38be76eaceac231dfde643e72bc0940625446bf6d1dbd995c91c5ba9fd59b338", + "https://deno.land/std@0.170.0/node/internal/streams/utils.mjs": "a0a6b93a7e68ef52bef4ed00b0c82bb7e335abf360af57335b07c6a3fcdde717", + "https://deno.land/std@0.170.0/node/internal/streams/writable.mjs": "b4d2069f0403dd4a0008d315de924b1d78e5135107a2eb41c41d04c3981791fc", + "https://deno.land/std@0.170.0/node/internal/util.mjs": "35d24fb775468cd24443bcf0ec68904b8aa44e5b53845491a5e3382421917f9a", + "https://deno.land/std@0.170.0/node/internal/util/inspect.mjs": "1ddace0c97719d2cc0869ba177d375e96051301352ec235cbfb2ecbfcd4e8fba", + "https://deno.land/std@0.170.0/node/internal/util/types.ts": "5b15a8051a6e58b6c1a424e0e7137b77b0ef60409d54d05db22a97e0d1d5b589", + "https://deno.land/std@0.170.0/node/internal/validators.mjs": "67deae0f488d013c8bf485742a5478112b8e48837cb2458c4a8b2669cf7017db", + "https://deno.land/std@0.170.0/node/internal_binding/_libuv_winerror.ts": "801e05c2742ae6cd42a5f0fd555a255a7308a65732551e962e5345f55eedc519", + "https://deno.land/std@0.170.0/node/internal_binding/_listen.ts": "c15a356ef4758770fc72d3ca4db33f0cc321016df1aafb927c027c0d73ac2c42", + "https://deno.land/std@0.170.0/node/internal_binding/_node.ts": "e4075ba8a37aef4eb5b592c8e3807c39cb49ca8653faf8e01a43421938076c1b", + "https://deno.land/std@0.170.0/node/internal_binding/_timingSafeEqual.ts": "80640f055101071cb3680a2d8a1fead5fd260ca8bf183efb94296b69463e06cd", + "https://deno.land/std@0.170.0/node/internal_binding/_utils.ts": "1c50883b5751a9ea1b38951e62ed63bacfdc9d69ea665292edfa28e1b1c5bd94", + "https://deno.land/std@0.170.0/node/internal_binding/_winerror.ts": "8811d4be66f918c165370b619259c1f35e8c3e458b8539db64c704fbde0a7cd2", + "https://deno.land/std@0.170.0/node/internal_binding/ares.ts": "33ff8275bc11751219af8bd149ea221c442d7e8676e3e9f20ccb0e1f0aac61b8", + "https://deno.land/std@0.170.0/node/internal_binding/async_wrap.ts": "b83e4021a4854b2e13720f96d21edc11f9905251c64c1bc625a361f574400959", + "https://deno.land/std@0.170.0/node/internal_binding/buffer.ts": "dfba9e1a50b637cfd72e569aa11959dcaf626b898ab7e851d21526a2bdaec588", + "https://deno.land/std@0.170.0/node/internal_binding/cares_wrap.ts": "720e6d5cff7018bb3d00e1a49dd4c31f0fc6af3a593ab68cd39e3592ed163d61", + "https://deno.land/std@0.170.0/node/internal_binding/config.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/connection_wrap.ts": "9debd4210d29c658054476fcb640c900725f564ef35412c56dc79eb07213a7c1", + "https://deno.land/std@0.170.0/node/internal_binding/constants.ts": "1ad4de9f76733320527c8bc841b5e4dd5869424924384157a72f3b171bd05b08", + "https://deno.land/std@0.170.0/node/internal_binding/contextify.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/credentials.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/crypto.ts": "d7f39700dc020364edf7f4785e5026bb91f099ce1bd02734182451b1af300c8c", + "https://deno.land/std@0.170.0/node/internal_binding/errors.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/fs.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/fs_dir.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/fs_event_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/handle_wrap.ts": "71c451060c9f555066d3ebe80de039a4e493a94b76c664450fbefd8f4167eb7e", + "https://deno.land/std@0.170.0/node/internal_binding/heap_utils.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/http_parser.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/icu.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/inspector.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/js_stream.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/messaging.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/mod.ts": "f68e74e8eed84eaa6b0de24f0f4c47735ed46866d7ee1c5a5e7c0667b4f0540f", + "https://deno.land/std@0.170.0/node/internal_binding/module_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/native_module.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/natives.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/node_file.ts": "37d6864897547d95ca24e0f5d01035915db0065bff128bc22191bc93f9ad59ad", + "https://deno.land/std@0.170.0/node/internal_binding/node_options.ts": "b098e6a1c80fa5003a1669c6828539167ab19337e13d20a47b610144cb888cef", + "https://deno.land/std@0.170.0/node/internal_binding/options.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/os.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/performance.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/pipe_wrap.ts": "105c73f268cb9ca1c6cebaf4bea089ab12e0c21c8c4e10bb0a14b0abd3e1661e", + "https://deno.land/std@0.170.0/node/internal_binding/process_methods.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/report.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/serdes.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/signal_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/spawn_sync.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/stream_wrap.ts": "64780dc713ee0a2dc36251a62fe533005f07f5996f2c7b61fd8b6fa277c25317", + "https://deno.land/std@0.170.0/node/internal_binding/string_decoder.ts": "5cb1863763d1e9b458bc21d6f976f16d9c18b3b3f57eaf0ade120aee38fba227", + "https://deno.land/std@0.170.0/node/internal_binding/symbols.ts": "51cfca9bb6132d42071d4e9e6b68a340a7f274041cfcba3ad02900886e972a6c", + "https://deno.land/std@0.170.0/node/internal_binding/task_queue.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/tcp_wrap.ts": "4217fa10072e048a26f26e5f548b3f38422452c9956265592cac57379a610acb", + "https://deno.land/std@0.170.0/node/internal_binding/timers.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/tls_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/trace_events.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/tty_wrap.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/types.ts": "69000b1b92e0ca999c737f5add38827742b3ca3fe37a2389c80290de0ae6ef01", + "https://deno.land/std@0.170.0/node/internal_binding/udp_wrap.ts": "cdd0882eff7e7db631d808608d20f5d1269b40fbcedd4a0972d6ed616a855c79", + "https://deno.land/std@0.170.0/node/internal_binding/url.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/util.ts": "faf5146c3cc3b2d6c26026a818b4a16e91488ab26e63c069f36ba3c3ae24c97b", + "https://deno.land/std@0.170.0/node/internal_binding/uv.ts": "27922aaec43de314afd99dfca1ce8f4d51ced9f5195e4917b54d387766404f61", + "https://deno.land/std@0.170.0/node/internal_binding/v8.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/worker.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/internal_binding/zlib.ts": "e292217d048a33573966b7d25352828d3282921fbcadce8735a20fb3da370cc4", + "https://deno.land/std@0.170.0/node/process.ts": "160ad8df6496ab00ed6ed25589d504855382d2641a0c9c4c6e1c577f770862d5", + "https://deno.land/std@0.170.0/node/stream.ts": "2c6d5d207d0ad295f396b34fd03a908c1638beb1754bc9c1fccd9a4cdcace8be", + "https://deno.land/std@0.170.0/node/string_decoder.ts": "7b6aaf9f98934fa33f89d7183a03858c0d1961870725d4ba39aa7cc137a9e9a1", + "https://deno.land/std@0.170.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.170.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.170.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", + "https://deno.land/std@0.170.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.170.0/path/glob.ts": "81cc6c72be002cd546c7a22d1f263f82f63f37fe0035d9726aa96fc8f6e4afa1", + "https://deno.land/std@0.170.0/path/mod.ts": "cf7cec7ac11b7048bb66af8ae03513e66595c279c65cfa12bfc07d9599608b78", + "https://deno.land/std@0.170.0/path/posix.ts": "b859684bc4d80edfd4cad0a82371b50c716330bed51143d6dcdbe59e6278b30c", + "https://deno.land/std@0.170.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.170.0/path/win32.ts": "7cebd2bda6657371adc00061a1d23fdd87bcdf64b4843bb148b0b24c11b40f69", + "https://deno.land/std@0.170.0/streams/write_all.ts": "7525aa90e34a2bb56d094403ad9c0e31e7db87a47cb556e6dc0404e6deca084c", + "https://esm.sh/mime-types@2.1.35": "f6f1a4f75582db380cfc5f13a2c94f8893db45fbb7f1e9eef1f9151aaa3c710f", + "https://esm.sh/multihashes@4.0.3": "571f959cb7ee8fcaba8be5b45fe7aaa58754e2728f0e40804ce733e2a0df49a9", + "https://esm.sh/pug-lint@2.6.0": "213f6febe1f43e555d2ec782f5d5655161a420ffd76a302cb64a5341993baac7", + "https://esm.sh/react-dom@18.2.0/server": "d573360693005ec5f79edbbde31887aa7ce0aac53c7d96428977d027613ee43d", + "https://esm.sh/react@18.2.0": "84aa07282d2f3c843f7d56daeae0be41ceae768391c4418f7358dd64e8fe4eca", + "https://esm.sh/stable/react@18.2.0/deno/react.js": "806868069cfdb815e028419cf07ebc7a7bcba7f9e31dec9e3a045b9f8e82c1ed", + "https://esm.sh/stable/vue@2.7.14/X-ZS8q/deno/vue.js": "2ff3d2822312b893b2f17cd98c5a9a8006c82dacf8c51422ca8d3d63d947d7eb", + "https://esm.sh/v102/*vue@2.7.14": "736e0e3dec6fb65e91c5e4328087d1bbc8b44763f4e137d8186607a02038df15", + "https://esm.sh/v102/@multiformats/base-x@4.0.1/deno/base-x.js": "3be0ffcdeb48834b9e6e6bdd345e909a15786f19b9b73f53a810d755af38ccd8", + "https://esm.sh/v102/multibase@4.0.6/deno/multibase.js": "a7ac224f6c3991cee8444170ffcb03426d16b0df376baf0437ba8b672bd366c8", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base10.js": "d08cb79795727f9dbec1cf12da2791dbb9f58f1a9fee46b8f3be712cd9941ad6", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base16.js": "fe9fa0c20d4ed68aee6cbcc5aaea8e625367ef32cef530cadda9dc36386ed2cb", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base2.js": "ca8fe539fed29032c4df60eb9859cc3b0b900abdaad2db3832e9dbc0d9cbc340", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base256emoji.js": "7e87284c2d969e06e88da82eebb4ace7a1d136d1cac285c51b1e9c8d93c809fb", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base32.js": "6f78009e853d4263a916999345e558729968fad6164d0ada9fde2ffc976447ac", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base36.js": "6d09a3fbc3cbd1d9a24482d47532c7c6fefbce82c4d40c0095a13ea9515b258f", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base58.js": "d622db1c6fb793a0b117988d02e4f8869028cce0fb639f26c138163959358c99", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base64.js": "6319f4b65cc97087e6a622bf0a84e2818213c7880216fab354697efaad7fc5af", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/base8.js": "fbd8630d99654818341312b4a43ab05a028c2b3a10402e541981b7cf8afd4539", + "https://esm.sh/v102/multiformats@9.9.0/deno/bases/identity.js": "f1bf23df863ac685636a7294d138daeb7f19a1f01940cac62e557130c034236e", + "https://esm.sh/v102/multiformats@9.9.0/deno/basics.js": "af0c6181a20ee2737452e6680940a2edd67c5ba4c1b6963f8f7ffcd63e24b968", + "https://esm.sh/v102/multiformats@9.9.0/deno/cid.js": "5cd2dfe1dc005bebdaf8ce2bdfe62eb3e8895ce4db44bfc08d74ecee0f2c8cea", + "https://esm.sh/v102/multiformats@9.9.0/deno/codecs/json.js": "09cf9be83f9e8399fe880ca16271edca74c1b3a0a0a0230d242f89e878772099", + "https://esm.sh/v102/multiformats@9.9.0/deno/codecs/raw.js": "1f07d049cf9b918ff405993fd9da099770d7e5b9509c25bd57e2b48cb719e862", + "https://esm.sh/v102/multiformats@9.9.0/deno/hashes/digest.js": "74233c910bfa9f1cdeb352b097f7218097eb48152a0bab9a8421411b32e51de5", + "https://esm.sh/v102/multiformats@9.9.0/deno/hashes/hasher.js": "d1720072babb24c6614adf96c2a5b65350939e5cd6fe8edc534fca15cf89d087", + "https://esm.sh/v102/multiformats@9.9.0/deno/hashes/identity.js": "d23c6b8e0b3a1ff54b3f304b6e3ca8bf9afb3f8758d49a57acf5b15f04101d4e", + "https://esm.sh/v102/multiformats@9.9.0/deno/hashes/sha2.js": "2be290579e9e1b88380678836cac52c98395c1fdb8ff09ca2316640f97cdc811", + "https://esm.sh/v102/multihashes@4.0.3/deno/multihashes.js": "1e66d6845707f3b02c218a3d773cb900886d41d7cb316f73c3d639dba25f6fa2", + "https://esm.sh/v102/multihashes@4.0.3/dist/src/constants.d.ts": "0f7b864a1fad52e55ac65dedd1c0592f62473274a25370ffe76091ed8c52d4ed", + "https://esm.sh/v102/multihashes@4.0.3/dist/src/index.d.ts": "460030109169e82a20a9ef6b7f9ee55876a8d61074bdc9429401f000fb55d0a6", + "https://esm.sh/v102/multihashes@4.0.3/dist/src/types.d.ts": "92d149099131b957241d25a116ea4a14ce2d667fef03b50432ba437313a68257", + "https://esm.sh/v102/uint8arrays@3.1.1/deno/alloc.js": "b7f8d1f2b0d7ab1ac640f1ddec69bf5658388561f4cbc685f5ef5bf46f99c81e", + "https://esm.sh/v102/uint8arrays@3.1.1/deno/concat.js": "4622787e2039e280a192cff4ad478fb5ec68c908693d5a45caab1a58f38a2798", + "https://esm.sh/v102/uint8arrays@3.1.1/deno/from-string.js": "055138d73849cd9bb4013921e84980a08603d3638bd8258e4c840aece56e4e6d", + "https://esm.sh/v102/uint8arrays@3.1.1/deno/to-string.js": "94bac2745a221b6aa84335ce776404789dcff6b94e84247514acbc14614368b7", + "https://esm.sh/v102/varint@5.0.2/deno/varint.js": "7aa8729fe484f288a204729dd41070968c2201f979fc9d5d7eed4a3fbe39a72f", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/common.d.ts": "2c5b192a87dea1e09a4b5348a1aadcba35e598c7a9c2ed2169dd08ab92c473c3", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/index.d.ts": "7f96baf5da7bf5e38fe1563b2799f4e6123003f0df44b72fac469373fb57957e", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/jsx.d.ts": "c11bd98f17f7bec63fdd6e0974d7a69a360039155524aaf129736e7fb837e0ba", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/options.d.ts": "7db97f2a4069b969dfea80d2056a31bbc2eb86d4bccbe9c2a5566dfbc0ce6f10", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/plugin.d.ts": "a17850223d40f93dea7df936e2bd4fc4904598e404e8f6bd606daad8f0af4d4d", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/umd.d.ts": "0a2f0fed8dd3052ceb98a254a7ad3226fe60a2e26cd9e74384886fa7986c40f4", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-component-options.d.ts": "b225da2578c5264963ed331a40dc17d5aa920c4a1cb4b11f99553b4dfac1a271", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-component-props.d.ts": "7aad5324d5e4bdca9a2d6733be3ab3864c69b1b64be07f83dada96e9193c9979", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-component-public-instance.d.ts": "aee586719542ac206b34c6618e8eeb36858ba64f27ba60bbaaf92e3727d7f18d", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-define-async-component.d.ts": "a28a02aa9abec47d7739c43c51e9800dac5bfd8662432d139ba1e5056498d463", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-define-component.d.ts": "31677c4cffdaf781d0ec68940f404204e0f07c6d23922355f1c2de8a0682d957", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-directive.d.ts": "dc4de9e25ee31872bfec612425d46b8176ef4287815d638d2fac16754858e076", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-generated.d.ts": "0ba8bf4908a6514e0f6cab4c0e8762df8c071e69af807183167f76dd85f28433", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-manual-apis.d.ts": "64c87ad3baf501b4febce12574c7db6e87380374bcd4128bc2af8e913dce8435", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-setup-context.d.ts": "6a4f4ff606e7c8f050b12e8040e5f905c7fcccd5d2859f52ac9b3c4fc7577997", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/v3-setup-helpers.d.ts": "b270d470009b69cbb769521ceaa1ad45f9fe37f52a2a42c05e113cb14a170f4d", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/vnode.d.ts": "a13df05299338f8e2f30113357e863bb9636cdcc37f660a88e3d418387cbda9a", + "https://esm.sh/v102/vue@2.7.14/X-ZS8q/types/vue.d.ts": "d344b1c8ea129c57169c3732340269f4badbdaf3aeb5944fc809dbbbfd92550b", + "https://esm.sh/v94/acorn@4.0.13/deno/acorn.js": "d6a570fa110ccdb438596ab38ac4c3e1749c1e5437720a88c1a8c4d752c487a5", + "https://esm.sh/v94/acorn@4.0.13/deno/dist/walk.js": "34077f51f425f4dbca15d47a31208d54e58c6d5bb9acfa8724734d2d5b0159ec", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/get-iterator.js": "8f5d654bd764a091b25d4e1ed9a76e20af525abeeb2b8f95627f3ab817935d49", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/json/stringify.js": "594aa5b9d77aa8e899d2abdb003e5da464beede26c49f7b4fe1eec60e898d70b", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/number/max-safe-integer.js": "da73b5beecbc38d2a12ea495f0f10c3ebf4fed5c11f7a776abb4004089f75889", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/object/create.js": "f462c268b4d5f093f690ad96ebb42f8a087990bfa6b16a7179e9ee5c7e90c755", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/object/get-own-property-symbols.js": "4ab87fad97e45125d70ff158b63e3e7d1ecb197f6facee2b96e8ef0052b7fbb9", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/object/keys.js": "7444f4e8f611d7682e8a10817e19a5f0cf92c471ab93bd62b2df5ab804d62343", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/symbol.js": "8d608908aa9a00c6c9dd1c36bb8ed5f1a72b2eb451158d573e7158667a273d18", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/symbol/for.js": "3e6cd0aecd87f9718600f2c205fccc8c19ddf0c2243f1c6abdcd5488ae72badc", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/core-js/symbol/iterator.js": "4d783cf0e5edc4fcc0cc5632d50eb2faf1303c5355248d4e6d8f0de210370ed0", + "https://esm.sh/v94/babel-runtime@6.26.0/deno/helpers/typeof.js": "9d87d8d488293464b5f24f5dcf77e02b644a3c58519ed5854577e8997487c223", + "https://esm.sh/v94/babel-types@6.26.0/deno/babel-types.js": "b43a47ed53da2bdbbba412d30eed27da2110c54592ab4f4677f154170f58bd4e", + "https://esm.sh/v94/babylon@6.18.0/deno/babylon.js": "1e00b6ee83d1b2847381dabe1261d99ef5796ee7cff402357d0f9d0f5e386e41", + "https://esm.sh/v94/balanced-match@1.0.2/deno/balanced-match.js": "544d8ea2c1b78b7bfc00fecc99805525ff33354cdecdd2cc2298463055e2c366", + "https://esm.sh/v94/brace-expansion@1.1.11/deno/brace-expansion.js": "14567d03055cb1617ebe7ccb4b0170291d692e199c0702ea6887cb19bf9a9ea4", + "https://esm.sh/v94/call-bind@1.0.2/deno/callBound.js": "ad961707b9a728653d2427755e25e002a44e03d55980a909e71dad6f1f484800", + "https://esm.sh/v94/character-parser@2.2.0/deno/character-parser.js": "229fac9db58a83f069d042b593ca4859001c988362a6325f5a8a8849b57d9155", + "https://esm.sh/v94/concat-map@0.0.1/deno/concat-map.js": "5be1aa47f35113c6a0a28b248515ac9c74acd96f5199c1586af8fcfa6c9c9065", + "https://esm.sh/v94/constantinople@3.1.2/deno/constantinople.js": "e3c3f5c49997d5f3455ff0a3cb3db561a8b7ba42d12b80071d34305e5efacbda", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/get-iterator.js": "24d700b3372c1cf16a66b94fc0486e7a957f5cda7384902e45601b483e0e0e67", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/json/stringify.js": "0d984a457312e1a7ab994f2c3d5128daf435659793209e1a9b2c5ab778de8aa5", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/number/max-safe-integer.js": "e23f1b42dc18234d64f139c5895207dcaf00267338e50c495e06f60629abc7b0", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/object/create.js": "5b4a8bb86930176e63cc2b37559ee4dd312a694718b2f8503d98dc19a76d7f81", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/object/get-own-property-symbols.js": "0374e947e7a1e8cb5de7f69c784a10fdc2fecaad9cf33f8b5a1909b73ce6316e", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/object/keys.js": "cc79d1d298496636f95cb2ca770cf61f9a4a85b00f40284fd833fc3183988e1b", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/symbol.js": "a7ef073eb773405278ac1f6e2fae1765972aaf5b9729c3ee7d01aeac177abec4", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/symbol/for.js": "f518d488d48f61adf668c4340851c9dd4152e11047a52f285daba04c873a2461", + "https://esm.sh/v94/core-js@2.6.12/deno/library/fn/symbol/iterator.js": "05a3f4586737b4a18327dbeda4655b08ce82e16848e87d998a1a8e2bb0b13cb1", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_a-function.js": "4c644c6ff3486fade3efb45631a6f907b317ffcc46bdc84dd03fdd764f90ad1c", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_add-to-unscopables.js": "fbabafd948880639435266ae05df759b576fe7f923ef2a7f95501a5f12c65eba", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_an-object.js": "6fac04a97b06571fc952d0cd97dc9643314fa5f245310ba6d4a76ad9b779c936", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_array-includes.js": "c972e636ef5de20fba94a7e663e49be16e706b8688f1a8b9e79fe4a09ba37c17", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_classof.js": "a67fface5f3ee91cbcfd62557236211d689b4f12b0fce508bae522104bf786f1", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_cof.js": "51df8d406c891a96e809a1ffcf7c783df7b1f149c1575a2395edaacdfa458e53", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_core.js": "1afaa8279382b96cdc30c155d818a8d640788eea5dbb5e69057b7ba7a4646281", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_ctx.js": "3034b03ea60353555d57a051b29915a7c5bf77976a95a74179b0c6656b737970", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_defined.js": "6ff57827f7d7ecbc9bbb818f7c0be8f0610dc5b45251ac26d9f81d83ee2cfa07", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_descriptors.js": "5c9e4183b65edc7830ab06c231541b06b092593ad41fa393694354a9afce5743", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_dom-create.js": "6282ae4ac3466e63fccde80981c25d4ce46e2d82d908c26af6f6fc501345651c", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_enum-bug-keys.js": "fad30ad43dc7acb8740c8a1a6d4688aa2232a0c34a0e8e2daf9ea67d73c5845e", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_enum-keys.js": "881b38bacf69ea473f5f46cc4a848baed7cb1939f1ba5bb0ea4579e0aa124eb7", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_export.js": "b829dd67fa6806d8c7fac48ca3cd7ce90168f78908ce4bda53a2d12143c825be", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_fails.js": "dcd49a8e7b6fcf79bd703c952d74338713983791d64cb14a97cfd97f6e7cd8d8", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_global.js": "2660d3932ae156b2fb8fc6c68503e88248d2f96caa913bc21ba59df42c5c0e63", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_has.js": "c13f43c2e308160de6c61813e668fc81d4c80629595f88ac2d15a07fcd194a22", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_hide.js": "6cb7ace358ec5b740844df0aa9007c09c05f824509c9b350533c537a03ce7ac5", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_html.js": "d7b30ec68249df94eff9bd04835205bf63e5bcbbe0557b0fb0fff08a11e00b50", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_ie8-dom-define.js": "6d46fc69b95ef1f0481a3be69e0ae3b8325963e0d18fc332acc883254f65d559", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_iobject.js": "a69ce947f295e771ae41ac6b8173b6b9a2503c2da0c9e18a0ffed287962fd8d1", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_is-array.js": "bdff609a8579634879d6a28bab3eb4b9c5efe33cb4060dc182beb655f261ba7a", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_is-object.js": "66a471aeb0c3b30d43e482a2369740811330c89d16dabb37ea25a2663dfaf567", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_iter-create.js": "9b75603598187d5bc717086eb2139e24b3968115ca9923481835ec625a9cd760", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_iter-define.js": "1842ae876da0aa5c9e8ca3d25fcebb7642033a2ed60d39e34880eb86058220ca", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_iter-step.js": "3a02acce659b21233b08d26693cb480d0c2c7fa3b0bcdaac62f1c538b39649ab", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_iterators.js": "29af946dbef77e1e5ad30b674dde870f61118a333455d09755eb75baa34c7455", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_library.js": "9b509bf9da4bbdf984a7a24da5a643b8acb9ffd3ac80dc8bd6c3050ca4921c44", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_meta.js": "5b15beb5606e2690a070ec4549639d77b4322aa0b9772902ba5f5d00045bde4c", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-create.js": "2fe9916fa20f21e703f3cd174eef00f11eb3c0ee58c5dbbfe43e228bc830e2c1", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-dp.js": "037c996487bec70bec84915768291c1a9ca30d4885836069e7124963e82a54c4", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-dps.js": "140314f1c3d84f996083b083b48f4f48af269ba744f679aba66a65cc710a4bc0", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-gopd.js": "82aff5ca525406d9d8a0e8cd6c60c81e3d4fc462c2876359b6b1a113d8e03b8a", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-gopn-ext.js": "516abe111b9806cc63eb766bef5ad02f2bb5870456ee375e1dce4a011ec864ad", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-gopn.js": "7641345efa9c87630b5906af23ae5335be70139469581a580296588172334a34", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-gops.js": "e9d4dbe1cbafa896d0998d7f4ca54f7dbb44716e5536f60074c40fb7a43310d0", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-gpo.js": "585f0800fc8377537f3930785fb2b67ec6ed4faa3a94c88b6dd4a82185815b1e", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-keys-internal.js": "25e2c74eef1e6d821ba9d9c0ec1ec09f42563a71f45238d141a6734b25bec8e7", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-keys.js": "02224a6b646a3434ff2527eaa32698a6604292245da11f6c7b21ddb3563c7244", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-pie.js": "9d940f1243de424447146b9fe88e0c191a330356bad4f58a0080b5e09a3c8056", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_object-sap.js": "5780fcec728aae2fb8348a6f86d4bc9f0b73df4c53948edbd683fbed9093084f", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_property-desc.js": "de606da1f3ec52e76818b3598e195d02eabcf780d24f0cc9e6a458bbf7156ea8", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_redefine.js": "3602f151828c896384956bbe0ac96fbbf7bd059587b77d099ac48b5e39be61f6", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_set-to-string-tag.js": "e19741ca1408423d5b47d0c57443bca6a44b83d5cc9fb9f182085f7ac7798875", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_shared-key.js": "2060855f9c525f01664c44873ef050b5ca4a07589d2dde4b41baaee776dc6cca", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_shared.js": "e7beb09bc37bcfd78e9de43d54e805b6aa4d12c2a6619bdb9e58f572c9293623", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_string-at.js": "7f2d047bc82148b6a0b5d8ff5864469313d0f931f83ab07f2329c696e906b205", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_to-absolute-index.js": "f837293c494fe3718c551254f6544a6ad91a94329bd3445bee9e1789e9de4046", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_to-integer.js": "390ac5507a51a82a80be9df15e0b0c9013512c6864e5c821db0c0363a26671a0", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_to-iobject.js": "da2a71e8636a2d5e7da2fe48c19b5c8b8f0f7e933487847526e5caa1e216b8ac", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_to-length.js": "24d6f7555ff6e44a794bdde79e04b22f273c804d176e5e1b31cd4deed22dc1e7", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_to-object.js": "99a06b661f55a201c331e5d3bb36ba1f4dc55112fcd00ea1c70d8374795bde5f", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_to-primitive.js": "89f29dfa6d642325a829fd650be168a401f3431d40d07123a1efc1fefbcad4b7", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_uid.js": "094d9b92580fce5c9c8c17864d90d036c990a635f6ce35638c60ba242227e447", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_wks-define.js": "c57a6a6a85d10b8f8e345136ec67878caa8e271103c5036c84d8527cf679a2b1", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_wks-ext.js": "91c9cc731aec244f5422f156131db99dae8a936927251170c1ba0c725e05c3cc", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/_wks.js": "969b83845928900208486142507b6a3ef251ff1dbf142a04514f8fcbdbbd6746", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/core.get-iterator-method.js": "db92aaa43398b3617c452cb1ffd72e7e3036bf608568795db3e457fa051a704c", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/core.get-iterator.js": "6ea23659f2cce45702640360fd990e185a7c337176a494722d033964d6381565", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.array.iterator.js": "8a8043f9f24d47b62b37ea364d8ee077f741cedba9fe097091d8130e66d80fa9", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.number.max-safe-integer.js": "05da5aa757e0e530283da2ec590bae6bdcd92cdc2c6054ebed91ea2cb542b9b8", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.object.create.js": "4b9e005dde602743a4e50293d1b3ae1a4f277d86042b8eaba22e8bc3e1f0c1a1", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.object.keys.js": "0c5e2ed0d6d3b43a6f7e11a7193c890ef7f54a68857dbb2fb52d85b6c4b6c716", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.object.to-string.js": "b51cfc0961d2f07cbcdf274892c04379cd9d610a791c530f5ff9778c369d72fb", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.string.iterator.js": "be026c8fb016b08d3ca493c331bab206830299f5a3739a77cec7469d85933295", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es6.symbol.js": "1a30babd7451c4f7d67b7372692c39d3240dc9751ba6b72c862bb7c58dec53d6", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es7.symbol.async-iterator.js": "fc19304608c3ad99504e676c8efc1ee93b69947a89479897222e6b8cf3077ae3", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/es7.symbol.observable.js": "41417acdc9868435d061c8fb6023f8cb7cac77d03260cd51a322064ed6090268", + "https://esm.sh/v94/core-js@2.6.12/deno/library/modules/web.dom.iterable.js": "0852ebbe191e2914b9ba422919dde45bdb5b04982e3f4a9a12a74f83fe36f525", + "https://esm.sh/v94/esutils@2.0.3/deno/esutils.js": "41c755e90ad30c9471f3860dd26bd74bc48a2f316550be9bb7a9659374763508", + "https://esm.sh/v94/find-line-column@0.5.2/deno/find-line-column.js": "583e8df69f2965059ee0f3e3f7e68eba797801acf1d201ff172a023472b47711", + "https://esm.sh/v94/fs.realpath@1.0.0/deno/fs.realpath.js": "f25fd3aa96cc21a5fdf9c73009bffed1d4e8ce6d518e6b78065eb459bd2f33bc", + "https://esm.sh/v94/function-bind@1.1.1/deno/function-bind.js": "9bed69e864acb26e062b3c9751944e71404f5c8ac3a277ffc8f80f5f1106eaff", + "https://esm.sh/v94/get-intrinsic@1.1.2/deno/get-intrinsic.js": "8461d7eb2c36bc462424502f29b45d17a3bf93940a9aebf5d545e28b61aedb14", + "https://esm.sh/v94/glob@7.2.3/deno/glob.js": "12fc04daeb01184c4d8a3924f6e5b474ac0cb5d147903008fc3c6cba03248b47", + "https://esm.sh/v94/has-symbols@1.0.3/deno/has-symbols.js": "70e708d3aa8c2b12c70be150054cc5b7e03dc15d6b40acfc661be41cb32e7664", + "https://esm.sh/v94/has-symbols@1.0.3/deno/shams.js": "48cf5bab4a30a8638b26f0551ac2a66ba9e3bb6604f2e7899d8ad6313f946f47", + "https://esm.sh/v94/has-tostringtag@1.0.0/deno/shams.js": "26828d46d9ded2f01ecca20ac8e2a408c9829245605b6a90c7f6df4c244e0dbb", + "https://esm.sh/v94/has@1.0.3/deno/has.js": "1760200d2855b8c3a4d6bc7dfc13db176e838b96d32133b32a6ca99d5a670741", + "https://esm.sh/v94/inflight@1.0.6/deno/inflight.js": "88bf7097b09b25d737c34dcace9dc05d663e084805be9e2d7d5a44e9ba882456", + "https://esm.sh/v94/inherits@2.0.4/deno/inherits.js": "ac5b8191d9ca26501c02ad35c6f6ab3204bb3c2aecbd29c1d63f6097870c7bfb", + "https://esm.sh/v94/is-core-module@2.10.0/deno/is-core-module.js": "59b987f39c9b0af521204399f0682e4569d9d63165f9c40e6ce6f243cd2f52f6", + "https://esm.sh/v94/is-expression@3.0.0/deno/is-expression.js": "7baad1828c82d0cf658289c5a895c30f0d02984a006b32d1f32a0560d5991190", + "https://esm.sh/v94/is-regex@1.1.4/deno/is-regex.js": "9d05c83c3d3b2bf0604d992fb2c417c4060e78d04f990c5a45f1a16039d29808", + "https://esm.sh/v94/js-stringify@1.0.2/deno/js-stringify.js": "ea85622618882a778d6339ebe6ed69a20942ea0b9ad4955a443c1d5db160ae2d", + "https://esm.sh/v94/lodash@4.17.21/deno/_DataView.js": "8ad2a6d9738d72ed1d5ad54c9e105bc882824be6f4b9fb6194a1479224cf84c5", + "https://esm.sh/v94/lodash@4.17.21/deno/_Hash.js": "92a31d65f75c33e27224dfbde20fc75b271bfbd9b0554f2f7063a675c53eb81e", + "https://esm.sh/v94/lodash@4.17.21/deno/_ListCache.js": "c472f98b7021d50a63ff94ad1722365f4c64f47a0e468ea1d5d2f65f02bde1c7", + "https://esm.sh/v94/lodash@4.17.21/deno/_Map.js": "0a674e2be10f8ba537bd628a949a1424fbac0640a4b3ea2a3f6f4dec8942ce60", + "https://esm.sh/v94/lodash@4.17.21/deno/_MapCache.js": "72a7aa19dbcfed0b2f0b8b90d1e3eb893bbc32fa57c79a8f5352e80dfe3fb379", + "https://esm.sh/v94/lodash@4.17.21/deno/_Promise.js": "ac75e5ca54ea4c399086d933187ad169bd7ca07939111e1932fea3c60b3da25c", + "https://esm.sh/v94/lodash@4.17.21/deno/_Set.js": "7e99c695ce5af8875a41ff70ce8f9872e9b7cd351b6bb161717e8dcf4de63d6e", + "https://esm.sh/v94/lodash@4.17.21/deno/_SetCache.js": "4c3a67fd8c616bb9f8ab4346f5b2b74bfbe47142da6ea1d47bb0e8ae09040f87", + "https://esm.sh/v94/lodash@4.17.21/deno/_Stack.js": "51a70532787165c20f73f480b17d3c4b543f4cc1d9be4186906b173d1bc32d32", + "https://esm.sh/v94/lodash@4.17.21/deno/_Symbol.js": "219c19dbefb0a631419779fbd20925ce6fefbc0f01a902a356912c01ce06cae2", + "https://esm.sh/v94/lodash@4.17.21/deno/_Uint8Array.js": "aa2627c2f67b7405f0b90d5d5cb7416c78a352f275eb56f644b23f15a8520fa0", + "https://esm.sh/v94/lodash@4.17.21/deno/_WeakMap.js": "5f6c078efe18c24d5bb27298a1394db9e48a636ce730cb38498176110858fc16", + "https://esm.sh/v94/lodash@4.17.21/deno/_arrayEach.js": "ed3e3abc500e055e6998497314a087758bb40d0adc6bc6b65a62286fa9abc6ba", + "https://esm.sh/v94/lodash@4.17.21/deno/_arrayFilter.js": "c041e1f39a14b478170dba5e3deac1343276456156f81f8ea95307bd9345eb94", + "https://esm.sh/v94/lodash@4.17.21/deno/_arrayIncludes.js": "a97bf94e8df48cc08cebfd8b304c59d1f5f941a387152fbc87850df9081624fd", + "https://esm.sh/v94/lodash@4.17.21/deno/_arrayIncludesWith.js": "e3a6a8a05c8d31267788ce3986461cba552a4be454340b08aeeffb1c5c532c33", + "https://esm.sh/v94/lodash@4.17.21/deno/_arrayLikeKeys.js": "40ad68fa926cbc810d9de877422b10506de8e4ae63178042d203123521d82602", + "https://esm.sh/v94/lodash@4.17.21/deno/_arrayPush.js": "6a07e93838cc566034a16efe0082d405ea85be762ce7aa221dd212e68af7d633", + "https://esm.sh/v94/lodash@4.17.21/deno/_assignValue.js": "ecb6c8b6f7d279b1448c81c5e661f77a1692bc9a5cb47e31c128c511dc8dd563", + "https://esm.sh/v94/lodash@4.17.21/deno/_assocIndexOf.js": "0b98d55496451ddd8b08810c3750f11e2b1ec8f043e6286326e8fb0864bdb118", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseAssign.js": "d36be31291bbe49004429cac72bc4164d99a8d3fa059eaa6b26d2f9f22ca94cb", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseAssignIn.js": "170c75caa44c2bbe0e78d927b021b3a20a8da5468ebc6485adc923aa799bcd04", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseAssignValue.js": "a5babb44f9e5cf492068abf69efbe956beb534aa08f876f855a8d42ae6d80390", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseClone.js": "98b7c2d875ab504c969c92e6516ffb760faa53de3af4392ee6a32f12e3513ae1", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseCreate.js": "173343f9706b04939906a44a26aaef00f4c1b57aa24207b1b231b183f14d73bd", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseFindIndex.js": "1bea38f2039604688151bd5b261c447ede75c2de6bfb0872013961a1f5024fbf", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseGetAllKeys.js": "70efa7a5764735ead1332f8b281d17568ee4a3c6d2ed78636024f3a78e0240e4", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseGetTag.js": "06e9852e66e8ca80547d0d1c8cb1a546d8c17609d9910f50e5eb5fea7f2d075f", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIndexOf.js": "aa2a5e511c47b7310523e197a34cf5be9eaf3e89844cbe8796941dbc21982080", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsArguments.js": "de1819ef66c8828f712c3ed245888a307d886b2ea474ff7c8024d3b20e48ed25", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsMap.js": "d4f170a38ebbc1a0753f3b4adf2ac4d39b7ca356a4352f5d102aadb7e2c48d1c", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsNaN.js": "a70d5a979108b813c54f964218c480b01945e30831ca66137650102aeac427c3", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsNative.js": "a07a2a2b1819b14e5bd01fd0782cc04f4ed7297c27f1c6b137262c2119519224", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsRegExp.js": "132bb34bacf03d4f6cd39f0909763215c481cc0eacc8128354090021df1587ee", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsSet.js": "064edabb3467dc01cb02b329435e1120a619c78eb3d09fd4ee93624344028400", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseIsTypedArray.js": "bcfadfeca59a8ee245f3fff618bd54e475596f40551bcbb47f62132cbc654f96", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseKeys.js": "0673cf020782408ba81dcd8d50a0c4b4a95e7caa6fdde68b59e12ce245bcef64", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseKeysIn.js": "d011f5d44bca515c39fc4c0bdbc14ef9d3588da3d22de5e4caf529007af5af61", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseTimes.js": "fd2403dec51d1e19e470553dd41f717add6ee01d1df8efba304558d3bb3e6434", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseUnary.js": "135dedaf6fb2ec96dd70903fd71aa83dedde7c75d68bb845155b0900ad4ad706", + "https://esm.sh/v94/lodash@4.17.21/deno/_baseUniq.js": "4fa9f351af1c5d85ec74d5fb1d1abb3ac196ca4834f9d407668f38cbc618a829", + "https://esm.sh/v94/lodash@4.17.21/deno/_cacheHas.js": "aec496fe9bc8d8ab31a90a9bb60ae4c7668986f1d3d3b8af444e12e0290ea5d7", + "https://esm.sh/v94/lodash@4.17.21/deno/_cloneArrayBuffer.js": "5bd6470d22f904f777f58b0b9982cf1cb4d75db6f92507b746bdcd31d6d864b6", + "https://esm.sh/v94/lodash@4.17.21/deno/_cloneBuffer.js": "fa810a3a0d68d2429c4f3084fa20fe6c88f5f5bf6c4ac973903e02e5efe244bf", + "https://esm.sh/v94/lodash@4.17.21/deno/_cloneDataView.js": "3442cdc28cb0c1e9d2f32ba9438091a3220a9488b4694c1f8b221b5d5aa33c51", + "https://esm.sh/v94/lodash@4.17.21/deno/_cloneRegExp.js": "93e2c2a02a910ec521beeb04537f5d0e4ab8954520f330e2786d6053b8fbdadf", + "https://esm.sh/v94/lodash@4.17.21/deno/_cloneSymbol.js": "d0208da0c6d2b2b11c08f39acbb4e6b4d2f61a9e76f08f1119cfd9d670652cfb", + "https://esm.sh/v94/lodash@4.17.21/deno/_cloneTypedArray.js": "cbe69f6db5eb2159342c8baef817a50b88c7a5aa272f42fb7ccbdcaf2cde2243", + "https://esm.sh/v94/lodash@4.17.21/deno/_copyArray.js": "1d7ab72ca4c4b25774bef8ba75b02167b2bc54d475ce12d5478eb191e24828c9", + "https://esm.sh/v94/lodash@4.17.21/deno/_copyObject.js": "e85037754e799bfe919036e8f0a5aba6793850d9b14e4bca428019f1eb2299c7", + "https://esm.sh/v94/lodash@4.17.21/deno/_copySymbols.js": "082716a843ab59b40542652d00a14638fdbce81c8cb791c4f415b68a6510b1d9", + "https://esm.sh/v94/lodash@4.17.21/deno/_copySymbolsIn.js": "766a23ee93ab4041db02ffea2649f929c574b7b83bc204b777d712a3852ba190", + "https://esm.sh/v94/lodash@4.17.21/deno/_coreJsData.js": "84ac1c677c9dd41f2bad4428af4b9c1c2889f951c82f9f3a409415c34845b4d3", + "https://esm.sh/v94/lodash@4.17.21/deno/_createSet.js": "a2ebed91b854516a4d43c06b617384eeb1577417b23f29cb1bd68f64ae7f4701", + "https://esm.sh/v94/lodash@4.17.21/deno/_defineProperty.js": "d3f86e9763ff6e77fb4e2ab7118093c7c6d713d2c78ddf65d545c281bde016d2", + "https://esm.sh/v94/lodash@4.17.21/deno/_freeGlobal.js": "4f3c6df66f238a732bdc4dd756dcc6c7dc254628c4bb574ee60ca59d353847f9", + "https://esm.sh/v94/lodash@4.17.21/deno/_getAllKeys.js": "d04e6444ce34a6650b17c46255f9217008ebeaa0d6108965079ed26e856df786", + "https://esm.sh/v94/lodash@4.17.21/deno/_getAllKeysIn.js": "b93adae59447793272c0adf71dd4fe9792bd09055a7f9566a16d55efca7c8b40", + "https://esm.sh/v94/lodash@4.17.21/deno/_getMapData.js": "ed8594441fe0a2dde86374897bc0c01f517f692951641c670bbfdfb372884a8e", + "https://esm.sh/v94/lodash@4.17.21/deno/_getNative.js": "feca7f83ec98675ebd9dc07486c53c735b20825092daf706b0dc5e06459fd877", + "https://esm.sh/v94/lodash@4.17.21/deno/_getPrototype.js": "1b2f05e01fd2fedcc7832ecdfecdfd3f473247be80b6863cd01dce16a5213e69", + "https://esm.sh/v94/lodash@4.17.21/deno/_getRawTag.js": "6a7f5b6da527d6b9921325f990ee6481c5307c3b723ef70773618b70f9eaaa78", + "https://esm.sh/v94/lodash@4.17.21/deno/_getSymbols.js": "6d0842a61ffde774c78f16f022105f7b1289fac3143aa2fcd1b93e03e49fcb0e", + "https://esm.sh/v94/lodash@4.17.21/deno/_getSymbolsIn.js": "cf52164659ae75a81cdbee937be6f2dec056e4966e0539492e4ff3c115436e60", + "https://esm.sh/v94/lodash@4.17.21/deno/_getTag.js": "43aaf9ee11ebcae24c3e9693537a80fc33a037f6b5428fefe762e81b09c6657d", + "https://esm.sh/v94/lodash@4.17.21/deno/_getValue.js": "3bd032e0cb044d85b03ba75f3b8059f491d7f0cf8f6acae831a1a75114c422a2", + "https://esm.sh/v94/lodash@4.17.21/deno/_hashClear.js": "555e103b461f06cb42d1fa4f44fad3f6d50630136463d25faf0e8e49f1920cc8", + "https://esm.sh/v94/lodash@4.17.21/deno/_hashDelete.js": "ced75518cabb404fa6e5bd7548f220da016c34f8e7d9c2a6cc325d1d9ddb9262", + "https://esm.sh/v94/lodash@4.17.21/deno/_hashGet.js": "ccacc86f0481e3c97bbad6d19380b97aaf4598b8af663d0b3cf8db6cea954f16", + "https://esm.sh/v94/lodash@4.17.21/deno/_hashHas.js": "0a798b8492a73b4426d3b6e17871b022f32eab897e32ff3c7b287b579feb1fce", + "https://esm.sh/v94/lodash@4.17.21/deno/_hashSet.js": "3cf7d14df7a4c4ea7b84431c06b575189f084412f405029ae72f19d989349784", + "https://esm.sh/v94/lodash@4.17.21/deno/_initCloneArray.js": "b0552a49135c7adc1f4be06b4db2ba53ab99f1cbd5c0bacf2b7702d4711be2f3", + "https://esm.sh/v94/lodash@4.17.21/deno/_initCloneByTag.js": "b5c59e499a4bd118794edcd6ec4316c77f90366f3eb7065a16531617fe27d2c7", + "https://esm.sh/v94/lodash@4.17.21/deno/_initCloneObject.js": "2cf5e17ee524ac410a36489b7fd0228de6c2605c79376af92e1ade13fc102934", + "https://esm.sh/v94/lodash@4.17.21/deno/_isIndex.js": "773e48e9632c9d530734320acc8c9c683db0c826c14a1fe2209ea4dc9b5171cf", + "https://esm.sh/v94/lodash@4.17.21/deno/_isKeyable.js": "6d6359757e08728918709ae89548dc84c9292f7440a2dcd15522a5b962a41952", + "https://esm.sh/v94/lodash@4.17.21/deno/_isMasked.js": "81038fee90acd8ed7164a20e7d0b84813c8ec52f477269e979c8326ebcf20703", + "https://esm.sh/v94/lodash@4.17.21/deno/_isPrototype.js": "30c4611cb75ad5124a4a33ba2f986c912950499cb06b76979ac9aae6dd1e5b43", + "https://esm.sh/v94/lodash@4.17.21/deno/_listCacheClear.js": "1815d74dee70df94b14486e667149e61ef82a06def845efdf5112dbdadfdbfb8", + "https://esm.sh/v94/lodash@4.17.21/deno/_listCacheDelete.js": "d053245fe359423668916c2de5e9de408d4479f31d8c874bea2c279cbc8096bb", + "https://esm.sh/v94/lodash@4.17.21/deno/_listCacheGet.js": "fde8789c9a40bf5cd740ff08a566cd6c05ca4978388c637794711bf9744c9276", + "https://esm.sh/v94/lodash@4.17.21/deno/_listCacheHas.js": "b6ffdfb36637ab847263187de7934d4f9db7416bb7087dbb6bdf0f6f290724b8", + "https://esm.sh/v94/lodash@4.17.21/deno/_listCacheSet.js": "a9c7e2ea8715107dc0e74bb01ca256e23a3d23623195eac17fa2f6e497c6d25c", + "https://esm.sh/v94/lodash@4.17.21/deno/_mapCacheClear.js": "d6ff1dc37b687729ec109fc9122b895e1a05f4f84ad843beeafff7f70fd141ff", + "https://esm.sh/v94/lodash@4.17.21/deno/_mapCacheDelete.js": "d65fee1f023ce92a54bbc88d5d4552ed79a699e533187d18d3fd37775f337f2a", + "https://esm.sh/v94/lodash@4.17.21/deno/_mapCacheGet.js": "57b6b15afbdd03ca40338761034efefd1480de6a5ac0d0c4dcd1ae61ade72b6a", + "https://esm.sh/v94/lodash@4.17.21/deno/_mapCacheHas.js": "f370c43047379869a746f15f623a0a4ae87a99f0561df1aff8732dfa07eb8ff0", + "https://esm.sh/v94/lodash@4.17.21/deno/_mapCacheSet.js": "3e28c26178eab3df064ce48dee3c5ea4da708de889ca7dbec057587a18e314b7", + "https://esm.sh/v94/lodash@4.17.21/deno/_nativeCreate.js": "258fa0ef2556fec1fc5b05287d51c1fed6955f27c5d169e5e8915b4531c584cd", + "https://esm.sh/v94/lodash@4.17.21/deno/_nativeKeys.js": "d2a3a64b2aafc6eb1dca635bbdb18e9056272d67f70e302e8d24939f703cc13d", + "https://esm.sh/v94/lodash@4.17.21/deno/_nativeKeysIn.js": "bcfde1087811a87a30a6c105f21f29fe6c31e0404cb102e001cca3c9773e0f2f", + "https://esm.sh/v94/lodash@4.17.21/deno/_nodeUtil.js": "1fb22f054df1e3fc5de703ab4a5c682afd1bd96b3ea7710fa409fcde12e443c1", + "https://esm.sh/v94/lodash@4.17.21/deno/_objectToString.js": "8c68659ecaaf7684f2fe98113ccdeeb091cf0ae775bfc27eea3061c366f9a30a", + "https://esm.sh/v94/lodash@4.17.21/deno/_overArg.js": "f110ba62963e1f8e4e2222659792ed5f886f9b0df667c4a0730b0c349023b4cd", + "https://esm.sh/v94/lodash@4.17.21/deno/_root.js": "cc0957d40628747fa06906aac96966b6792dace39e35c70f7305b285659e831c", + "https://esm.sh/v94/lodash@4.17.21/deno/_setCacheAdd.js": "3e548f1e89e32cb32b9e05e6a87961574d3b67dcc591a73e7134d9156c3f66ca", + "https://esm.sh/v94/lodash@4.17.21/deno/_setCacheHas.js": "fc94364b0649896a6daedb9580abcdabd5d70a65eb0b0ee40bf0f19f6ec8386a", + "https://esm.sh/v94/lodash@4.17.21/deno/_setToArray.js": "56e91e5ba6a7128dd1cab23504b35884da7fa616f89222ac0b0e183a418ebc5e", + "https://esm.sh/v94/lodash@4.17.21/deno/_stackClear.js": "3da0c0d671c6f29d712a7852fc6b949369080077950f7f15d8abeae7a4a332a6", + "https://esm.sh/v94/lodash@4.17.21/deno/_stackDelete.js": "4c4e766fbd8a38411f0ab6ed3ce7d9e9ec978921d85865b2e68b4234d07b19fd", + "https://esm.sh/v94/lodash@4.17.21/deno/_stackGet.js": "7c83593b8e8fc9791b67f3e415c992d2b873c9478f8f37e61875956a50875c6e", + "https://esm.sh/v94/lodash@4.17.21/deno/_stackHas.js": "a0a52db05ee70bbb53b8aca8f74959dc359deaf16ff0f3e797279943f885e5d6", + "https://esm.sh/v94/lodash@4.17.21/deno/_stackSet.js": "acd4de9d7ba570b52b44428bb420ac5cc5c4e9a2d45db3bf900ec7eae6457a7f", + "https://esm.sh/v94/lodash@4.17.21/deno/_strictIndexOf.js": "01e4cf92ddfbaab672c8893c0b8bedd645a5fd37e5c9f44a5f1ff8de6c4826af", + "https://esm.sh/v94/lodash@4.17.21/deno/_toSource.js": "2e11b93f2c697a87943cd26ea583eb2d5e9535a9d644a4e202dc958efe877351", + "https://esm.sh/v94/lodash@4.17.21/deno/clone.js": "7868b4a4e574529134abadc55f5b942266795a781cce993f7bff3a987110932c", + "https://esm.sh/v94/lodash@4.17.21/deno/eq.js": "25fd45b2ba7ac36a02fb749e7f8ef2664ee0722b9ab3f9bb848756b496cfa62a", + "https://esm.sh/v94/lodash@4.17.21/deno/isArguments.js": "9ca1e74fd82e12ba90fe38b316e8cd01cb1647ab6324b724fd50b2963e760810", + "https://esm.sh/v94/lodash@4.17.21/deno/isArray.js": "333c97e4ff54b7ec16324ee28bb88d855ee1b5f2c086188504c7a545f25b9725", + "https://esm.sh/v94/lodash@4.17.21/deno/isArrayLike.js": "5f26f9f8ef526968210f05588911ae9f735e2e08e17b332fc694fed1491c183f", + "https://esm.sh/v94/lodash@4.17.21/deno/isBuffer.js": "6f64211a954558a271d3cbfaa905ae18ac01bd17906c16d12734a9b3f3196354", + "https://esm.sh/v94/lodash@4.17.21/deno/isFunction.js": "7a626878bfb1381f79a1359595ee1f8fc7aaa19ab49ca4fefbaf663e0761825e", + "https://esm.sh/v94/lodash@4.17.21/deno/isLength.js": "1b2cee52286d867948c10adea090f37992573145bed82bf89f8ccbb69d008a66", + "https://esm.sh/v94/lodash@4.17.21/deno/isMap.js": "14173fea16847415f4767e76a7990e27801ad8af53dd797571e2f65fd7838d69", + "https://esm.sh/v94/lodash@4.17.21/deno/isObject.js": "416d11c6100cf1e42c557964c2ba4d5da365bf1c291e13763ee2af74ae8f6f8b", + "https://esm.sh/v94/lodash@4.17.21/deno/isObjectLike.js": "ea086b5ba333b27ddfe422ee3b572478ba9ba4413582146fa8b3ea6cf38d14de", + "https://esm.sh/v94/lodash@4.17.21/deno/isPlainObject.js": "2d509f89fcbb12ffa01c205e179c59dfea1a957310d3d290b767cc8d67201d4c", + "https://esm.sh/v94/lodash@4.17.21/deno/isRegExp.js": "a486fc05c614e973bc3166323e115a1b4cbcc2b5753dc28d76c177b902bbcc30", + "https://esm.sh/v94/lodash@4.17.21/deno/isSet.js": "a9fce0528dd95edd3e13f28bcaa479193d4e8cbaa134fc53d3a3e59b58335729", + "https://esm.sh/v94/lodash@4.17.21/deno/isTypedArray.js": "60ebe3d6164e51f71ab33b45285b0e401193d878b616056d5ed2047852b7652e", + "https://esm.sh/v94/lodash@4.17.21/deno/keys.js": "1d1c3893edfe40357cc09276ca7ce8928711fd1db350147b2c58fb86aa40d8b4", + "https://esm.sh/v94/lodash@4.17.21/deno/keysIn.js": "0a1c8576a02ce35a896e3e88a0ffb5be9046d385b50bf9e349e9f1813685dde5", + "https://esm.sh/v94/lodash@4.17.21/deno/noop.js": "b327a1f4b0cb586419f5497a4afabfe6c0785a591f2e30ad491920b04763e941", + "https://esm.sh/v94/lodash@4.17.21/deno/stubArray.js": "97ef417662371e52df258a1e7455e423b75f64f80eb0141d4b4a0c19459a2474", + "https://esm.sh/v94/lodash@4.17.21/deno/stubFalse.js": "024a0ea544d6c632893dc6be756da34690a9d508741e63bb29f1956359280344", + "https://esm.sh/v94/lodash@4.17.21/deno/uniq.js": "d4ddd86b230e0ce1ed2c068f77e6b95e67255afb63789ea2840913b5d8bb2f0c", + "https://esm.sh/v94/minimatch@3.1.2/deno/minimatch.js": "0532998bfabe1500dc83022cf08943256838a69b22574fe1a7d39b7bac5279b4", + "https://esm.sh/v94/once@1.4.0/deno/once.js": "935e37ea4d0a3a099881ef06c0fc19c27cdc5f040b86655b0a6eac34142fbbc7", + "https://esm.sh/v94/path-is-absolute@1.0.1/deno/path-is-absolute.js": "35310cd399b3630cd1358fdeb698fb417c10ba8d34a9dabde7a60fa6407bbdee", + "https://esm.sh/v94/path-parse@1.0.7/deno/path-parse.js": "3ca4a503ee514781983a773e7952304187fabcb620bc8e35998bb9862a149d35", + "https://esm.sh/v94/pug-attrs@2.0.4/deno/pug-attrs.js": "0f3fae1eec4a3846347fe89e095d80e5e9a3de4a95eadc6756bbb9c1c5afd132", + "https://esm.sh/v94/pug-error@1.3.3/deno/pug-error.js": "f98fc09d0726238de7766b4a2f110304b34d5d662e1255e8124a063e66a9a131", + "https://esm.sh/v94/pug-lexer@4.1.0/deno/pug-lexer.js": "57892d0658fc53fc4e415aeba693d446f7c586049ddd8757ee011df5d7efb120", + "https://esm.sh/v94/pug-lint@2.6.0/deno/pug-lint.js": "880117da8b20ddce508dd6df953f93303a34521f41ba227f5d4d77dd8890637e", + "https://esm.sh/v94/pug-runtime@2.0.5/deno/pug-runtime.js": "998f76980903fdca9e624235e10f347249a67c27651e788f04d3f1f01bdc11e0", + "https://esm.sh/v94/resolve@1.22.1/deno/resolve.js": "8ec98c502f506eaed57c0b6c53d7808b6035b50eae34addb327f9a69f38a9a30", + "https://esm.sh/v94/strip-json-comments@2.0.1/deno/strip-json-comments.js": "d46d511c23ea8c954ca124feeada16261a3523056aef79a747cf0e570ddaa4f4", + "https://esm.sh/v94/to-fast-properties@1.0.3/deno/to-fast-properties.js": "17260d91f1d306ae585200294433636f27a01618fa4b76186d6859569e2d4418", + "https://esm.sh/v94/wrappy@1.0.2/deno/wrappy.js": "1bae2b23d6610b0706d63344a9c305a491f53d74c6d0cbef86d81cc01e90fed5", + "https://esm.sh/v96/@types/mime-types@2.1.1/index.d.ts": "c757372a092924f5c16eaf11a1475b80b95bb4dae49fe3242d2ad908f97d5abe", + "https://esm.sh/v96/@types/prop-types@15.7.5/index.d.ts": "6a386ff939f180ae8ef064699d8b7b6e62bc2731a62d7fbf5e02589383838dea", + "https://esm.sh/v96/@types/react-dom@18.0.8/server~.d.ts": "954d3e8681fe59bde6377bc8136c3488eaaace7746bd13b4060f655cc111be25", + "https://esm.sh/v96/@types/react@18.0.21/global.d.ts": "bbdf156fea2fabed31a569445835aeedcc33643d404fcbaa54541f06c109df3f", + "https://esm.sh/v96/@types/react@18.0.21/index.d.ts": "c1331ac9d04748a098127f35e0f3f08f6739381dfdc5d718c2180702bcf3eb96", + "https://esm.sh/v96/@types/scheduler@0.16.2/tracing.d.ts": "f5a8b384f182b3851cec3596ccc96cb7464f8d3469f48c74bf2befb782a19de5", + "https://esm.sh/v96/csstype@3.1.1/index.d.ts": "1c29793071152b207c01ea1954e343be9a44d85234447b2b236acae9e709a383", + "https://esm.sh/v96/mime-db@1.52.0/deno/mime-db.js": "dc5bf518082a09c8ad3e112b76a1d25db1dc588b8952bd5ec4b87a31fa36bd19", + "https://esm.sh/v96/mime-types@2.1.35/deno/mime-types.js": "3de92861cafb1435fac28726f095951a5f6228943fc879c186467320f5d9c5d8", + "https://esm.sh/v96/react-dom@18.2.0/deno/server.js": "bf1ce0fcb084be382713a3bbf9df8d6564cddad331ef30e9307f1c8eaa1e513d", + "https://raw.githubusercontent.com/snowteamer/pogo/master/dependencies.ts": "0af98b3c28bb3eef5356bd37698ce60a7f0c90615f79d5d68f6ed0e2f7b66c6e", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/bang.ts": "9b6dc1db3adbcde198b145e17cc08dcd8c5423d3975fbf6f41280af7d7c886d2", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/components/directory-listing.tsx": "777da6a4d4317ba88f7ec2a1b640e1d939fe370ec843a829496bad79290a609c", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/helpers/directory.tsx": "600d3ec118557ae553bbae5a6ccb66e043ebb7e36cbb0500b1bb98d8eeefc476", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/helpers/file.ts": "14bf844a65089f7559ecc6c9334893993da359d22439a6d0beee1bfcc9efd868", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/request.ts": "8b6b1c9c963fc1e100180fc7b805453db41c7d32a9106096c409d3e9bc41c788", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/response.ts": "5a673f1f20c2ebb7421597424d8087fd0af7ace00d6c04485354582d69c2edfc", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/router.ts": "4c2a3af0df72180e4dc4696d50458f51c68e28b5444fb0687f664a5430ca0295", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/server.ts": "e24f57c5559db57555f0db342647c34593cb6e3d43c42415a598bf1c016230af", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/toolkit.ts": "23584a527b6534080856a9fff9de748cd06cf7c373e92f1e6ff44fb5f4c06a8d", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/types.ts": "b3585878e7c9b2bdf2b24ff2919ab37f119403d0dc5401be8fb2ede3381ba188", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/util/is-path-inside.ts": "1f04a7873076c27410fa6cc1320f33c415ee5870c10f9465b13db0119c541bd4", + "https://raw.githubusercontent.com/snowteamer/pogo/master/lib/util/read-dir-stats.ts": "0335af669825065c74ead2dbdc5fce40acc9630d4e20250241eb9e0e80a46d89", + "https://raw.githubusercontent.com/snowteamer/pogo/master/main.ts": "744a8c286bcc34e620efcf3450d63af0ecea985dede38be367f4705a202d5f93" + } +} diff --git a/frontend/common/errors.js b/frontend/common/errors.js index 6365a7b579..cb29bbb4b3 100644 --- a/frontend/common/errors.js +++ b/frontend/common/errors.js @@ -3,7 +3,7 @@ export class GIErrorIgnoreAndBan extends Error { // ugly boilerplate because JavaScript is stupid // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types - constructor (...params: any[]) { + constructor (...params /*: any[] */) { super(...params) this.name = 'GIErrorIgnoreAndBan' if (Error.captureStackTrace) { @@ -14,7 +14,7 @@ export class GIErrorIgnoreAndBan extends Error { // Used to throw human readable errors on UI. export class GIErrorUIRuntimeError extends Error { - constructor (...params: any[]) { + constructor (...params /*: any[] */) { super(...params) // this.name = this.constructor.name this.name = 'GIErrorUIRuntimeError' // string literal so minifier doesn't overwrite diff --git a/frontend/common/stringTemplate.js b/frontend/common/stringTemplate.js index e3db477867..c376a31a70 100644 --- a/frontend/common/stringTemplate.js +++ b/frontend/common/stringTemplate.js @@ -1,6 +1,6 @@ const nargs = /\{([0-9a-zA-Z_]+)\}/g -export default function template (string: string, ...args: any[]): string { +export default function template (string /*: string */, ...args /*: any[] */) /*: string */ { const firstArg = args[0] // If the first rest argument is a plain object or array, use it as replacement table. // Otherwise, use the whole rest array as replacement table. diff --git a/frontend/common/stringTemplate.test.js b/frontend/common/stringTemplate.test.js deleted file mode 100644 index 367d9be3d1..0000000000 --- a/frontend/common/stringTemplate.test.js +++ /dev/null @@ -1,146 +0,0 @@ -/* eslint-env mocha */ -import template from './stringTemplate.js' -const should = require('should') -describe('Test string-template', function () { - it('Named arguments are replaced', function () { - const result = template('Hello {name}, how are you?', { name: 'Mark' }) - should(result).equal('Hello Mark, how are you?') - }) - it('Named arguments at the start of strings are replaced', function () { - const result = template('{likes} people have liked this', { likes: 123 }) - should(result).equal('123 people have liked this') - }) - it('Named arguments at the end of string are replaced', function () { - const result = template('Please respond by {date}', { date: '01/01/2015' }) - should(result).equal(result, 'Please respond by 01/01/2015') - }) - it('Multiple named arguments are replaced', function () { - const result = template('Hello {name}, you have {emails} new messages', { name: 'Anna', emails: 5 }) - should(result).equal('Hello Anna, you have 5 new messages') - }) - it('Missing named arguments become 0 characters', function () { - const result = template('Hello{name}, how are you?', {}) - should(result).equal('Hello, how are you?') - }) - it('Named arguments can be escaped', function () { - const result = template('Hello {{name}}, how are you?', { name: 'Mark' }) - should(result).equal('Hello {name}, how are you?') - }) - it('Array arguments are replaced', function () { - const result = template('Hello {0}, how are you?', ['Mark']) - should(result).equal('Hello Mark, how are you?') - }) - it('Array arguments at the start of strings are replaced', function () { - const result = template('{0} people have liked this', [123]) - should(result).equal('123 people have liked this') - }) - it('Array arguments at the end of string are replaced', function () { - const result = template('Please respond by {0}', ['01/01/2015']) - should(result).equal('Please respond by 01/01/2015') - }) - it('Multiple array arguments are replaced', function () { - const result = template('Hello {0}, you have {1} new messages', ['Anna', 5]) - should(result).equal('Hello Anna, you have 5 new messages') - }) - it('Missing array arguments become 0 characters', function () { - const result = template('Hello{0}, how are you?', []) - should(result).equal('Hello, how are you?') - }) - it('Array arguments can be escaped', function () { - const result = template('Hello {{0}}, how are you?', ['Mark']) - should(result).equal('Hello {0}, how are you?') - }) - it('Array keys are not accessible', function () { - const result = template('Function{splice}', []) - should(result).equal('Function') - }) - it('Listed arguments are replaced', function () { - const result = template('Hello {0}, how are you?', 'Mark') - should(result).equal('Hello Mark, how are you?') - }) - it('Listed arguments at the start of strings are replaced', function () { - const result = template('{0} people have liked this', 123) - should(result).equal('123 people have liked this') - }) - it('Listed arguments at the end of string are replaced', function () { - const result = template('Please respond by {0}', '01/01/2015') - should(result).equal('Please respond by 01/01/2015') - }) - it('Multiple listed arguments are replaced', function () { - const result = template('Hello {0}, you have {1} new messages', 'Anna', 5) - should(result).equal('Hello Anna, you have 5 new messages') - }) - it('Missing listed arguments become 0 characters', function () { - const result = template('Hello{1}, how are you?', 'no') - should(result).equal('Hello, how are you?') - }) - it('Listed arguments can be escaped', function () { - const result = template('Hello {{0}}, how are you?', 'Mark') - should(result).equal('Hello {0}, how are you?') - }) - it('Allow null data', function () { - const result = template('Hello{0}', null) - should(result).equal('Hello') - }) - it('Allow undefined data', function () { - const result1 = template('Hello{0}') - const result2 = template('Hello{0}', undefined) - should(result1).equal('Hello') - should(result2).equal('Hello') - }) - it('Null keys become 0 characters', function () { - const result1 = template('Hello{name}', { name: null }) - const result2 = template('Hello{0}', [null]) - const result3 = template('Hello{0}{1}{2}', null, null, null) - should(result1).equal('Hello') - should(result2).equal('Hello') - should(result3).equal('Hello') - }) - it('Undefined keys become 0 characters', function () { - const result1 = template('Hello{firstName}{lastName}', { name: undefined }) - const result2 = template('Hello{0}{1}', [undefined]) - const result3 = template('Hello{0}{1}{2}', undefined, undefined) - should(result1).equal(result1, 'Hello') - should(result2).equal(result2, 'Hello') - should(result3).equal(result3, 'Hello') - }) - it('Works across multline strings', function () { - const result1 = template('{zero}\n{one}\n{two}', { - zero: 'A', - one: 'B', - two: 'C' - }) - const result2 = template('{0}\n{1}\n{2}', ['A', 'B', 'C']) - const result3 = template('{0}\n{1}\n{2}', 'A', 'B', 'C') - should(result1).equal('A\nB\nC') - should(result2).equal('A\nB\nC') - should(result3).equal('A\nB\nC') - }) - it('Allow multiple references', function () { - const result1 = template('{a}{b}{c}\n{a}{b}{c}\n{a}{b}{c}', { - a: 'one', - b: 'two', - c: 'three' - }) - const result2 = template('{0}{1}{2}\n{0}{1}{2}\n{0}{1}{2}', [ - 'one', - 'two', - 'three' - ]) - const result3 = template('{0}{1}{2}\n{0}{1}{2}\n{0}{1}{2}', - 'one', - 'two', - 'three') - should(result1).equal('onetwothree\nonetwothree\nonetwothree') - should(result2).equal('onetwothree\nonetwothree\nonetwothree') - should(result3).equal('onetwothree\nonetwothree\nonetwothree') - }) - it('Template string without arguments', function () { - const result = template('Hello, how are you?') - should(result).equal('Hello, how are you?') - }) - it('Template string with underscores', function () { - const result = template('Hello {FULL_NAME}, how are you?', { FULL_NAME: 'James Bond' }) - should(result).equal('Hello James Bond, how are you?') - }) -}) diff --git a/frontend/common/stringTemplate.test.ts b/frontend/common/stringTemplate.test.ts new file mode 100644 index 0000000000..a3463aa624 --- /dev/null +++ b/frontend/common/stringTemplate.test.ts @@ -0,0 +1,150 @@ +// Can run directly with: +// deno test --import-map=import_map.json frontend/common/stringTemplate.test.ts + +import { assertEquals } from 'asserts' + +import template from './stringTemplate.js' + +Deno.test('Test string-template', async function (tests) { + await tests.step('Named arguments are replaced', async function () { + const result = template('Hello {name}, how are you?', { name: 'Mark' }) + assertEquals(result, 'Hello Mark, how are you?') + }) + await tests.step('Named arguments at the start of strings are replaced', async function () { + const result = template('{likes} people have liked this', { likes: 123 }) + assertEquals(result, '123 people have liked this') + }) + await tests.step('Named arguments at the end of string are replaced', async function () { + const result = template('Please respond by {date}', { date: '01/01/2015' }) + assertEquals(result, result, 'Please respond by 01/01/2015') + }) + await tests.step('Multiple named arguments are replaced', async function () { + const result = template('Hello {name}, you have {emails} new messages', { name: 'Anna', emails: 5 }) + assertEquals(result, 'Hello Anna, you have 5 new messages') + }) + await tests.step('Missing named arguments become 0 characters', async function () { + const result = template('Hello{name}, how are you?', {}) + assertEquals(result, 'Hello, how are you?') + }) + await tests.step('Named arguments can be escaped', async function () { + const result = template('Hello {{name}}, how are you?', { name: 'Mark' }) + assertEquals(result, 'Hello {name}, how are you?') + }) + await tests.step('Array arguments are replaced', async function () { + const result = template('Hello {0}, how are you?', ['Mark']) + assertEquals(result, 'Hello Mark, how are you?') + }) + await tests.step('Array arguments at the start of strings are replaced', async function () { + const result = template('{0} people have liked this', [123]) + assertEquals(result, '123 people have liked this') + }) + await tests.step('Array arguments at the end of string are replaced', async function () { + const result = template('Please respond by {0}', ['01/01/2015']) + assertEquals(result, 'Please respond by 01/01/2015') + }) + await tests.step('Multiple array arguments are replaced', async function () { + const result = template('Hello {0}, you have {1} new messages', ['Anna', 5]) + assertEquals(result, 'Hello Anna, you have 5 new messages') + }) + await tests.step('Missing array arguments become 0 characters', async function () { + const result = template('Hello{0}, how are you?', []) + assertEquals(result, 'Hello, how are you?') + }) + await tests.step('Array arguments can be escaped', async function () { + const result = template('Hello {{0}}, how are you?', ['Mark']) + assertEquals(result, 'Hello {0}, how are you?') + }) + await tests.step('Array keys are not accessible', async function () { + const result = template('Function{splice}', []) + assertEquals(result, 'Function') + }) + await tests.step('Listed arguments are replaced', async function () { + const result = template('Hello {0}, how are you?', 'Mark') + assertEquals(result, 'Hello Mark, how are you?') + }) + await tests.step('Listed arguments at the start of strings are replaced', async function () { + const result = template('{0} people have liked this', 123) + assertEquals(result, '123 people have liked this') + }) + await tests.step('Listed arguments at the end of string are replaced', async function () { + const result = template('Please respond by {0}', '01/01/2015') + assertEquals(result, 'Please respond by 01/01/2015') + }) + await tests.step('Multiple listed arguments are replaced', async function () { + const result = template('Hello {0}, you have {1} new messages', 'Anna', 5) + assertEquals(result, 'Hello Anna, you have 5 new messages') + }) + await tests.step('Missing listed arguments become 0 characters', async function () { + const result = template('Hello{1}, how are you?', 'no') + assertEquals(result, 'Hello, how are you?') + }) + await tests.step('Listed arguments can be escaped', async function () { + const result = template('Hello {{0}}, how are you?', 'Mark') + assertEquals(result, 'Hello {0}, how are you?') + }) + await tests.step('Allow null data', async function () { + const result = template('Hello{0}', null) + assertEquals(result, 'Hello') + }) + await tests.step('Allow undefined data', async function () { + const result1 = template('Hello{0}') + const result2 = template('Hello{0}', undefined) + assertEquals(result1, 'Hello') + assertEquals(result2, 'Hello') + }) + await tests.step('Null keys become 0 characters', async function () { + const result1 = template('Hello{name}', { name: null }) + const result2 = template('Hello{0}', [null]) + const result3 = template('Hello{0}{1}{2}', null, null, null) + assertEquals(result1, 'Hello') + assertEquals(result2, 'Hello') + assertEquals(result3, 'Hello') + }) + await tests.step('Undefined keys become 0 characters', async function () { + const result1 = template('Hello{firstName}{lastName}', { name: undefined }) + const result2 = template('Hello{0}{1}', [undefined]) + const result3 = template('Hello{0}{1}{2}', undefined, undefined) + assertEquals(result1, result1, 'Hello') + assertEquals(result2, result2, 'Hello') + assertEquals(result3, result3, 'Hello') + }) + await tests.step('Works across multline strings', async function () { + const result1 = template('{zero}\n{one}\n{two}', { + zero: 'A', + one: 'B', + two: 'C' + }) + const result2 = template('{0}\n{1}\n{2}', ['A', 'B', 'C']) + const result3 = template('{0}\n{1}\n{2}', 'A', 'B', 'C') + assertEquals(result1, 'A\nB\nC') + assertEquals(result2, 'A\nB\nC') + assertEquals(result3, 'A\nB\nC') + }) + await tests.step('Allow multiple references', async function () { + const result1 = template('{a}{b}{c}\n{a}{b}{c}\n{a}{b}{c}', { + a: 'one', + b: 'two', + c: 'three' + }) + const result2 = template('{0}{1}{2}\n{0}{1}{2}\n{0}{1}{2}', [ + 'one', + 'two', + 'three' + ]) + const result3 = template('{0}{1}{2}\n{0}{1}{2}\n{0}{1}{2}', + 'one', + 'two', + 'three') + assertEquals(result1, 'onetwothree\nonetwothree\nonetwothree') + assertEquals(result2, 'onetwothree\nonetwothree\nonetwothree') + assertEquals(result3, 'onetwothree\nonetwothree\nonetwothree') + }) + await tests.step('Template string without arguments', async function () { + const result = template('Hello, how are you?') + assertEquals(result, 'Hello, how are you?') + }) + await tests.step('Template string with underscores', async function () { + const result = template('Hello {FULL_NAME}, how are you?', { FULL_NAME: 'James Bond' }) + assertEquals(result, 'Hello James Bond, how are you?') + }) +}) diff --git a/frontend/common/translations.js b/frontend/common/translations.js index bd63bd9766..ed47690c18 100644 --- a/frontend/common/translations.js +++ b/frontend/common/translations.js @@ -12,7 +12,7 @@ Vue.prototype.LTags = LTags const defaultLanguage = 'en-US' const defaultLanguageCode = 'en' -const defaultTranslationTable: { [string]: string } = {} +const defaultTranslationTable /*: { [string]: string } */ = {} /** * Allow 'href' and 'target' attributes to avoid breaking our hyperlinks, @@ -41,7 +41,7 @@ let currentTranslationTable = defaultTranslationTable * @see https://tools.ietf.org/rfc/bcp/bcp47.txt */ sbp('sbp/selectors/register', { - 'translations/init': async function init (language: string): Promise { + 'translations/init': async function init (language /*: string */) /*: Promise */ { // A language code is usually the first part of a language tag. const [languageCode] = language.toLowerCase().split('-') @@ -111,7 +111,7 @@ String with Vue components inside: ) Invite {count} members to the party! */ -export function LTags (...tags: string[]): {|br_: string|} { +export function LTags (...tags /*: string[] */) /*: {|br_: string|} */ { const o = { 'br_': '
' } @@ -123,15 +123,15 @@ export function LTags (...tags: string[]): {|br_: string|} { } export default function L ( - key: string, - args: Array<*> | Object | void -): string { + key /*: string */, + args /*: Array<*> | Object | void */ +) /*: string */ { return template(currentTranslationTable[key] || key, args) // Avoid inopportune linebreaks before certain punctuations. .replace(/\s(?=[;:?!])/g, ' ') } -export function LError (error: Error): {|reportError: any|} { +export function LError (error /*: Error */) /*: {|reportError: any|} */ { let url = `/app/dashboard?modal=UserSettingsModal§ion=application-logs&errorMsg=${encodeURI(error.message)}` if (!sbp('state/vuex/state').loggedIn) { url = 'https://github.com/okTurtles/group-income/issues' diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 7abf382bc4..a52dc9d2fd 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -25,7 +25,7 @@ import { dateToPeriodStamp, addTimeToDate, DAYS_MILLIS } from '@model/contracts/ import { encryptedAction } from './utils.js' import { VOTE_FOR } from '@model/contracts/shared/voting/rules.js' import type { GIActionParams } from './types.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import type { GIMessage } from '~/shared/domains/chelonia/types.flow.js' import { REPLACE_MODAL } from '@utils/events.js' export async function leaveAllChatRooms (groupContractID: string, member: string) { diff --git a/frontend/controller/actions/mailbox.js b/frontend/controller/actions/mailbox.js index 16137a47a0..416f5a3e7c 100644 --- a/frontend/controller/actions/mailbox.js +++ b/frontend/controller/actions/mailbox.js @@ -5,8 +5,8 @@ import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { omit } from '@model/contracts/shared/giLodash.js' import { CHATROOM_PRIVACY_LEVEL, CHATROOM_TYPES } from '@model/contracts/shared/constants.js' import { encryptedAction } from './utils.js' +import type { GIMessage } from '~/shared/domains/chelonia/types.flow.js' import type { GIActionParams } from './types.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' export default (sbp('sbp/selectors/register', { 'gi.actions/mailbox/create': async function ({ diff --git a/frontend/controller/backend.js b/frontend/controller/backend.js index 58f5b90fca..adffb3dcb8 100644 --- a/frontend/controller/backend.js +++ b/frontend/controller/backend.js @@ -1,9 +1,9 @@ 'use strict' -import type { JSONObject } from '~/shared/types.js' +import type { JSONObject } from '~/shared/types.flow.js' import sbp from '@sbp/sbp' -import { NOTIFICATION_TYPE } from '~/shared/pubsub.js' +import { NOTIFICATION_TYPE } from '~/shared/pubsub.ts' import { handleFetchResult } from './utils/misc.js' import { PUBSUB_INSTANCE } from './instance-keys.js' diff --git a/frontend/controller/e2e/keys.js b/frontend/controller/e2e/keys.js index e1ff2a4569..00163bb8e3 100644 --- a/frontend/controller/e2e/keys.js +++ b/frontend/controller/e2e/keys.js @@ -3,11 +3,11 @@ 'use strict' import sbp from '@sbp/sbp' -import { blake32Hash, bytesToB64 } from '~/shared/functions.js' +import { blake32Hash, bytesToB64 } from '~/shared/functions.ts' import nacl from 'tweetnacl' import scrypt from 'scrypt-async' -import type { GIKey, GIKeyType } from '~/shared/domains/chelonia/GIMessage.js' +import type { GIKey, GIKeyType } from '~/shared/domains/chelonia/types.flow.js' function genSeed (): string { return bytesToB64(nacl.randomBytes(nacl.box.secretKeyLength)) diff --git a/frontend/controller/namespace.js b/frontend/controller/namespace.js index bf521b1add..e612606d18 100644 --- a/frontend/controller/namespace.js +++ b/frontend/controller/namespace.js @@ -6,7 +6,11 @@ import { handleFetchResult } from './utils/misc.js' // NOTE: prefix groups with `group/` and users with `user/` ? sbp('sbp/selectors/register', { - 'namespace/register': (name: string, value: string) => { + /* + * @param {string} name + * @param {string} value + */ + 'namespace/register': (name /*: string */, value /*: string */) => { return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name`, { method: 'POST', body: JSON.stringify({ name, value }), @@ -18,13 +22,16 @@ sbp('sbp/selectors/register', { return result }) }, - 'namespace/lookup': (name: string) => { + /* + * @param {string} name + */ + 'namespace/lookup': (name /*: string */) => { // TODO: should `name` be encodeURI'd? const cache = sbp('state/vuex/state').namespaceLookups if (name in cache) { return cache[name] } - return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${name}`).then((r: Object) => { + return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${name}`).then((r) => { if (!r.ok) { console.warn(`namespace/lookup: ${r.status} for ${name}`) if (r.status !== 404) { @@ -32,7 +39,7 @@ sbp('sbp/selectors/register', { } return null } - return r['text']() + return r.text() }).then(value => { if (value !== null) { Vue.set(cache, name, value) diff --git a/frontend/controller/utils/misc.js b/frontend/controller/utils/misc.js index b59c1fbfcc..61e21cf4ef 100644 --- a/frontend/controller/utils/misc.js +++ b/frontend/controller/utils/misc.js @@ -1,8 +1,14 @@ 'use strict' -export function handleFetchResult (type: string): ((r: any) => any) { - return function (r: Object) { +export function handleFetchResult (type /*: string */) /*: (r: Response) => any */ { + return function (r /*: Response */) /*: any */ { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) - return r[type]() + // Can't just write `r[type]` here because of a Flow error. + switch (type) { + case 'blob': return r.blob() + case 'json': return r.json() + case 'text': return r.text() + default: throw new TypeError(`Invalid fetch result type: ${type}.`) + } } } diff --git a/frontend/main.js b/frontend/main.js index c5a5d0f085..d611338885 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -10,9 +10,9 @@ import { mapMutations, mapGetters, mapState } from 'vuex' import 'wicg-inert' import '@model/captureLogs.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' -import '~/shared/domains/chelonia/chelonia.js' -import { CONTRACT_IS_SYNCING } from '~/shared/domains/chelonia/events.js' +import type { GIMessage } from '~/shared/domains/chelonia/types.flow.js' +import '~/shared/domains/chelonia/chelonia.ts' +import { CONTRACT_IS_SYNCING } from '~/shared/domains/chelonia/events.ts' import * as Common from '@common/common.js' import { LOGIN, LOGOUT } from './utils/events.js' import './controller/namespace.js' diff --git a/frontend/model/contracts/shared/currencies.js b/frontend/model/contracts/shared/currencies.js index 1dfdb8c2da..907d95fabe 100644 --- a/frontend/model/contracts/shared/currencies.js +++ b/frontend/model/contracts/shared/currencies.js @@ -1,5 +1,6 @@ 'use strict' +/*:: type Currency = {| decimalsMax: number; displayWithCurrency(n: number): string; @@ -8,6 +9,7 @@ type Currency = {| symbolWithCode: string; validate(n: string): boolean; |} +*/ // https://github.com/okTurtles/group-income/issues/813#issuecomment-593680834 // round all accounting to DECIMALS_MAX decimal places max to avoid consensus @@ -16,55 +18,55 @@ type Currency = {| // this value, switch to a different currency base, e.g. from BTC to mBTC. export const DECIMALS_MAX = 8 -function commaToDots (value: string | number): string { +function commaToDots (value /*: string | number */) /*: string */ { // ex: "1,55" -> "1.55" return typeof value === 'string' ? value.replace(/,/, '.') : value.toString() } -function isNumeric (nr: string): boolean { - return !isNaN((nr: any) - parseFloat(nr)) +function isNumeric (nr /*: string */) /*: boolean */ { + return !isNaN((nr /*: any */) - parseFloat(nr)) } -function isInDecimalsLimit (nr: string, decimalsMax: number) { +function isInDecimalsLimit (nr /*: string */, decimalsMax /*: number */) { const decimals = nr.split('.')[1] return !decimals || decimals.length <= decimalsMax } // NOTE: Unsure whether this is *always* string; it comes from 'validators' in other files -function validateMincome (value: string, decimalsMax: number) { +function validateMincome (value /*: string */, decimalsMax /*: number */) { const nr = commaToDots(value) return isNumeric(nr) && isInDecimalsLimit(nr, decimalsMax) } -function decimalsOrInt (num: number, decimalsMax: number): string { +function decimalsOrInt (num /*: number */, decimalsMax /*: number */) /*: string */ { // ex: 12.5 -> "12.50", but 250 -> "250" return num.toFixed(decimalsMax).replace(/\.0+$/, '') } -export function saferFloat (value: number): number { +export function saferFloat (value /*: number */) /*: number */ { // ex: 1.333333333333333333 -> 1.33333333 return parseFloat(value.toFixed(DECIMALS_MAX)) } -export function normalizeCurrency (value: string): number { +export function normalizeCurrency (value /*: string */) /*: number */ { // ex: "1,333333333333333333" -> 1.33333333 return saferFloat(parseFloat(commaToDots(value))) } // NOTE: Unsure whether this is *always* string; it comes from 'validators' in other files -export function mincomePositive (value: string): boolean { +export function mincomePositive (value /*: string */) /*: boolean */ { return parseFloat(commaToDots(value)) > 0 } -function makeCurrency (options): Currency { +function makeCurrency (options) /*: Currency */ { const { symbol, symbolWithCode, decimalsMax, formatCurrency } = options return { symbol, symbolWithCode, decimalsMax, - displayWithCurrency: (n: number) => formatCurrency(decimalsOrInt(n, decimalsMax)), - displayWithoutCurrency: (n: number) => decimalsOrInt(n, decimalsMax), - validate: (n: string) => validateMincome(n, decimalsMax) + displayWithCurrency: (n /*: number */) => formatCurrency(decimalsOrInt(n, decimalsMax)), + displayWithoutCurrency: (n /*: number */) => decimalsOrInt(n, decimalsMax), + validate: (n /*: string */) => validateMincome(n, decimalsMax) } } @@ -72,7 +74,7 @@ function makeCurrency (options): Currency { // a json file that's read in and generates this object. For // example, that would allow the addition of currencies without // having to "recompile" a new version of the app. -const currencies: { [string]: Currency } = { +const currencies /*: { [string]: Currency } */ = { USD: makeCurrency({ symbol: '$', symbolWithCode: '$ USD', diff --git a/frontend/model/contracts/shared/distribution/distribution.js b/frontend/model/contracts/shared/distribution/distribution.js index 3aaeb30dc3..165b74ef5b 100644 --- a/frontend/model/contracts/shared/distribution/distribution.js +++ b/frontend/model/contracts/shared/distribution/distribution.js @@ -5,20 +5,22 @@ import minimizeTotalPaymentsCount from './payments-minimizer.js' import { cloneDeep } from '../giLodash.js' import { saferFloat, DECIMALS_MAX } from '../currencies.js' +/*:: type Distribution = Array; +*/ const tinyNum = 1 / Math.pow(10, DECIMALS_MAX) -export function unadjustedDistribution ({ haveNeeds = [], minimize = true }: { +export function unadjustedDistribution ({ haveNeeds = [], minimize = true } /*: { haveNeeds: Array, minimize?: boolean -}): Distribution { +} */) /*: Distribution */ { const distribution = mincomeProportional(haveNeeds) return minimize ? minimizeTotalPaymentsCount(distribution) : distribution } export function adjustedDistribution ( - { distribution, payments, dueOn }: { distribution: Distribution, payments: Distribution, dueOn: string } -): Distribution { + { distribution, payments, dueOn } /*: { distribution: Distribution, payments: Distribution, dueOn: string } */ +) /*: Distribution */ { distribution = cloneDeep(distribution) // ensure the total is set because of how reduceDistribution works for (const todo of distribution) { @@ -41,7 +43,7 @@ export function adjustedDistribution ( } // Merges multiple payments between any combinations two of users: -function reduceDistribution (payments: Distribution): Distribution { +function reduceDistribution (payments /*: Distribution */) /*: Distribution */ { // Don't modify the payments list/object parameter in-place, as this is not intended: payments = cloneDeep(payments) for (let i = 0; i < payments.length; i++) { @@ -65,11 +67,11 @@ function reduceDistribution (payments: Distribution): Distribution { return payments } -function addDistributions (paymentsA: Distribution, paymentsB: Distribution): Distribution { +function addDistributions (paymentsA /*: Distribution */, paymentsB /*: Distribution */) /*: Distribution */ { return reduceDistribution([...paymentsA, ...paymentsB]) } -function subtractDistributions (paymentsA: Distribution, paymentsB: Distribution): Distribution { +function subtractDistributions (paymentsA /*: Distribution */, paymentsB /*: Distribution */) /*: Distribution */ { // Don't modify any payment list/objects parameters in-place, as this is not intended: paymentsB = cloneDeep(paymentsB) // Reverse the sign of the second operand's amounts so that the final addition is actually subtraction: diff --git a/frontend/model/contracts/shared/distribution/distribution.test.js b/frontend/model/contracts/shared/distribution/distribution.test.js deleted file mode 100644 index 68d6945a56..0000000000 --- a/frontend/model/contracts/shared/distribution/distribution.test.js +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-env mocha */ - -// run with: ./node_modules/.bin/mocha -w --require @babel/register frontend/model/contracts/distribution/distribution.test.js - -import should from 'should' -import { unadjustedDistribution, adjustedDistribution } from './distribution.js' - -const setup = [] - -function distributionWrapper (events: Array, { adjusted }: { adjusted: boolean } = {}) { - const haveNeeds = [] - const payments = [] - const handlers = { - haveNeedEvent (e) { haveNeeds.push(e.data) }, - paymentEvent (e) { payments.push({ ...e.data, total: 0 }) } - } - for (const e of events) { handlers[e.type](e) } - const distribution = unadjustedDistribution({ haveNeeds }) - return adjusted ? adjustedDistribution({ distribution, payments, dueOn: '2021-01' }) : distribution -} - -describe('Test group-income-distribution.js', function () { - it('Test empty distirbution event list for unadjusted distribution.', function () { - should(distributionWrapper(setup)).eql([]) - }) - it('EVENTS: [u1, u2, u3 and u4] join the group and set haveNeeds of [100, 100, -50, and -50], respectively. Test unadjusted.', function () { - setup.splice(setup.length, 0, - { type: 'haveNeedEvent', data: { name: 'u1', haveNeed: 100 } }, - { type: 'haveNeedEvent', data: { name: 'u2', haveNeed: 100 } }, - { type: 'haveNeedEvent', data: { name: 'u3', haveNeed: -50 } }, - { type: 'haveNeedEvent', data: { name: 'u4', haveNeed: -50 } } - ) - should(distributionWrapper(setup)).eql([ - { amount: 50, from: 'u2', to: 'u4' }, - { amount: 50, from: 'u1', to: 'u3' } - ]) - }) - it('Test the adjusted version of the previous event-list. Should be unchanged.', function () { - should(distributionWrapper(setup, { adjusted: true })).eql([ - { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, - { amount: 50, from: 'u1', to: 'u3', total: 50, partial: false, isLate: false, dueOn: '2021-01' } - ]) - }) - it('EVENT: a payment of $10 is made from u1 to u3.', function () { - setup.push({ type: 'paymentEvent', data: { from: 'u1', to: 'u3', amount: 10 } }) - should(distributionWrapper(setup, { adjusted: true })).eql([ - { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, - { amount: 40, from: 'u1', to: 'u3', total: 50, partial: true, isLate: false, dueOn: '2021-01' } - ]) - }) - it('EVENT: a payment of $40 is made from u1 to u3.', function () { - setup.push({ type: 'paymentEvent', data: { from: 'u1', to: 'u3', amount: 40 } }) - should(distributionWrapper(setup, { adjusted: true })).eql([ - { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' } - ]) - }) -}) diff --git a/frontend/model/contracts/shared/distribution/distribution.test.ts b/frontend/model/contracts/shared/distribution/distribution.test.ts new file mode 100644 index 0000000000..b7fec539da --- /dev/null +++ b/frontend/model/contracts/shared/distribution/distribution.test.ts @@ -0,0 +1,84 @@ +// Can run directly with: +// deno test --import-map=import_map.json frontend/model/contracts/shared/distribution/distribution.test.ts + +import { assertEquals } from 'asserts' + +import { adjustedDistribution, unadjustedDistribution } from './distribution.js' + +type DistributionEvent = HaveNeedEvent | PaymentEvent + +type HaveNeed = { + name: string + haveNeed: number +} + +type HaveNeedEvent = { + type: 'haveNeedEvent' + data: HaveNeed +} + +type Payment = { + amount: number + from: string + to: string +} + +type PaymentEvent = { + type: 'paymentEvent' + data: Payment +} + +const setup: DistributionEvent[] = [] + +function distributionWrapper (events: DistributionEvent[], { adjusted }: { adjusted?: boolean } = {}) { + const haveNeeds: HaveNeed[] = [] + const payments: Array = [] + const handlers: Record void> = { + haveNeedEvent (e: DistributionEvent) { haveNeeds.push((e as HaveNeedEvent).data) }, + paymentEvent (e: DistributionEvent) { payments.push({ ...(e as PaymentEvent).data, total: 0 }) } + } + for (const e of events) { handlers[e.type](e) } + const distribution = unadjustedDistribution({ haveNeeds }) + return adjusted ? adjustedDistribution({ distribution, payments, dueOn: '2021-01' }) : distribution +} + +Deno.test('Tests for group-income-distribution', async function (tests) { + await tests.step('Test empty distribution event list for unadjusted distribution.', async function () { + assertEquals(distributionWrapper(setup), []) + }) + + await tests.step('EVENTS: [u1, u2, u3 and u4] join the group and set haveNeeds of [100, 100, -50, and -50], respectively. Test unadjusted.', function () { + setup.splice(setup.length, 0, + { type: 'haveNeedEvent', data: { name: 'u1', haveNeed: 100 } }, + { type: 'haveNeedEvent', data: { name: 'u2', haveNeed: 100 } }, + { type: 'haveNeedEvent', data: { name: 'u3', haveNeed: -50 } }, + { type: 'haveNeedEvent', data: { name: 'u4', haveNeed: -50 } } + ) + assertEquals(distributionWrapper(setup), [ + { amount: 50, from: 'u2', to: 'u4' }, + { amount: 50, from: 'u1', to: 'u3' } + ]) + }) + + await tests.step('Test the adjusted version of the previous event-list. Should be unchanged.', async function () { + assertEquals(distributionWrapper(setup, { adjusted: true }), [ + { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, + { amount: 50, from: 'u1', to: 'u3', total: 50, partial: false, isLate: false, dueOn: '2021-01' } + ]) + }) + + await tests.step('EVENT: a payment of $10 is made from u1 to u3.', async function () { + setup.push({ type: 'paymentEvent', data: { from: 'u1', to: 'u3', amount: 10 } }) + assertEquals(distributionWrapper(setup, { adjusted: true }), [ + { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, + { amount: 40, from: 'u1', to: 'u3', total: 50, partial: true, isLate: false, dueOn: '2021-01' } + ]) + }) + + await tests.step('EVENT: a payment of $40 is made from u1 to u3.', async function () { + setup.push({ type: 'paymentEvent', data: { from: 'u1', to: 'u3', amount: 40 } }) + assertEquals(distributionWrapper(setup, { adjusted: true }), [ + { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' } + ]) + }) +}) diff --git a/frontend/model/contracts/shared/distribution/mincome-proportional.js b/frontend/model/contracts/shared/distribution/mincome-proportional.js index b6db8a1416..42fd2888f4 100644 --- a/frontend/model/contracts/shared/distribution/mincome-proportional.js +++ b/frontend/model/contracts/shared/distribution/mincome-proportional.js @@ -1,11 +1,13 @@ 'use strict' -export type HaveNeedObject = { +/*:: +export type HaveNeed = { name: string; haveNeed: number } +*/ -export default function mincomeProportional (haveNeeds: Array): Array { +export default function mincomeProportional (haveNeeds /*: HaveNeed[] */) /*: Object[] */ { let totalHave = 0 let totalNeed = 0 const havers = [] @@ -19,6 +21,7 @@ export default function mincomeProportional (haveNeeds: Array): totalNeed += Math.abs(haveNeed.haveNeed) } } + // NOTE: This will be NaN if both totalNeed and totalHave are 0. const totalPercent = Math.min(1, totalNeed / totalHave) const payments = [] for (const haver of havers) { diff --git a/frontend/model/contracts/shared/distribution/mincome-proportional.test.js b/frontend/model/contracts/shared/distribution/mincome-proportional.test.ts similarity index 60% rename from frontend/model/contracts/shared/distribution/mincome-proportional.test.js rename to frontend/model/contracts/shared/distribution/mincome-proportional.test.ts index ab7050cf54..39dafb122c 100644 --- a/frontend/model/contracts/shared/distribution/mincome-proportional.test.js +++ b/frontend/model/contracts/shared/distribution/mincome-proportional.test.ts @@ -1,10 +1,18 @@ -/* eslint-env mocha */ +// Can run directly with: +// deno test --import-map=import_map.json frontend/model/contracts/shared/distribution/mincome-proportional.test.ts + +import { assertEquals } from 'asserts' -import should from 'should' import mincomeProportional from './mincome-proportional.js' -describe('proportionalMincomeDistributionTest', function () { - it('distribute income above mincome proportionally', function () { +type Payment = { + amount: number + from: string + to: string +} + +Deno.test('proportionalMincomeDistributionTest', async function (tests) { + await tests.step('distribute income above mincome proportionally', async function () { const members = [ { name: 'a', haveNeed: -30 }, { name: 'b', haveNeed: -20 }, @@ -22,10 +30,10 @@ describe('proportionalMincomeDistributionTest', function () { { amount: 12, from: 'f', to: 'b' } ] - should(mincomeProportional(members)).eql(expected) + assertEquals(mincomeProportional(members), expected) }) - it('distribute income above mincome proportionally when extra won\'t cover need', function () { + await tests.step('distribute income above mincome proportionally when extra won\'t cover need', async function () { const members = [ { name: 'a', haveNeed: -30 }, { name: 'b', haveNeed: -20 }, @@ -42,10 +50,10 @@ describe('proportionalMincomeDistributionTest', function () { { amount: 6, from: 'f', to: 'a' }, { amount: 4, from: 'f', to: 'b' } ] - should(mincomeProportional(members)).eql(expected) + assertEquals(mincomeProportional(members), expected) }) - it('don\'t distribute anything if no one is above mincome', function () { + await tests.step('don\'t distribute anything if no one is above mincome', async function () { const members = [ { name: 'a', haveNeed: -30 }, { name: 'b', haveNeed: -20 }, @@ -54,11 +62,11 @@ describe('proportionalMincomeDistributionTest', function () { { name: 'e', haveNeed: -20 }, { name: 'f', haveNeed: -30 } ] - const expected = [] - should(mincomeProportional(members)).eql(expected) + const expected: Payment[] = [] + assertEquals(mincomeProportional(members), expected) }) - it('don\'t distribute anything if everyone is above mincome', function () { + await tests.step('don\'t distribute anything if everyone is above mincome', async function () { const members = [ { name: 'a', haveNeed: 0 }, { name: 'b', haveNeed: 5 }, @@ -67,7 +75,7 @@ describe('proportionalMincomeDistributionTest', function () { { name: 'e', haveNeed: 60 }, { name: 'f', haveNeed: 12 } ] - const expected = [] - should(mincomeProportional(members)).eql(expected) + const expected: Payment[] = [] + assertEquals(mincomeProportional(members), expected) }) }) diff --git a/frontend/model/contracts/shared/distribution/payments-minimizer.js b/frontend/model/contracts/shared/distribution/payments-minimizer.js index 5e401ef89e..c852fbb50f 100644 --- a/frontend/model/contracts/shared/distribution/payments-minimizer.js +++ b/frontend/model/contracts/shared/distribution/payments-minimizer.js @@ -3,8 +3,8 @@ // greedy algorithm responsible for "balancing" payments // such that the least number of payments are made. export default function minimizeTotalPaymentsCount ( - distribution: Array -): Array { + distribution /*: Array */ +) /*: Array */ { const neederTotalReceived = {} const haverTotalHave = {} const haversSorted = [] diff --git a/frontend/model/contracts/shared/giLodash.js b/frontend/model/contracts/shared/giLodash.js index f36b6e6ad1..c98c4426b0 100644 --- a/frontend/model/contracts/shared/giLodash.js +++ b/frontend/model/contracts/shared/giLodash.js @@ -2,22 +2,22 @@ // https://github.com/lodash/babel-plugin-lodash // additional tiny versions of lodash functions are available in VueScript2 -export function mapValues (obj: Object, fn: Function, o: Object = {}): any { +export function mapValues (obj /*: Object */, fn /*: Function */, o /*: Object */ = {}) /*: any */ { for (const key in obj) { o[key] = fn(obj[key]) } return o } -export function mapObject (obj: Object, fn: Function): {[any]: any} { +export function mapObject (obj /*: Object */, fn /*: Function */) /*: {[any]: any} */ { return Object.fromEntries(Object.entries(obj).map(fn)) } -export function pick (o: Object, props: string[]): Object { +export function pick (o /*: Object */, props /*: string[] */) /*: Object */ { const x = {} for (const k of props) { x[k] = o[k] } return x } -export function pickWhere (o: Object, where: Function): Object { +export function pickWhere (o /*: Object */, where /*: Function */) /*: Object */ { const x = {} for (const k in o) { if (where(o[k])) { x[k] = o[k] } @@ -25,13 +25,13 @@ export function pickWhere (o: Object, where: Function): Object { return x } -export function choose (array: Array<*>, indices: Array): Array<*> { +export function choose (array /*: Array<*> */, indices /*: Array */) /*: Array<*> */ { const x = [] for (const idx of indices) { x.push(array[idx]) } return x } -export function omit (o: Object, props: string[]): {...} { +export function omit (o /*: Object */, props /*: string[] */) /*: {...} */ { const x = {} for (const k in o) { if (!props.includes(k)) { @@ -41,7 +41,7 @@ export function omit (o: Object, props: string[]): {...} { return x } -export function cloneDeep (obj: Object): any { +export function cloneDeep (obj /*: Object */) /*: any */ { return JSON.parse(JSON.stringify(obj)) } @@ -51,7 +51,7 @@ function isMergeableObject (val) { return nonNullObject && Object.prototype.toString.call(val) !== '[object RegExp]' && Object.prototype.toString.call(val) !== '[object Date]' } -export function merge (obj: Object, src: Object): any { +export function merge (obj /*: Object */, src /*: Object */) /*: any */ { for (const key in src) { const clone = isMergeableObject(src[key]) ? cloneDeep(src[key]) : undefined if (clone && isMergeableObject(obj[key])) { @@ -63,30 +63,42 @@ export function merge (obj: Object, src: Object): any { return obj } -export function delay (msec: number): Promise { +export function delay (msec /*: number */) /*: Promise */ { return new Promise((resolve, reject) => { setTimeout(resolve, msec) }) } -export function randomBytes (length: number): Uint8Array { +export function randomBytes (length /*: number */) /*: Uint8Array */ { // $FlowIssue crypto support: https://github.com/facebook/flow/issues/5019 return crypto.getRandomValues(new Uint8Array(length)) } -export function randomHexString (length: number): string { +export function randomHexString (length /*: number */) /*: string */ { return Array.from(randomBytes(length), byte => (byte % 16).toString(16)).join('') } -export function randomIntFromRange (min: number, max: number): number { +export function randomIntFromRange (min /*: number */, max /*: number */) /*: number */ { return Math.floor(Math.random() * (max - min + 1) + min) } -export function randomFromArray (arr: any[]): any { +export function randomFromArray (arr /*: any[] */) /*: any */ { return arr[Math.floor(Math.random() * arr.length)] } -export function linearScale ([d1, d2]: Array, [r1, r2]: Array): Function { +export function flatten (arr /*: Array<*> */) /*: Array */ { + let flat /*: Array<*> */ = [] + for (let i = 0; i < arr.length; i++) { + if (Array.isArray(arr[i])) { + flat = flat.concat(arr[i]) + } else { + flat.push(arr[i]) + } + } + return flat +} + +export function linearScale ([d1, d2] /*: Array */, [r1, r2] /*: Array */) /*: Function */ { // generate a function that takes a value between d1 and d2 and then // returns a linearly-scaled output whose min and max values are r1 and r2 respectively. const [dSpan, rSpan] = [d2 - d1, r2 - r1] @@ -102,19 +114,7 @@ export function linearScale ([d1, d2]: Array, [r1, r2]: Array): } } -export function flatten (arr: Array<*>): Array { - let flat: Array<*> = [] - for (let i = 0; i < arr.length; i++) { - if (Array.isArray(arr[i])) { - flat = flat.concat(arr[i]) - } else { - flat.push(arr[i]) - } - } - return flat -} - -export function zip (): any[] { +export function zip () /*: any[] */ { // $FlowFixMe const arr = Array.prototype.slice.call(arguments) const zipped = [] @@ -129,26 +129,26 @@ export function zip (): any[] { return zipped } -export function uniq (array: any[]): any[] { +export function uniq (array /*: any[] */) /*: any[] */ { return Array.from(new Set(array)) } -export function union (...arrays: any[][]): any[] { +export function union (...arrays /*: any[][] */) /*: any[] */ { // $FlowFixMe return uniq([].concat.apply([], arrays)) } -export function intersection (a1: any[], ...arrays: any[][]): any[] { +export function intersection (a1 /*: any[] */, ...arrays /*: any[][] */) /*: any[] */ { return uniq(a1).filter(v1 => arrays.every(v2 => v2.indexOf(v1) >= 0)) } -export function difference (a1: any[], ...arrays: any[][]): any[] { +export function difference (a1 /*: any[] */, ...arrays /*: any[][] */) /*: any[] */ { // $FlowFixMe const a2 = [].concat.apply([], arrays) return a1.filter(v => a2.indexOf(v) === -1) } -export function deepEqualJSONType (a: any, b: any): boolean { +export function deepEqualJSONType (a /*: any */, b /*: any */) /*: boolean */ { if (a === b) return true if (a === null || b === null || typeof (a) !== typeof (b)) return false if (typeof a !== 'object') return a === b @@ -178,7 +178,7 @@ export function deepEqualJSONType (a: any, b: any): boolean { * @param {Boolean} whether to execute at the beginning (`false`) * @api public */ -export function debounce (func: Function, wait: number, immediate: ?boolean): Function { +export function debounce (func /*: Function */, wait /*: number */, immediate /*: ?boolean */) /*: Function */ { let timeout, args, context, timestamp, result if (wait == null) wait = 100 @@ -229,7 +229,7 @@ export function debounce (func: Function, wait: number, immediate: ?boolean): Fu return debounced } -export function throttle (func: Function, delay: number): Function { +export function throttle (func /*: Function */, delay /*: number */) /*: Function */ { // reference: https://www.geeksforgeeks.org/javascript-throttling/ // Previously called time of the function @@ -252,7 +252,7 @@ export function throttle (func: Function, delay: number): Function { * `undefined`, the `defaultValue` is returned in its place. * */ -export function get (obj: Object, path: string[], defaultValue: any): any { +export function get (obj /*: Object */, path /*: string[] */, defaultValue /*: any */) /*: any */ { if (!path.length) { return obj } else if (obj === undefined) { diff --git a/frontend/model/contracts/shared/giLodash.test.js b/frontend/model/contracts/shared/giLodash.test.js deleted file mode 100644 index 23871db058..0000000000 --- a/frontend/model/contracts/shared/giLodash.test.js +++ /dev/null @@ -1,86 +0,0 @@ -/* eslint-env mocha */ - -// Can run directly with: -// ./node_modules/.bin/mocha -w --require Gruntfile.js frontend/utils/giLodash.test.js - -import * as _ from './giLodash.js' -import sinon from 'sinon' -require('should-sinon') -const should = require('should') - -describe('Test giLodash', function () { - it('should debounce', function () { - const clock = sinon.useFakeTimers() - const callback = sinon.spy() - const callback2 = sinon.spy() - const debounced = _.debounce(callback, 500) - const debounced2 = _.debounce(callback2, 500) - debounced() - clock.tick(400) - callback.should.be.not.called() - debounced() - clock.tick(400) - callback.should.be.not.called() - clock.tick(400) - callback.should.be.called() - debounced() - clock.tick(200) - callback.should.be.not.calledTwice() - clock.tick(300) - callback.should.be.calledTwice() - debounced2() - debounced2() - debounced2.flush() - callback2.should.be.calledOnce() - debounced2() - clock.tick(450) - debounced2.clear() - callback2.should.be.calledOnce() - clock.restore() - }) - it('should choose', function () { - const a = _.choose([7, 3, 9, [0], 1], [0, 3]) - should(a).deepEqual([7, [0]]) - }) - it('should mapObject', function () { - should(_.mapObject({ - foo: 5, - bar: 'asdf' - }, ([key, value]) => { - return [`process.env.${key}`, JSON.stringify(value)] - })).deepEqual({ - 'process.env.foo': '5', - 'process.env.bar': '"asdf"' - }) - }) - it('should merge', function () { - const a = { a: 'taco', b: { a: 'burrito', b: 'combo' }, c: [20] } - const b = { a: 'churro', b: { c: 'platter' } } - const c = _.merge(a, b) - should(c).deepEqual({ a: 'churro', b: { a: 'burrito', b: 'combo', c: 'platter' }, c: [20] }) - }) - it('should flatten', function () { - const a = [1, [2, [3, 4]], 5] - const b = _.flatten(a) - should(b).deepEqual([1, 2, [3, 4], 5]) // important: use deepEqual not equal - }) - it('should zip', function () { - const a = _.zip([1, 2], ['a', 'b'], [true, false, null]) - const b = _.zip(['/foo/bar/node_modules/vue/dist/vue.common.js']) - const c = _.zip(['/foo/bar/node_modules/vue/dist/vue.common.js'], []) - should(a).deepEqual([[1, 'a', true], [2, 'b', false], [undefined, undefined, null]]) - should(b).deepEqual([['/foo/bar/node_modules/vue/dist/vue.common.js']]) - should(b).deepEqual([['/foo/bar/node_modules/vue/dist/vue.common.js']]) - should(c).deepEqual([['/foo/bar/node_modules/vue/dist/vue.common.js', undefined]]) - }) - it('should deepEqual for JSON only', function () { - should(_.deepEqualJSONType(4, 4)).be.true() - should(_.deepEqualJSONType(4, 5)).be.false() - should(_.deepEqualJSONType(4, new Number(4))).be.false() // eslint-disable-line - should(() => _.deepEqualJSONType(new Number(4), new Number(4))).throw() // eslint-disable-line - should(_.deepEqualJSONType('asdf', 'asdf')).be.true() - should(() => _.deepEqualJSONType(new String('asdf'), new String('asdf'))).throw() // eslint-disable-line - should(_.deepEqualJSONType({ a: 5, b: ['adsf'] }, { b: ['adsf'], a: 5 })).be.true() - should(_.deepEqualJSONType({ a: 5, b: ['adsf', {}] }, { b: ['adsf'], a: 5 })).be.false() - }) -}) diff --git a/frontend/model/contracts/shared/giLodash.test.ts b/frontend/model/contracts/shared/giLodash.test.ts new file mode 100644 index 0000000000..e45fa5a334 --- /dev/null +++ b/frontend/model/contracts/shared/giLodash.test.ts @@ -0,0 +1,84 @@ +// Can run directly with: +// deno test --import-map=import_map.json frontend/model/contracts/shared/giLodash.test.ts + +import { assertEquals, assertThrows } from 'asserts' +import sinon from 'sinon' + +import * as _ from './giLodash.js' + +Deno.test('Tests for giLodash', async function (tests) { + await tests.step('should debounce', async function () { + const clock = sinon.useFakeTimers() + const callback = sinon.spy() + const callback2 = sinon.spy() + const debounced = _.debounce(callback, 500, false) + const debounced2 = _.debounce(callback2, 500, false) + debounced() + clock.tick(400) + assertEquals(callback.called, false) + debounced() + clock.tick(400) + assertEquals(callback.called, false) + clock.tick(400) + assertEquals(callback.called, true) + debounced() + clock.tick(200) + assertEquals(callback.getCalls().length, 1) + clock.tick(300) + assertEquals(callback.getCalls().length, 2) + debounced2() + debounced2() + debounced2.flush() + assertEquals(callback2.getCalls().length, 1) + debounced2() + clock.tick(450) + debounced2.clear() + assertEquals(callback2.getCalls().length, 1) + clock.restore() + }) + await tests.step('should choose', async function () { + const a = _.choose([7, 3, 9, [0], 1], [0, 3]) + assertEquals(a, [7, [0]]) + }) + await tests.step('should mapObject', async function () { + assertEquals(_.mapObject({ + foo: 5, + bar: 'asdf' + }, ([key, value]: [string, unknown]) => { + return [`process.env.${key}`, JSON.stringify(value)] + }), { + 'process.env.foo': '5', + 'process.env.bar': '"asdf"' + }) + }) + await tests.step('should merge', async function () { + const a = { a: 'taco', b: { a: 'burrito', b: 'combo' }, c: [20] } + const b = { a: 'churro', b: { c: 'platter' } } + const c = _.merge(a, b) + assertEquals(c, { a: 'churro', b: { a: 'burrito', b: 'combo', c: 'platter' }, c: [20] }) + }) + await tests.step('should flatten', async function () { + const a = [1, [2, [3, 4]], 5] + const b = _.flatten(a) + assertEquals(b, [1, 2, [3, 4], 5]) // important: use deepEqual not equal + }) + await tests.step('should zip', async function () { + const a = _.zip([1, 2], ['a', 'b'], [true, false, null]) + const b = _.zip(['/foo/bar/node_modules/vue/dist/vue.common.js']) + const c = _.zip(['/foo/bar/node_modules/vue/dist/vue.common.js'], []) + assertEquals(a, [[1, 'a', true], [2, 'b', false], [undefined, undefined, null]]) + assertEquals(b, [['/foo/bar/node_modules/vue/dist/vue.common.js']]) + assertEquals(b, [['/foo/bar/node_modules/vue/dist/vue.common.js']]) + assertEquals(c, [['/foo/bar/node_modules/vue/dist/vue.common.js', undefined]]) + }) + await tests.step('should deepEqual for JSON only', async function () { + assertEquals(_.deepEqualJSONType(4, 4), true) + assertEquals(_.deepEqualJSONType(4, 5), false) + assertEquals(_.deepEqualJSONType(4, new Number(4)), false) // eslint-disable-line + assertThrows(() => _.deepEqualJSONType(new Number(4), new Number(4))) // eslint-disable-line + assertEquals(_.deepEqualJSONType('asdf', 'asdf'), true) + assertThrows(() => _.deepEqualJSONType(new String('asdf'), new String('asdf'))) // eslint-disable-line + assertEquals(_.deepEqualJSONType({ a: 5, b: ['adsf'] }, { b: ['adsf'], a: 5 }), true) + assertEquals(_.deepEqualJSONType({ a: 5, b: ['adsf', {}] }, { b: ['adsf'], a: 5 }), false) + }) +}) diff --git a/frontend/model/contracts/shared/index.js b/frontend/model/contracts/shared/index.js new file mode 100644 index 0000000000..db7a767bd1 --- /dev/null +++ b/frontend/model/contracts/shared/index.js @@ -0,0 +1,26 @@ +// Useful for bundling. +'use strict' + +import mincomeProportional from './distribution/mincome-proportional.js' +import minimizeTotalPaymentsCount from './distribution/payments-minimizer.js' +import proposals from './voting/proposals.js' +import rules from './voting/rules.js' + +export * from './constants.js' +export * from './currencies.js' +export * from './distribution/distribution.js' +export * from './payments/index.js' +export * from './functions.js' +export * from './giLodash.js' +export * from './nativeNotification.js' +export * from './time.js' +export * from './validators.js' +export * from './voting/proposals.js' +export * from './voting/rules.js' + +export { + mincomeProportional, + minimizeTotalPaymentsCount, + proposals, + rules +} diff --git a/frontend/model/contracts/shared/time.js b/frontend/model/contracts/shared/time.js index 23484f201e..517afb1ca5 100644 --- a/frontend/model/contracts/shared/time.js +++ b/frontend/model/contracts/shared/time.js @@ -7,7 +7,7 @@ export const HOURS_MILLIS = 60 * MINS_MILLIS export const DAYS_MILLIS = 24 * HOURS_MILLIS export const MONTHS_MILLIS = 30 * DAYS_MILLIS -export function addMonthsToDate (date: string, months: number): Date { +export function addMonthsToDate (date /*: string */, months /*: number */) /*: Date */ { const now = new Date(date) return new Date(now.setMonth(now.getMonth() + months)) } @@ -18,17 +18,17 @@ export function addMonthsToDate (date: string, months: number): Date { // TODO: We may want to, for example, get the time from the server instead of relying on // the client in case the client's clock isn't set correctly. // See: https://github.com/okTurtles/group-income/issues/531 -export function dateToPeriodStamp (date: string | Date): string { +export function dateToPeriodStamp (date /*: string | Date */) /*: string */ { return new Date(date).toISOString() } -export function dateFromPeriodStamp (daystamp: string): Date { +export function dateFromPeriodStamp (daystamp /*: string */) /*: Date */ { return new Date(daystamp) } -export function periodStampGivenDate ({ recentDate, periodStart, periodLength }: { +export function periodStampGivenDate ({ recentDate, periodStart, periodLength } /*: { recentDate: string, periodStart: string, periodLength: number -}): string { +} */) /*: string */ { const periodStartDate = dateFromPeriodStamp(periodStart) let nextPeriod = addTimeToDate(periodStartDate, periodLength) const curDate = new Date(recentDate) @@ -54,27 +54,27 @@ export function periodStampGivenDate ({ recentDate, periodStart, periodLength }: return dateToPeriodStamp(curPeriod) } -export function dateIsWithinPeriod ({ date, periodStart, periodLength }: { +export function dateIsWithinPeriod ({ date, periodStart, periodLength } /*: { date: string, periodStart: string, periodLength: number -}): boolean { +} */) /*: boolean */ { const dateObj = new Date(date) const start = dateFromPeriodStamp(periodStart) return dateObj > start && dateObj < addTimeToDate(start, periodLength) } -export function addTimeToDate (date: string | Date, timeMillis: number): Date { +export function addTimeToDate (date /*: string | Date */, timeMillis /*: number */) /*: Date */ { const d = new Date(date) d.setTime(d.getTime() + timeMillis) return d } -export function dateToMonthstamp (date: string | Date): string { +export function dateToMonthstamp (date /*: string | Date */) /*: string */ { // we could use Intl.DateTimeFormat but that doesn't support .format() on Android // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat/format return new Date(date).toISOString().slice(0, 7) } -export function dateFromMonthstamp (monthstamp: string): Date { +export function dateFromMonthstamp (monthstamp /*: string */) /*: Date */ { // this is a hack to prevent new Date('2020-01').getFullYear() => 2019 return new Date(`${monthstamp}-01T00:01:00.000Z`) // the Z is important } @@ -82,76 +82,76 @@ export function dateFromMonthstamp (monthstamp: string): Date { // TODO: to prevent conflicts among user timezones, we need // to use the server's time, and not our time here. // https://github.com/okTurtles/group-income/issues/531 -export function currentMonthstamp (): string { +export function currentMonthstamp () /*: string */ { return dateToMonthstamp(new Date()) } -export function prevMonthstamp (monthstamp: string): string { +export function prevMonthstamp (monthstamp /*: string */) /*: string */ { const date = dateFromMonthstamp(monthstamp) date.setMonth(date.getMonth() - 1) return dateToMonthstamp(date) } -export function comparePeriodStamps (periodA: string, periodB: string): number { +export function comparePeriodStamps (periodA /*: string */, periodB /*: string */) /*: number */ { return dateFromPeriodStamp(periodA).getTime() - dateFromPeriodStamp(periodB).getTime() } -export function compareMonthstamps (monthstampA: string, monthstampB: string): number { +export function compareMonthstamps (monthstampA /*: string */, monthstampB /*: string */) /*: number */ { return dateFromMonthstamp(monthstampA).getTime() - dateFromMonthstamp(monthstampB).getTime() // const A = dateA.getMonth() + dateA.getFullYear() * 12 // const B = dateB.getMonth() + dateB.getFullYear() * 12 // return A - B } -export function compareISOTimestamps (a: string, b: string): number { +export function compareISOTimestamps (a /*: string */, b /*: string */) /*: number */ { return new Date(a).getTime() - new Date(b).getTime() } -export function lastDayOfMonth (date: Date): Date { +export function lastDayOfMonth (date /*: Date */) /*: Date */ { return new Date(date.getFullYear(), date.getMonth() + 1, 0) } -export function firstDayOfMonth (date: Date): Date { +export function firstDayOfMonth (date /*: Date */) /*: Date */ { return new Date(date.getFullYear(), date.getMonth(), 1) } export function humanDate ( - date: number | Date | string, - options?: Intl$DateTimeFormatOptions = { month: 'short', day: 'numeric' } -): string { + date /*: number | Date | string */, + options /*: Intl$DateTimeFormatOptions */ = { month: 'short', day: 'numeric' } +) /*: string */ { const locale = typeof navigator === 'undefined' // Fallback for Mocha tests. ? 'en-US' // Flow considers `navigator.languages` to be of type `$ReadOnlyArray`, // which is not compatible with the `string[]` expected by `.toLocaleDateString()`. // Casting to `string[]` through `any` as a workaround. - : ((navigator.languages: any): string[]) ?? navigator.language + : ((navigator.languages /*: any */) /*: string[] */) ?? navigator.language // NOTE: `.toLocaleDateString()` automatically takes local timezone differences into account. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString return new Date(date).toLocaleDateString(locale, options) } -export function isPeriodStamp (arg: string): boolean { +export function isPeriodStamp (arg /*: string */) /*: boolean */ { return /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(arg) } -export function isFullMonthstamp (arg: string): boolean { +export function isFullMonthstamp (arg /*: string */) /*: boolean */ { return /^\d{4}-(0[1-9]|1[0-2])$/.test(arg) } -export function isMonthstamp (arg: string): boolean { +export function isMonthstamp (arg /*: string */) /*: boolean */ { return isShortMonthstamp(arg) || isFullMonthstamp(arg) } -export function isShortMonthstamp (arg: string): boolean { +export function isShortMonthstamp (arg /*: string */) /*: boolean */ { return /^(0[1-9]|1[0-2])$/.test(arg) } -export function monthName (monthstamp: string): string { +export function monthName (monthstamp /*: string */) /*: string */ { return humanDate(dateFromMonthstamp(monthstamp), { month: 'long' }) } -export function proximityDate (date: Date): string { +export function proximityDate (date /*: Date */) /*: string */ { date = new Date(date) const today = new Date() const yesterday = (d => new Date(d.setDate(d.getDate() - 1)))(new Date()) @@ -171,7 +171,7 @@ export function proximityDate (date: Date): string { return pd } -export function timeSince (datems: number, dateNow: number = Date.now()): string { +export function timeSince (datems /*: number */, dateNow /*: number */ = Date.now()) /*: string */ { const interval = dateNow - datems if (interval >= DAYS_MILLIS * 2) { @@ -191,7 +191,7 @@ export function timeSince (datems: number, dateNow: number = Date.now()): string return L('<1m') } -export function cycleAtDate (atDate: string | Date): number { +export function cycleAtDate (atDate /*: string | Date */) /*: number */ { const now = new Date(atDate) // Just in case the parameter is a string type. const partialCycles = now.getDate() / lastDayOfMonth(now).getDate() return partialCycles diff --git a/frontend/model/contracts/shared/time.test.js b/frontend/model/contracts/shared/time.test.js deleted file mode 100644 index 1349e8caa5..0000000000 --- a/frontend/model/contracts/shared/time.test.js +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-env mocha */ -import { - DAYS_MILLIS, - HOURS_MILLIS, - MINS_MILLIS, - timeSince -} from './time.js' -const should = require('should') - -describe('timeSince', function () { - const currentDate = 1590823007327 - it('Current date is "May 30, 7:16 AM"', () => { - const humanDate = new Date(currentDate).toLocaleDateString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }) - should(humanDate).equal('May 30, 7:16 AM') - }) - - it('should return "<1m" when 59s have passed', () => { - should(timeSince( - currentDate - 59e3, - currentDate - )).equal('<1m') - }) - - it('should return "1m" when 60s have passed', () => { - should(timeSince( - currentDate - MINS_MILLIS, - currentDate - )).equal('1m') - }) - - it('should return "11m" when 11min have passed', () => { - should(timeSince( - currentDate - MINS_MILLIS * 11, - currentDate - )).equal('11m') - }) - - it('should return "1h" when 60min have passed', () => { - should(timeSince( - currentDate - MINS_MILLIS * 60, - currentDate - )).equal('1h') - }) - - it('should return "4h" when 4h25m have passed', () => { - should(timeSince( - currentDate - HOURS_MILLIS * 4.25, - currentDate - )).equal('4h') - }) - - it('should return "1d" when 24h have passed', () => { - should(timeSince( - currentDate - HOURS_MILLIS * 24, - currentDate - )).equal('1d') - }) - - it('should return "1d" when 40h have passed', () => { - should(timeSince( - currentDate - HOURS_MILLIS * 40, - currentDate - )).equal('1d') - }) - - it('should return current day when +48h have passed', () => { - should(timeSince( - currentDate - DAYS_MILLIS * 23, - currentDate - )).equal('May 7') - }) -}) diff --git a/frontend/model/contracts/shared/time.test.ts b/frontend/model/contracts/shared/time.test.ts new file mode 100644 index 0000000000..08e67cfc4a --- /dev/null +++ b/frontend/model/contracts/shared/time.test.ts @@ -0,0 +1,88 @@ +// Can run directly with: +// deno test --import-map=import_map.json frontend/model/contracts/shared/time.test.ts + +import { assertEquals } from 'asserts' + +import { + DAYS_MILLIS, + HOURS_MILLIS, + MINS_MILLIS, + timeSince +} from './time.js' + +Deno.test('Tests for time.js', async function (tests) { + const defaultLocale = 'en-US' + // In Deno ^v1.27.0, `navigator.language` is defined, + // which results in failed tests on devices using another locale than en-US. + if (navigator.language !== undefined) { + Object.defineProperty(navigator, 'language', { configurable: true, enumerable: true, writable: false, value: defaultLocale }) + } + if (navigator.languages !== undefined) { + Object.defineProperty(navigator, 'languages', { configurable: true, enumerable: true, writable: false, value: [defaultLocale] }) + } + + await tests.step('timeSince', async function (tests) { + const currentDate = 1590823007327 + + await tests.step('Current date is "May 30, 7:16 AM"', () => { + const humanDate = new Date(currentDate).toLocaleDateString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }) + assertEquals(humanDate, 'May 30, 7:16 AM') + }) + + await tests.step('assertEquals return "<1m" when 59s have passed', () => { + assertEquals(timeSince( + currentDate - 59e3, + currentDate + ), '<1m') + }) + + await tests.step('assertEquals return "1m" when 60s have passed', () => { + assertEquals(timeSince( + currentDate - MINS_MILLIS, + currentDate + ), '1m') + }) + + await tests.step('assertEquals return "11m" when 11min have passed', () => { + assertEquals(timeSince( + currentDate - MINS_MILLIS * 11, + currentDate + ), '11m') + }) + + await tests.step('assertEquals return "1h" when 60min have passed', () => { + assertEquals(timeSince( + currentDate - MINS_MILLIS * 60, + currentDate + ), '1h') + }) + + await tests.step('assertEquals return "4h" when 4h25m have passed', () => { + assertEquals(timeSince( + currentDate - HOURS_MILLIS * 4.25, + currentDate + ), '4h') + }) + + await tests.step('assertEquals return "1d" when 24h have passed', () => { + assertEquals(timeSince( + currentDate - HOURS_MILLIS * 24, + currentDate + ), '1d') + }) + + await tests.step('assertEquals return "1d" when 40h have passed', () => { + assertEquals(timeSince( + currentDate - HOURS_MILLIS * 40, + currentDate + ), '1d') + }) + + await tests.step('assertEquals return current day when +48h have passed', () => { + assertEquals(timeSince( + currentDate - DAYS_MILLIS * 23, + currentDate + ), 'May 7') + }) + }) +}) diff --git a/frontend/model/contracts/shared/voting/rules.js b/frontend/model/contracts/shared/voting/rules.js index fecd9fa2dd..f258925740 100644 --- a/frontend/model/contracts/shared/voting/rules.js +++ b/frontend/model/contracts/shared/voting/rules.js @@ -30,7 +30,7 @@ export const RULE_MULTI_CHOICE = 'multi-choice' const getPopulation = (state) => Object.keys(state.profiles).filter(p => state.profiles[p].status === PROFILE_STATUS.ACTIVE).length -const rules: Object = { +const rules /*: Object */ = { [RULE_PERCENTAGE]: function (state, proposalType, votes) { votes = Object.values(votes) let population = getPopulation(state) @@ -85,7 +85,7 @@ const rules: Object = { export default rules -export const ruleType: any = unionOf(...Object.keys(rules).map(k => literalOf(k))) +export const ruleType /*: any */ = unionOf(...Object.keys(rules).map(k => literalOf(k))) /** * @@ -99,7 +99,7 @@ export const ruleType: any = unionOf(...Object.keys(rules).map(k => literalOf(k) * @example ('percentage', 0.1, 10) => 0.2 * @example ('percentage', 0.3, 10) => 0.3 */ -export const getThresholdAdjusted = (rule: string, threshold: number, groupSize: number): number => { +export const getThresholdAdjusted = (rule /*: string */, threshold /*: number */, groupSize /*: number */) /*: number */ => { const groupSizeVoting = Math.max(3, groupSize) // 3 = minimum groupSize to vote return { @@ -121,12 +121,12 @@ export const getThresholdAdjusted = (rule: string, threshold: number, groupSize: * @example (3, 0.8) => 3 * @example (1, 0.6) => 2 */ -export const getCountOutOfMembers = (groupSize: number, decimal: number): number => { +export const getCountOutOfMembers = (groupSize /*: number */, decimal /*: number */) /*: number */ => { const minGroupSize = 3 // when group can vote return Math.ceil(Math.max(minGroupSize, groupSize) * decimal) } -export const getPercentFromDecimal = (decimal: number): number => { +export const getPercentFromDecimal = (decimal /*: number */) /*: number */ => { // convert decimal to percentage avoiding weird decimals results. // e.g. 0.58 -> 58 instead of 57.99999 return Math.round(decimal * 100) diff --git a/frontend/model/contracts/shared/voting/rules.test.js b/frontend/model/contracts/shared/voting/rules.test.ts similarity index 56% rename from frontend/model/contracts/shared/voting/rules.test.js rename to frontend/model/contracts/shared/voting/rules.test.ts index 17483b468e..adfa853e05 100644 --- a/frontend/model/contracts/shared/voting/rules.test.js +++ b/frontend/model/contracts/shared/voting/rules.test.ts @@ -1,16 +1,39 @@ -/* eslint-env mocha */ -import rules, { RULE_PERCENTAGE, RULE_DISAGREEMENT, VOTE_FOR, VOTE_AGAINST, VOTE_UNDECIDED } from './rules.js' -import { PROPOSAL_REMOVE_MEMBER } from '~/frontend/model/contracts/shared/constants.js' -const should = require('should') +// Can run directly with: +// deno test --import-map=import_map.json frontend/model/contracts/shared/voting/rules.test.ts + +import { assertEquals } from 'asserts' + +import { + rules, + PROPOSAL_REMOVE_MEMBER, + RULE_PERCENTAGE, + RULE_DISAGREEMENT, + VOTE_FOR, + VOTE_AGAINST, + VOTE_UNDECIDED +} from '@test-contracts/shared.js' + +type Options = { + membersInactive?: number + proposalType?: string +} + +type Profile = { + status: string +} + +type ProfileMap = { + [key: string]: Profile +} -const buildState = (groupSize, rule, threshold, opts = {}) => { +const buildState = (groupSize: number, rule: string, threshold: number, opts: Options = {}) => { const { proposalType, membersInactive } = { proposalType: opts.proposalType || 'generic', membersInactive: opts.membersInactive || 0 } return { profiles: (() => { - const profiles = {} + const profiles: ProfileMap = {} for (let i = 0; i < groupSize; i++) { profiles[`u${i}`] = { status: 'active' } } @@ -38,87 +61,89 @@ const buildState = (groupSize, rule, threshold, opts = {}) => { // VF - Vote For // VA - Vote Against -describe('RULE_PERCENTAGE - 70% - 5 members', function () { +Deno.test('RULE_PERCENTAGE - 70% - 5 members', async function (tests) { const state = buildState(5, RULE_PERCENTAGE, 0.70) - it('3VF returns undecided', () => { + + await tests.step('3VF returns undecided', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, u3: VOTE_FOR }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('4VF returns for', () => { + await tests.step('4VF returns for', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, u3: VOTE_FOR, u4: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) - it('2VA returns against', () => { + await tests.step('2VA returns against', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_AGAINST, u2: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) }) -describe('RULE_PERCENTAGE - 25% - 5 members - (adjusted to 40%)', function () { +Deno.test('RULE_PERCENTAGE - 25% - 5 members - (adjusted to 40%)', async function (tests) { const state = buildState(5, RULE_PERCENTAGE, 0.4) - it('1VF returns undecided', () => { + + await tests.step('1VF returns undecided', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_FOR }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('2VF returns for', () => { + await tests.step('2VF returns for', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) - it('3VA returns undecided', () => { + await tests.step('3VA returns undecided', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_AGAINST, u2: VOTE_AGAINST, u3: VOTE_AGAINST }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('4VA returns against', () => { + await tests.step('4VA returns against', () => { const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_AGAINST, u2: VOTE_AGAINST, u3: VOTE_AGAINST, u4: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) }) -describe('RULE_DISAGREEMENT - 1 - 5 members', function () { +Deno.test('RULE_DISAGREEMENT - 1 - 5 members', async function (tests) { const state = buildState(5, RULE_DISAGREEMENT, 1) - it('4VF returns undecided', () => { + await tests.step('4VF returns undecided', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, u3: VOTE_FOR, u4: VOTE_FOR }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('4VA returns for', () => { + await tests.step('4VA returns for', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, @@ -126,56 +151,56 @@ describe('RULE_DISAGREEMENT - 1 - 5 members', function () { u4: VOTE_FOR, u5: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) - it('1VA returns against', () => { + await tests.step('1VA returns against', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) }) -describe('RULE_DISAGREEMENT - 4 - 5 members', function () { +Deno.test('RULE_DISAGREEMENT - 4 - 5 members', async function (tests) { const state = buildState(5, RULE_DISAGREEMENT, 4) - it('1VF vs 1VA returns for', () => { + await tests.step('1VF vs 1VA returns for', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_AGAINST }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('2VF returns for', () => { + await tests.step('2VF returns for', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) - it('3VA returns undecided', () => { + await tests.step('3VA returns undecided', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_AGAINST, u2: VOTE_AGAINST, u3: VOTE_AGAINST }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('4VA returns against', () => { + await tests.step('4VA returns against', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_AGAINST, u2: VOTE_AGAINST, u3: VOTE_AGAINST, u4: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) - it('2VF vs 3VA returns for', () => { + await tests.step('2VF vs 3VA returns for', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, @@ -183,109 +208,111 @@ describe('RULE_DISAGREEMENT - 4 - 5 members', function () { u4: VOTE_AGAINST, u5: VOTE_AGAINST }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) }) -describe('RULE_DISAGREEMENT - 10 - 4 members - (10 adjusted to 3)', function () { +Deno.test('RULE_DISAGREEMENT - 10 - 4 members - (10 adjusted to 3)', async function (tests) { const state = buildState(4, RULE_DISAGREEMENT, 10) - it('3VA returns against', () => { + await tests.step('3VA returns against', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_AGAINST, u2: VOTE_AGAINST, u3: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) - it('2VF returns for', () => { + await tests.step('2VF returns for', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) - it('1VF vs 1VA returns undecided', () => { + await tests.step('1VF vs 1VA returns undecided', () => { const result = rules[RULE_DISAGREEMENT](state, 'generic', { u1: VOTE_FOR, u2: VOTE_AGAINST }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) }) -describe('RULE_PERCENTAGE - 60% - inactive members', function () { - it('6 members - 3VF returns undecided', () => { +Deno.test('RULE_PERCENTAGE - 60% - inactive members', async function (tests) { + await tests.step('6 members - 3VF returns undecided', () => { const state = buildState(6, RULE_PERCENTAGE, 0.6) const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, u3: VOTE_FOR }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('6 members (1 inactive) - 3VF returns for', () => { + await tests.step('6 members (1 inactive) - 3VF returns for', () => { const state = buildState(6, RULE_PERCENTAGE, 0.6, { membersInactive: 1 }) const result = rules[RULE_PERCENTAGE](state, 'generic', { u1: VOTE_FOR, u2: VOTE_FOR, u3: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) }) -describe('RULE_DISAGREEMENT - 1 - 3 members - propose to remove member', function () { +Deno.test('RULE_DISAGREEMENT - 1 - 3 members - propose to remove member', async function (tests) { const state = buildState(3, RULE_DISAGREEMENT, 1, { proposalType: PROPOSAL_REMOVE_MEMBER }) - it('1VA returns undecided', () => { + + await tests.step('1VA returns undecided', () => { const result = rules[RULE_DISAGREEMENT](state, PROPOSAL_REMOVE_MEMBER, { u1: VOTE_AGAINST }) - should(result).equal(VOTE_UNDECIDED) + assertEquals(result, VOTE_UNDECIDED) }) - it('2VA returns against', () => { + await tests.step('2VA returns against', () => { const result = rules[RULE_DISAGREEMENT](state, PROPOSAL_REMOVE_MEMBER, { u1: VOTE_AGAINST, u2: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) - it('2VF returns for', () => { + await tests.step('2VF returns for', () => { const result = rules[RULE_DISAGREEMENT](state, PROPOSAL_REMOVE_MEMBER, { u1: VOTE_FOR, u2: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) }) -describe('RULE_PERCENTAGE - 80% - 3 members - propose to remove member', function () { +Deno.test('RULE_PERCENTAGE - 80% - 3 members - propose to remove member', async function (tests) { const state = buildState(3, RULE_PERCENTAGE, 0.8, { proposalType: PROPOSAL_REMOVE_MEMBER }) - it('1VA returns undecided', () => { + + await tests.step('1VA returns undecided', () => { const result = rules[RULE_PERCENTAGE](state, PROPOSAL_REMOVE_MEMBER, { u1: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) - it('2VA returns against', () => { + await tests.step('2VA returns against', () => { const result = rules[RULE_PERCENTAGE](state, PROPOSAL_REMOVE_MEMBER, { u1: VOTE_AGAINST, u2: VOTE_AGAINST }) - should(result).equal(VOTE_AGAINST) + assertEquals(result, VOTE_AGAINST) }) - it('2VF returns for', () => { + await tests.step('2VF returns for', () => { const result = rules[RULE_PERCENTAGE](state, PROPOSAL_REMOVE_MEMBER, { u1: VOTE_FOR, u2: VOTE_FOR }) - should(result).equal(VOTE_FOR) + assertEquals(result, VOTE_FOR) }) }) diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index 83ac4dea1b..2c760ddfd6 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -1,4 +1,4 @@ -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import type { GIMessage } from '~/shared/domains/chelonia/types.flow.js' import type { NewProposalType, NotificationTemplate diff --git a/frontend/model/state.js b/frontend/model/state.js index 5bcb3f0758..f90780f2cf 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -5,7 +5,7 @@ import sbp from '@sbp/sbp' import { Vue } from '@common/common.js' -import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/events.js' +import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/events.ts' import Vuex from 'vuex' import { CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' import { MINS_MILLIS } from '@model/contracts/shared/time.js' diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 2beedb895e..59bc3f187b 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -1,7 +1,7 @@ 'use strict' import sbp from '@sbp/sbp' -import { blake32Hash } from '~/shared/functions.js' +import { blake32Hash } from '~/shared/functions.ts' import { handleFetchResult } from '~/frontend/controller/utils/misc.js' // Copied from https://stackoverflow.com/a/27980815/4737729 diff --git a/backend/auth.js b/historical/backend/auth.js similarity index 100% rename from backend/auth.js rename to historical/backend/auth.js diff --git a/import_map.json b/import_map.json new file mode 100644 index 0000000000..f4e9c29930 --- /dev/null +++ b/import_map.json @@ -0,0 +1,69 @@ +{ + "imports": { + "~/": "./", + "~/backend/": "./backend/", + "~/shared/": "./shared/", + "@common/": "./frontend/common/", + "@contracts/": "./frontend/model/contracts/", + "@test-contracts/": "./test/contracts/", + "@sbp/": "https://cdn.skypack.dev/@sbp/", + "asserts": "https://deno.land/std@0.161.0/testing/asserts.ts", + "blakejs": "https://cdn.skypack.dev/pin/blakejs@v1.2.1-x2cUecvDoYCQ5VOei7tK/mode=imports,min/optimized/blakejs.js", + "browser-sync": "https://esm.sh/v102/*browser-sync@2.27.11", + "buffer": "https://cdn.skypack.dev/pin/buffer@v6.0.3-9TXtXoOPyENPVOx2wqZk/mode=imports,min/optimized/buffer.js", + "dompurify": "https://cdn.skypack.dev/pin/dompurify@v2.4.0-v17nByMVzL2lE2lRHgyo/mode=imports,min/optimized/dompurify.js", + "fmt/": "https://deno.land/std@0.161.0/fmt/", + "lru-cache": "https://cdn.skypack.dev/pin/lru-cache@v7.14.0-2D6bOfAhBDZrjELxYska/mode=imports,min/optimized/lru-cache.js", + "multihashes": "https://esm.sh/multihashes@v4.0.3", + "path": "https://deno.land/std@0.161.0/path/mod.ts", + "sinon": "https://cdn.skypack.dev/sinon@14.0.1?dts", + "tweetnacl": "https://cdn.skypack.dev/tweetnacl@1.0.3", + "pogo": "https://raw.githubusercontent.com/snowteamer/pogo/master/main.ts", + "pogo/": "https://raw.githubusercontent.com/snowteamer/pogo/master/", + "pug-lint": "https://esm.sh/pug-lint@v2.6.0", + "vue": "https://esm.sh/v102/*vue@2.7.14", + "vue/": "https://esm.sh/v102/*vue@2.7.14/" + }, + "scopes": { + "browser-sync": { + "browser-sync-client": "https://esm.sh/v102/browser-sync-client@2.27.11", + "browser-sync-ui": "https://esm.sh/v102/browser-sync-ui@2.27.11", + "bs-recipes": "https://esm.sh/v102/bs-recipes@1.3.4", + "bs-snippet-injector": "https://esm.sh/v102/bs-snippet-injector@2.0.1", + "chokidar": "https://esm.sh/v102/chokidar@3.5.3", + "connect-history-api-fallback": "https://esm.sh/v102/connect-history-api-fallback@1.6.0", + "connect": "https://esm.sh/v102/connect@3.6.6", + "dev-ip": "https://esm.sh/v102/dev-ip@1.0.1", + "easy-extender": "https://esm.sh/v102/easy-extender@2.3.4", + "eazy-logger": "https://esm.sh/v102/eazy-logger@3.1.0", + "etag": "https://esm.sh/v102/etag@1.8.1", + "fresh": "https://esm.sh/v102/fresh@0.5.2", + "fs-extra": "https://esm.sh/v102/fs-extra@3.0.1", + "http-proxy": "https://esm.sh/v102/http-proxy@1.18.1", + "immutable": "https://esm.sh/v102/immutable@3.8.2", + "localtunnel": "https://esm.sh/v102/localtunnel@2.0.2", + "micromatch": "https://esm.sh/v102/micromatch@4.0.5", + "opn": "https://esm.sh/v102/opn@5.3.0", + "portscanner": "https://esm.sh/v102/portscanner@2.2.0", + "qs": "https://esm.sh/v102/qs@6.11.0", + "raw-body": "https://esm.sh/v102/raw-body@2.5.1", + "resp-modifier": "https://esm.sh/v102/resp-modifier@6.0.2", + "rx": "https://esm.sh/v102/rx@4.1.0", + "send": "https://esm.sh/v102/send@0.16.2", + "serve-index": "https://esm.sh/v102/serve-index@1.9.1", + "serve-static": "https://esm.sh/v102/serve-static@1.13.2", + "server-destroy": "https://esm.sh/v102/server-destroy@1.0.1", + "socket.io": "https://esm.sh/v102/socket.io@4.5.4", + "ua-parser-js": "https://esm.sh/v102/ua-parser-js@1.0.2", + "yargs": "https://esm.sh/v102/yargs@17.6.2" + }, + "vue": { + "@vue/compiler-sfc": "https://esm.sh/v102/@vue/compiler-sfc@2.7.14", + "csstype": "https://esm.sh/v102/csstype@3.1.1" + }, + "vue/": { + "@vue/compiler-sfc": "https://esm.sh/v102/@vue/compiler-sfc@2.7.14", + "csstype": "https://esm.sh/v102/csstype@3.1.1" + } + } +} diff --git a/package-lock.json b/package-lock.json index 54b3a78f4b..d448bb3691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,8 @@ "@babel/register": "7.12.1", "@babel/runtime": "7.12.5", "@chelonia/cli": "1.1.3", + "@typescript-eslint/eslint-plugin": "5.37.0", + "@typescript-eslint/parser": "5.37.0", "@vue/component-compiler": "4.2.4", "acorn": "8.0.4", "babel-plugin-module-resolver": "4.1.0", @@ -78,7 +80,6 @@ "grunt-contrib-copy": "1.0.0", "grunt-exec": "3.0.0", "load-grunt-tasks": "5.1.0", - "mocha": "8.4.0", "pug-lint-vue": "git+https://git@github.com/okTurtles/pug-lint-vue.git#619952b834296a98e807691019ef6c2f70540df0", "sass": "1.37.5", "should": "13.2.3", @@ -86,6 +87,7 @@ "sinon": "9.2.1", "stylelint": "13.8.0", "stylelint-config-standard": "20.0.0", + "typescript": "4.8.3", "vue-cli-plugin-pug": "2.0.0", "vue-template-compiler": "2.6.12", "vue-template-es2015-compiler": "1.9.1" @@ -2731,6 +2733,70 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "peer": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true, + "peer": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz", + "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==", + "dev": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.3", "dev": true, @@ -2989,10 +3055,393 @@ "@types/node": "*" } }, - "node_modules/@ungap/promise-all-settled": { - "version": "1.1.2", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.37.0.tgz", + "integrity": "sha512-Fde6W0IafXktz1UlnhGkrrmnnGpAo1kyX7dnyHHVrmwJOn72Oqm3eYtddrpOwwel2W8PAK9F3pIL5S+lfoM0og==", "dev": true, - "license": "ISC" + "dependencies": { + "@typescript-eslint/scope-manager": "5.37.0", + "@typescript-eslint/type-utils": "5.37.0", + "@typescript-eslint/utils": "5.37.0", + "debug": "^4.3.4", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.37.0.tgz", + "integrity": "sha512-01VzI/ipYKuaG5PkE5+qyJ6m02fVALmMPY3Qq5BHflDx3y4VobbLdHQkSMg9VPRS4KdNt4oYTMaomFoHonBGAw==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.37.0", + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/typescript-estree": "5.37.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.37.0.tgz", + "integrity": "sha512-F67MqrmSXGd/eZnujjtkPgBQzgespu/iCZ+54Ok9X5tALb9L2v3G+QBSoWkXG0p3lcTJsL+iXz5eLUEdSiJU9Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/visitor-keys": "5.37.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.37.0.tgz", + "integrity": "sha512-BSx/O0Z0SXOF5tY0bNTBcDEKz2Ec20GVYvq/H/XNKiUorUFilH7NPbFUuiiyzWaSdN3PA8JV0OvYx0gH/5aFAQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "5.37.0", + "@typescript-eslint/utils": "5.37.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/types": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.37.0.tgz", + "integrity": "sha512-3frIJiTa5+tCb2iqR/bf7XwU20lnU05r/sgPJnRpwvfZaqCJBrl8Q/mw9vr3NrNdB/XtVyMA0eppRMMBqdJ1bA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.37.0.tgz", + "integrity": "sha512-JkFoFIt/cx59iqEDSgIGnQpCTRv96MQnXCYvJi7QhBC24uyuzbD8wVbajMB1b9x4I0octYFJ3OwjAwNqk1AjDA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/visitor-keys": "5.37.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.37.0.tgz", + "integrity": "sha512-jUEJoQrWbZhmikbcWSMDuUSxEE7ID2W/QCV/uz10WtQqfOuKZUqFGjqLJ+qhDd17rjgp+QJPqTdPIBWwoob2NQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.37.0", + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/typescript-estree": "5.37.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.37.0.tgz", + "integrity": "sha512-Hp7rT4cENBPIzMwrlehLW/28EVCOcE9U1Z1BQTc8EA8v5qpr7GRGuG+U58V5tTY48zvUOA3KHvw3rA8tY9fbdA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.37.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } }, "node_modules/@vue/component-compiler": { "version": "4.2.4", @@ -4114,11 +4563,6 @@ "node": ">=0.10.0" } }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "dev": true, - "license": "ISC" - }, "node_modules/browser-sync": { "version": "2.27.10", "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.27.10.tgz", @@ -4685,17 +5129,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "6.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/camelcase-keys": { "version": "6.2.2", "dev": true, @@ -5371,14 +5804,6 @@ "node": ">=6" } }, - "node_modules/code-point-at": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/collection-visit": { "version": "1.0.0", "dev": true, @@ -7853,25 +8278,26 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.2.4", + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", + "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.2", - "picomatch": "^2.2.1" + "micromatch": "^4.0.4" }, "engines": { - "node": ">=8" + "node": ">=8.6.0" } }, "node_modules/fast-glob/node_modules/braces": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, - "license": "MIT", "dependencies": { "fill-range": "^7.0.1" }, @@ -7881,8 +8307,9 @@ }, "node_modules/fast-glob/node_modules/fill-range": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, - "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -7892,28 +8319,31 @@ }, "node_modules/fast-glob/node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.12.0" } }, "node_modules/fast-glob/node_modules/micromatch": { - "version": "4.0.2", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, - "license": "MIT", "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.0.5" + "braces": "^3.0.2", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=8" + "node": ">=8.6" } }, "node_modules/fast-glob/node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, - "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -8170,14 +8600,6 @@ "node": ">= 0.10" } }, - "node_modules/flat": { - "version": "5.0.2", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { "version": "3.0.4", "dev": true, @@ -8616,15 +9038,16 @@ } }, "node_modules/globby": { - "version": "11.0.1", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, - "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", "slash": "^3.0.0" }, "engines": { @@ -8635,9 +9058,10 @@ } }, "node_modules/globby/node_modules/ignore": { - "version": "5.1.8", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -8666,14 +9090,6 @@ "dev": true, "license": "ISC" }, - "node_modules/growl": { - "version": "1.10.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.x" - } - }, "node_modules/grunt": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.5.3.tgz", @@ -9437,21 +9853,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-glob": { - "version": "4.0.1", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -10788,280 +11194,110 @@ "is-plain-obj": "^1.1.0", "kind-of": "^6.0.3" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minimist-options/node_modules/arrify": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minimist-options/node_modules/is-plain-obj": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minimist-options/node_modules/kind-of": { - "version": "6.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minipass": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz", - "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/mitt": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-1.2.0.tgz", - "integrity": "sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==", - "dev": true - }, - "node_modules/mixin-deep": { - "version": "1.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-deep/node_modules/is-extendable": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha": { - "version": "8.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@ungap/promise-all-settled": "1.1.2", - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.1", - "debug": "4.3.1", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.1.6", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "4.0.0", - "log-symbols": "4.0.0", - "minimatch": "3.0.4", - "ms": "2.1.3", - "nanoid": "3.1.20", - "serialize-javascript": "5.0.1", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "which": "2.0.2", - "wide-align": "1.1.3", - "workerpool": "6.1.0", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha" - }, - "engines": { - "node": ">= 10.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" - } - }, - "node_modules/mocha/node_modules/argparse": { - "version": "2.0.1", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/mocha/node_modules/debug": { - "version": "4.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "engines": { + "node": ">= 6" } }, - "node_modules/mocha/node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/diff": { - "version": "5.0.0", + "node_modules/minimist-options/node_modules/arrify": { + "version": "1.0.1", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.3.1" + "node": ">=0.10.0" } }, - "node_modules/mocha/node_modules/escape-string-regexp": { - "version": "4.0.0", + "node_modules/minimist-options/node_modules/is-plain-obj": { + "version": "1.1.0", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/mocha/node_modules/glob": { - "version": "7.1.6", + "node_modules/minimist-options/node_modules/kind-of": { + "version": "6.0.3", "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, + "license": "MIT", "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=0.10.0" } }, - "node_modules/mocha/node_modules/has-flag": { - "version": "4.0.0", + "node_modules/minipass": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.4.tgz", + "integrity": "sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==", "dev": true, - "license": "MIT", + "dependencies": { + "yallist": "^4.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/mocha/node_modules/js-yaml": { + "node_modules/minipass/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "dev": true, - "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "minipass": "^3.0.0", + "yallist": "^4.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">= 8" } }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, - "node_modules/mocha/node_modules/strip-json-comments": { - "version": "3.1.1", + "node_modules/mitt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-1.2.0.tgz", + "integrity": "sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==", + "dev": true + }, + "node_modules/mixin-deep": { + "version": "1.3.2", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "is-plain-object": "^2.0.4" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=0.10.0" } }, - "node_modules/mocha/node_modules/which": { - "version": "2.0.2", + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, "bin": { - "node-which": "bin/node-which" + "mkdirp": "bin/cmd.js" }, "engines": { - "node": ">= 8" + "node": ">=10" } }, "node_modules/mout": { @@ -11097,17 +11333,6 @@ "node": ">=8" } }, - "node_modules/nanoid": { - "version": "3.1.20", - "dev": true, - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/nanomatch": { "version": "1.2.13", "dev": true, @@ -11257,14 +11482,6 @@ "dev": true, "license": "MIT" }, - "node_modules/number-is-nan": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "dev": true, @@ -11780,9 +11997,10 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.0", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, - "license": "MIT", "engines": { "node": ">=8.6" }, @@ -12784,6 +13002,7 @@ "version": "2.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -13047,9 +13266,10 @@ } }, "node_modules/regexpp": { - "version": "3.1.0", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -13418,14 +13638,6 @@ "semver": "bin/semver" } }, - "node_modules/serialize-javascript": { - "version": "5.0.1", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/serve-index": { "version": "1.9.1", "dev": true, @@ -14107,19 +14319,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-width": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string.prototype.trimend": { "version": "1.0.3", "dev": true, @@ -14637,14 +14836,15 @@ "dev": true }, "node_modules/terser": { - "version": "5.12.1", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz", + "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==", "dev": true, - "license": "BSD-2-Clause", "peer": true, "dependencies": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "bin": { @@ -14718,15 +14918,6 @@ "node": ">=0.4.0" } }, - "node_modules/terser/node_modules/source-map": { - "version": "0.7.3", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/text-table": { "version": "0.2.0", "dev": true, @@ -14929,6 +15120,21 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "dev": true, @@ -14984,9 +15190,9 @@ } }, "node_modules/typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz", + "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -15631,14 +15837,6 @@ "node_modules/wicg-inert": { "version": "3.1.0" }, - "node_modules/wide-align": { - "version": "1.1.3", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2" - } - }, "node_modules/with": { "version": "7.0.2", "dev": true, @@ -15669,11 +15867,6 @@ "node": ">=0.4.0" } }, - "node_modules/workerpool": { - "version": "6.1.0", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -15790,126 +15983,44 @@ } } }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true - }, - "node_modules/yaml": { - "version": "1.10.0", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/yargs": { - "version": "16.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.4", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser/node_modules/decamelize": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", "dev": true, - "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.4.0" } }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, - "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.0", + "node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.0", "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - }, + "license": "ISC", "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.0", + "node_modules/yargs-parser": { + "version": "20.2.4", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.0" - }, + "license": "ISC", "engines": { - "node": ">=8" + "node": ">=10" } }, "node_modules/yauzl": { @@ -18060,6 +18171,61 @@ "version": "1.2.0", "dev": true }, + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "peer": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, + "peer": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "peer": true + }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, + "peer": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true, + "peer": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz", + "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==", + "dev": true, + "peer": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "@nodelib/fs.scandir": { "version": "2.1.3", "dev": true, @@ -18202,70 +18368,309 @@ "@types/estree": "*" } }, - "@types/estree": { - "version": "0.0.51", - "dev": true, - "peer": true - }, - "@types/json-schema": { - "version": "7.0.11", - "dev": true - }, - "@types/json5": { - "version": "0.0.29", - "dev": true - }, - "@types/mdast": { - "version": "3.0.3", + "@types/estree": { + "version": "0.0.51", + "dev": true, + "peer": true + }, + "@types/json-schema": { + "version": "7.0.11", + "dev": true + }, + "@types/json5": { + "version": "0.0.29", + "dev": true + }, + "@types/mdast": { + "version": "3.0.3", + "dev": true, + "requires": { + "@types/unist": "*" + } + }, + "@types/minimatch": { + "version": "3.0.3", + "dev": true + }, + "@types/minimist": { + "version": "1.2.1", + "dev": true + }, + "@types/node": { + "version": "14.17.4", + "dev": true + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "dev": true + }, + "@types/parse-json": { + "version": "4.0.0", + "dev": true + }, + "@types/sinonjs__fake-timers": { + "version": "6.0.4", + "dev": true + }, + "@types/sizzle": { + "version": "2.3.2", + "dev": true + }, + "@types/unist": { + "version": "2.0.3", + "dev": true + }, + "@types/yauzl": { + "version": "2.9.1", + "dev": true, + "optional": true, + "requires": { + "@types/node": "*" + } + }, + "@typescript-eslint/eslint-plugin": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.37.0.tgz", + "integrity": "sha512-Fde6W0IafXktz1UlnhGkrrmnnGpAo1kyX7dnyHHVrmwJOn72Oqm3eYtddrpOwwel2W8PAK9F3pIL5S+lfoM0og==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.37.0", + "@typescript-eslint/type-utils": "5.37.0", + "@typescript-eslint/utils": "5.37.0", + "debug": "^4.3.4", + "functional-red-black-tree": "^1.0.1", + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "@typescript-eslint/parser": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.37.0.tgz", + "integrity": "sha512-01VzI/ipYKuaG5PkE5+qyJ6m02fVALmMPY3Qq5BHflDx3y4VobbLdHQkSMg9VPRS4KdNt4oYTMaomFoHonBGAw==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.37.0", + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/typescript-estree": "5.37.0", + "debug": "^4.3.4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@typescript-eslint/scope-manager": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.37.0.tgz", + "integrity": "sha512-F67MqrmSXGd/eZnujjtkPgBQzgespu/iCZ+54Ok9X5tALb9L2v3G+QBSoWkXG0p3lcTJsL+iXz5eLUEdSiJU9Q==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/visitor-keys": "5.37.0" + } + }, + "@typescript-eslint/type-utils": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.37.0.tgz", + "integrity": "sha512-BSx/O0Z0SXOF5tY0bNTBcDEKz2Ec20GVYvq/H/XNKiUorUFilH7NPbFUuiiyzWaSdN3PA8JV0OvYx0gH/5aFAQ==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "5.37.0", + "@typescript-eslint/utils": "5.37.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@typescript-eslint/types": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.37.0.tgz", + "integrity": "sha512-3frIJiTa5+tCb2iqR/bf7XwU20lnU05r/sgPJnRpwvfZaqCJBrl8Q/mw9vr3NrNdB/XtVyMA0eppRMMBqdJ1bA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.37.0.tgz", + "integrity": "sha512-JkFoFIt/cx59iqEDSgIGnQpCTRv96MQnXCYvJi7QhBC24uyuzbD8wVbajMB1b9x4I0octYFJ3OwjAwNqk1AjDA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/visitor-keys": "5.37.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "@typescript-eslint/utils": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.37.0.tgz", + "integrity": "sha512-jUEJoQrWbZhmikbcWSMDuUSxEE7ID2W/QCV/uz10WtQqfOuKZUqFGjqLJ+qhDd17rjgp+QJPqTdPIBWwoob2NQ==", "dev": true, "requires": { - "@types/unist": "*" + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.37.0", + "@typescript-eslint/types": "5.37.0", + "@typescript-eslint/typescript-estree": "5.37.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "dependencies": { + "eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + } + }, + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + } } }, - "@types/minimatch": { - "version": "3.0.3", - "dev": true - }, - "@types/minimist": { - "version": "1.2.1", - "dev": true - }, - "@types/node": { - "version": "14.17.4", - "dev": true - }, - "@types/normalize-package-data": { - "version": "2.4.0", - "dev": true - }, - "@types/parse-json": { - "version": "4.0.0", - "dev": true - }, - "@types/sinonjs__fake-timers": { - "version": "6.0.4", - "dev": true - }, - "@types/sizzle": { - "version": "2.3.2", - "dev": true - }, - "@types/unist": { - "version": "2.0.3", - "dev": true - }, - "@types/yauzl": { - "version": "2.9.1", + "@typescript-eslint/visitor-keys": { + "version": "5.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.37.0.tgz", + "integrity": "sha512-Hp7rT4cENBPIzMwrlehLW/28EVCOcE9U1Z1BQTc8EA8v5qpr7GRGuG+U58V5tTY48zvUOA3KHvw3rA8tY9fbdA==", "dev": true, - "optional": true, "requires": { - "@types/node": "*" + "@typescript-eslint/types": "5.37.0", + "eslint-visitor-keys": "^3.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + } } }, - "@ungap/promise-all-settled": { - "version": "1.1.2", - "dev": true - }, "@vue/component-compiler": { "version": "4.2.4", "dev": true, @@ -19022,10 +19427,6 @@ } } }, - "browser-stdout": { - "version": "1.3.1", - "dev": true - }, "browser-sync": { "version": "2.27.10", "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.27.10.tgz", @@ -19425,10 +19826,6 @@ "version": "3.1.0", "dev": true }, - "camelcase": { - "version": "6.2.0", - "dev": true - }, "camelcase-keys": { "version": "6.2.2", "dev": true, @@ -19851,10 +20248,6 @@ "is-regexp": "^2.0.0" } }, - "code-point-at": { - "version": "1.1.0", - "dev": true - }, "collection-visit": { "version": "1.0.0", "dev": true, @@ -21420,19 +21813,22 @@ "dev": true }, "fast-glob": { - "version": "3.2.4", + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", + "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.2", - "picomatch": "^2.2.1" + "micromatch": "^4.0.4" }, "dependencies": { "braces": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "requires": { "fill-range": "^7.0.1" @@ -21440,6 +21836,8 @@ }, "fill-range": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -21447,18 +21845,24 @@ }, "is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, "micromatch": { - "version": "4.0.2", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, "requires": { - "braces": "^3.0.1", - "picomatch": "^2.0.5" + "braces": "^3.0.2", + "picomatch": "^2.3.1" } }, "to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "requires": { "is-number": "^7.0.0" @@ -21638,10 +22042,6 @@ "version": "1.0.1", "dev": true }, - "flat": { - "version": "5.0.2", - "dev": true - }, "flat-cache": { "version": "3.0.4", "dev": true, @@ -21912,19 +22312,23 @@ "dev": true }, "globby": { - "version": "11.0.1", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "requires": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", "slash": "^3.0.0" }, "dependencies": { "ignore": { - "version": "5.1.8", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "dev": true } } @@ -21944,10 +22348,6 @@ "version": "4.2.10", "dev": true }, - "growl": { - "version": "1.10.5", - "dev": true - }, "grunt": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.5.3.tgz", @@ -22444,15 +22844,10 @@ "version": "2.1.1", "dev": true }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "dev": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, "is-glob": { - "version": "4.0.1", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "requires": { "is-extglob": "^2.1.1" @@ -23405,109 +23800,6 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, - "mocha": { - "version": "8.4.0", - "dev": true, - "requires": { - "@ungap/promise-all-settled": "1.1.2", - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.1", - "debug": "4.3.1", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.1.6", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "4.0.0", - "log-symbols": "4.0.0", - "minimatch": "3.0.4", - "ms": "2.1.3", - "nanoid": "3.1.20", - "serialize-javascript": "5.0.1", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "which": "2.0.2", - "wide-align": "1.1.3", - "workerpool": "6.1.0", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "dev": true - }, - "debug": { - "version": "4.3.1", - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "dev": true - } - } - }, - "diff": { - "version": "5.0.0", - "dev": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "dev": true - }, - "glob": { - "version": "7.1.6", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "dev": true - }, - "js-yaml": { - "version": "4.0.0", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "ms": { - "version": "2.1.3", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.1", - "dev": true - }, - "supports-color": { - "version": "8.1.1", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "which": { - "version": "2.0.2", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, "mout": { "version": "1.2.3", "dev": true @@ -23534,10 +23826,6 @@ "minimatch": "^3.0.4" } }, - "nanoid": { - "version": "3.1.20", - "dev": true - }, "nanomatch": { "version": "1.2.13", "dev": true, @@ -23645,10 +23933,6 @@ "version": "1.2.2", "dev": true }, - "number-is-nan": { - "version": "1.0.1", - "dev": true - }, "object-assign": { "version": "4.1.1", "dev": true @@ -23975,7 +24259,9 @@ "dev": true }, "picomatch": { - "version": "2.3.0", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, "pify": { @@ -24711,6 +24997,7 @@ "randombytes": { "version": "2.1.0", "dev": true, + "peer": true, "requires": { "safe-buffer": "^5.1.0" } @@ -24884,7 +25171,9 @@ } }, "regexpp": { - "version": "3.1.0", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true }, "regjsgen": { @@ -25116,13 +25405,6 @@ "version": "5.3.0", "dev": true }, - "serialize-javascript": { - "version": "5.0.1", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, "serve-index": { "version": "1.9.1", "dev": true, @@ -25612,15 +25894,6 @@ "version": "3.0.1", "dev": true }, - "string-width": { - "version": "1.0.2", - "dev": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, "string.prototype.trimend": { "version": "1.0.3", "dev": true, @@ -25973,13 +26246,15 @@ } }, "terser": { - "version": "5.12.1", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz", + "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==", "dev": true, "peer": true, "requires": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "dependencies": { @@ -25987,11 +26262,6 @@ "version": "8.7.0", "dev": true, "peer": true - }, - "source-map": { - "version": "0.7.3", - "dev": true, - "peer": true } } }, @@ -26159,6 +26429,15 @@ "version": "1.11.1", "dev": true }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, "tunnel-agent": { "version": "0.6.0", "dev": true, @@ -26195,9 +26474,9 @@ } }, "typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz", + "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==", "dev": true }, "ua-parser-js": { @@ -26597,13 +26876,6 @@ "wicg-inert": { "version": "3.1.0" }, - "wide-align": { - "version": "1.1.3", - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2" - } - }, "with": { "version": "7.0.2", "dev": true, @@ -26622,10 +26894,6 @@ "version": "0.0.3", "dev": true }, - "workerpool": { - "version": "6.1.0", - "dev": true - }, "wrap-ansi": { "version": "7.0.0", "dev": true, @@ -26715,65 +26983,10 @@ "version": "1.10.0", "dev": true }, - "yargs": { - "version": "16.2.0", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.1", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, "yargs-parser": { "version": "20.2.4", "dev": true }, - "yargs-unparser": { - "version": "2.0.0", - "dev": true, - "requires": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "dependencies": { - "decamelize": { - "version": "4.0.0", - "dev": true - } - } - }, "yauzl": { "version": "2.10.0", "dev": true, diff --git a/package.json b/package.json index abb4c0a439..ffd1f0af80 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "description": "", "scripts": { - "backend": "npx nodemon --watch backend/ -- --require ./Gruntfile.js --require @babel/register ./backend/index.js", + "backend": "deno run --allow-env --allow-net --allow-read --allow-write --import-map=import_map.json --watch backend/index.ts", "sass-dev": "node-sass --output-style nested --source-map true -w -r -o ./dist/assets/css ./frontend/assets/style", "sass-dist": "node-sass --output-style compressed -o ./dist/assets/css ./frontend/assets/style", "cy:open": "cypress open", @@ -18,46 +18,6 @@ "stylelint": "stylelint 'frontend/**/*.{css,scss,vue}' --fix", "docker": "./scripts/docker.sh" }, - "eslintConfig": { - "root": true, - "parserOptions": { - "parser": "@babel/eslint-parser" - }, - "extends": [ - "plugin:cypress/recommended", - "plugin:flowtype/recommended", - "plugin:vue/essential", - "standard" - ], - "plugins": [ - "cypress", - "flowtype", - "import" - ], - "rules": { - "require-await": "error", - "vue/max-attributes-per-line": "off", - "vue/html-indent": "off", - "flowtype/no-types-missing-file-annotation": "off", - "quote-props": "off", - "dot-notation": "off", - "import/extensions": [ - 2, - "ignorePackages" - ] - } - }, - "eslintIgnore": [ - "frontend/assets/*", - "frontend/model/contracts/misc/flowTyper.js", - "historical/*", - "shared/types.js", - "dist/*", - "ignored/*", - "node_modules/*", - "test/cypress/cache/*", - "contracts/*" - ], "browserslist": "> 1% and since 2018 and not dead", "stylelint": { "extends": "stylelint-config-standard", @@ -135,6 +95,8 @@ "@babel/register": "7.12.1", "@babel/runtime": "7.12.5", "@chelonia/cli": "1.1.3", + "@typescript-eslint/eslint-plugin": "5.37.0", + "@typescript-eslint/parser": "5.37.0", "@vue/component-compiler": "4.2.4", "acorn": "8.0.4", "babel-plugin-module-resolver": "4.1.0", @@ -162,7 +124,6 @@ "grunt-contrib-copy": "1.0.0", "grunt-exec": "3.0.0", "load-grunt-tasks": "5.1.0", - "mocha": "8.4.0", "pug-lint-vue": "git+https://git@github.com/okTurtles/pug-lint-vue.git#619952b834296a98e807691019ef6c2f70540df0", "sass": "1.37.5", "should": "13.2.3", @@ -170,6 +131,7 @@ "sinon": "9.2.1", "stylelint": "13.8.0", "stylelint-config-standard": "20.0.0", + "typescript": "4.8.3", "vue-cli-plugin-pug": "2.0.0", "vue-template-compiler": "2.6.12", "vue-template-es2015-compiler": "1.9.1" diff --git a/scripts/applyPortShift.ts b/scripts/applyPortShift.ts new file mode 100644 index 0000000000..658761b09b --- /dev/null +++ b/scripts/applyPortShift.ts @@ -0,0 +1,27 @@ +/** + * Creates a modified copy of the given `process.env` object, according to its `PORT_SHIFT` variable. + * + * The `API_HOSTNAME`, `API_PORT` and `API_URL` variables will be updated. + * TODO: make the protocol (http vs https) variable based on environment var. + * TODO: implement automatic port selection when `PORT_SHIFT` is 'auto'. + * @param {Object} env + * @returns {Object} + */ +export default function applyPortShift (env: ReturnType) { + // In development we're using BrowserSync, whose proxy tries to connect only via ipv4, + // hence our server must be accessible via ipv4. + // By default, on Linux 'localhost' refers to both the ipv4 and ipv6 endpoints, + // while on MacOS and Windows it only resolves to the ipv6 one, so we have to specify + // '127.0.0.1' to listen via ipv4. + // However modern browsers tend to try ipv6 first and only fallback to ipv4 if it fails, + // so in production and/or on other OSes it's better to listen via both, or only via ipv6 + // if we don't want two listeners. + const API_HOSTNAME = env.NODE_ENV === 'production' || Deno.build.os === 'linux' ? 'localhost' : '127.0.0.1' + const API_PORT = 8000 + Number.parseInt(env.PORT_SHIFT || '0') + const API_URL = `http://${API_HOSTNAME}:${API_PORT}` + + if (Number.isNaN(API_PORT) || API_PORT < 8000 || API_PORT > 65535) { + throw new RangeError(`Invalid API_PORT value: ${API_PORT}.`) + } + return { ...env, API_HOSTNAME, API_PORT: String(API_PORT), API_URL } +} diff --git a/scripts/mocha-helper.js b/scripts/mocha-helper.js deleted file mode 100644 index beaed19551..0000000000 --- a/scripts/mocha-helper.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict' - -// https://babeljs.io/docs/en/babel-register/ -// https://github.com/tleunen/babel-plugin-module-resolver -// -// We register babel-plugin-module-resolver only here so that we don't -// step on the toes of esbuild when resolving @common via our custom esbuild alias plugin -require('@babel/register')({ - plugins: [ - ['module-resolver', { - 'alias': { - '@common': './frontend/common' - } - }] - ] -}) diff --git a/scripts/process-shim.ts b/scripts/process-shim.ts new file mode 100644 index 0000000000..342dd5166f --- /dev/null +++ b/scripts/process-shim.ts @@ -0,0 +1,14 @@ +type EnvRecord = Record + +// @ts-expect-error Element implicitly has an 'any' type. +globalThis.process = { + env: new Proxy({} as EnvRecord, { + get (obj: EnvRecord, key: string): string | void { + return Deno.env.get(key) + }, + set (obj: EnvRecord, key: string, value: string): boolean { + Deno.env.set(key, value) + return true + } + }) +} diff --git a/shared/declarations.js b/shared/declarations.js index cb1963923a..ef3f31adb4 100644 --- a/shared/declarations.js +++ b/shared/declarations.js @@ -28,10 +28,6 @@ declare var Compartment: Function // TODO: Proper fix is to use: // https://github.com/okTurtles/group-income/issues/157 // ======================= -declare module '@hapi/boom' { declare module.exports: any } -declare module '@hapi/hapi' { declare module.exports: any } -declare module '@hapi/inert' { declare module.exports: any } -declare module '@hapi/joi' { declare module.exports: any } declare module 'blakejs' { declare module.exports: any } declare module 'buffer' { declare module.exports: any } declare module 'chalk' { declare module.exports: any } @@ -67,21 +63,18 @@ declare module 'lru-cache' { declare module.exports: any } // Only necessary because `AppStyles.vue` imports it from its script tag rather than its style tag. declare module '@assets/style/main.scss' { declare module.exports: any } // Other .js files. +declare module '@common/common.js' { declare module.exports: any } +declare module '@model/contracts/shared/payments/index.js' { declare module.exports: any } declare module '@utils/blockies.js' { declare module.exports: Object } declare module '~/frontend/model/contracts/misc/flowTyper.js' { declare module.exports: Object } declare module '~/frontend/model/contracts/shared/time.js' { declare module.exports: Object } -declare module '@model/contracts/shared/time.js' { declare module.exports: Object } -// HACK: declared three files below but not sure why it's necessary -declare module '~/shared/domains/chelonia/events.js' { declare module.exports: Object } -declare module '~/shared/domains/chelonia/errors.js' { declare module.exports: Object } -declare module '~/shared/domains/chelonia/internals.js' { declare module.exports: Object } -declare module '~/frontend/model/contracts/shared/giLodash.js' { declare module.exports: any } -declare module '@model/contracts/shared/giLodash.js' { declare module.exports: any } -declare module '@model/contracts/shared/constants.js' { declare module.exports: any } -declare module '@model/contracts/shared/distribution/distribution.js' { declare module.exports: any } -declare module '@model/contracts/shared/voting/rules.js' { declare module.exports: any } -declare module '@model/contracts/shared/voting/proposals.js' { declare module.exports: any } -declare module '@model/contracts/shared/functions.js' { declare module.exports: any } -declare module '@common/common.js' { declare module.exports: any } +// HACK: declared some shared files below but not sure why it's necessary +declare module '~/shared/domains/chelonia/chelonia.ts' { declare module.exports: any } +declare module '~/shared/domains/chelonia/errors.ts' { declare module.exports: Object } +declare module '~/shared/domains/chelonia/events.ts' { declare module.exports: Object } +declare module '~/shared/domains/chelonia/internals.ts' { declare module.exports: Object } +declare module '~/shared/functions.ts' { declare module.exports: any } +declare module '~/shared/pubsub.ts' { declare module.exports: any } +// JSON files. declare module './model/contracts/manifests.json' { declare module.exports: any } -declare module '@model/contracts/shared/payments/index.js' { declare module.exports: any } + diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.ts similarity index 62% rename from shared/domains/chelonia/GIMessage.js rename to shared/domains/chelonia/GIMessage.ts index 4af2f1134a..940c67c29f 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.ts @@ -1,23 +1,47 @@ -'use strict' - // TODO: rename GIMessage to CMessage or something similar -import { blake32Hash } from '~/shared/functions.js' -import type { JSONType, JSONObject } from '~/shared/types.js' +import { blake32Hash } from '~/shared/functions.ts' +import type { JSONObject } from '~/shared/types.ts' + +type JSONType = ReturnType + +type Mapping = { + key: string + value: string +} + +type Message = { + contractID: string | null + manifest: string + // The nonce makes it difficult to predict message contents + // and makes it easier to prevent conflicts during development. + nonce: number + op: GIOp + previousHEAD: string | null + version: string // Semver version string +} + +type Signature = { + sig: string + type: string +} + +type DecryptFunction = (v: GIOpActionEncrypted) => GIOpActionUnencrypted +type SignatureFunction = (data: string) => Signature export type GIKeyType = '' export type GIKey = { - type: GIKeyType; - data: Object; // based on GIKeyType this will change - meta: Object; + type: GIKeyType + data: JSONType // based on GIKeyType this will change + meta: JSONObject } // Allows server to check if the user is allowed to register this type of contract // TODO: rename 'type' to 'contractName': export type GIOpContract = { type: string; keyJSON: string, parentContract?: string } export type GIOpActionEncrypted = string // encrypted version of GIOpActionUnencrypted export type GIOpActionUnencrypted = { action: string; data: JSONType; meta: JSONObject } -export type GIOpKeyAdd = { keyHash: string, keyJSON: ?string, context: string } +export type GIOpKeyAdd = { keyHash: string, keyJSON: string | null | void, context: string } export type GIOpPropSet = { key: string, value: JSONType } export type GIOpType = 'c' | 'ae' | 'au' | 'ka' | 'kd' | 'pu' | 'ps' | 'pd' @@ -25,19 +49,18 @@ export type GIOpValue = GIOpContract | GIOpActionEncrypted | GIOpActionUnencrypt export type GIOp = [GIOpType, GIOpValue] export class GIMessage { - // flow type annotations to make flow happy - _decrypted: GIOpValue - _mapping: Object - _message: Object - - static OP_CONTRACT: 'c' = 'c' - static OP_ACTION_ENCRYPTED: 'ae' = 'ae' // e2e-encrypted action - static OP_ACTION_UNENCRYPTED: 'au' = 'au' // publicly readable action - static OP_KEY_ADD: 'ka' = 'ka' // add this key to the list of keys allowed to write to this contract, or update an existing key - static OP_KEY_DEL: 'kd' = 'kd' // remove this key from authorized keys - static OP_PROTOCOL_UPGRADE: 'pu' = 'pu' - static OP_PROP_SET: 'ps' = 'ps' // set a public key/value pair - static OP_PROP_DEL: 'pd' = 'pd' // delete a public key/value pair + _decrypted?: GIOpValue + _mapping: Mapping + _message: Message + + static OP_CONTRACT = 'c' as const + static OP_ACTION_ENCRYPTED = 'ae' as const // e2e-encrypted action + static OP_ACTION_UNENCRYPTED = 'au' as const // publicly readable action + static OP_KEY_ADD = 'ka' as const // add this key to the list of keys allowed to write to this contract, or update an existing key + static OP_KEY_DEL = 'kd' as const // remove this key from authorized keys + static OP_PROTOCOL_UPGRADE = 'pu' as const + static OP_PROP_SET = 'ps' as const // set a public key/value pair + static OP_PROP_DEL = 'pd' as const // delete a public key/value pair // eslint-disable-next-line camelcase static createV1_0 ( @@ -45,9 +68,9 @@ export class GIMessage { previousHEAD: string | null = null, op: GIOp, manifest: string, - signatureFn?: Function = defaultSignatureFn - ): this { - const message = { + signatureFn: SignatureFunction = defaultSignatureFn + ): GIMessage { + const message: Message = { version: '1.0.0', previousHEAD, contractID, @@ -73,7 +96,7 @@ export class GIMessage { } // TODO: we need signature verification upon decryption somewhere... - static deserialize (value: string): this { + static deserialize (value: string): GIMessage { if (!value) throw new Error(`deserialize bad value: ${value}`) return new this({ mapping: { key: blake32Hash(value), value }, @@ -81,7 +104,7 @@ export class GIMessage { }) } - constructor ({ mapping, message }: { mapping: Object, message: Object }) { + constructor ({ mapping, message }: { mapping: Mapping, message: Message }) { this._mapping = mapping this._message = message // perform basic sanity check @@ -98,18 +121,18 @@ export class GIMessage { } } - decryptedValue (fn?: Function): any { + decryptedValue (fn?: DecryptFunction): GIOpValue { if (!this._decrypted) { this._decrypted = ( this.opType() === GIMessage.OP_ACTION_ENCRYPTED && fn !== undefined - ? fn(this.opValue()) + ? fn(this.opValue() as string) : this.opValue() ) } return this._decrypted } - message (): Object { return this._message } + message (): Message { return this._message } op (): GIOp { return this.message().op } @@ -124,13 +147,13 @@ export class GIMessage { let desc = `` @@ -145,7 +168,7 @@ export class GIMessage { hash (): string { return this._mapping.key } } -function defaultSignatureFn (data: string) { +function defaultSignatureFn (data: string): Signature { return { type: 'default', sig: blake32Hash(data) diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.ts similarity index 71% rename from shared/domains/chelonia/chelonia.js rename to shared/domains/chelonia/chelonia.ts index d6896e0401..b4a889ed9f 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.ts @@ -1,49 +1,188 @@ -'use strict' - +/* eslint-disable camelcase */ import sbp from '@sbp/sbp' import '@sbp/okturtles.events' import '@sbp/okturtles.eventqueue' -import './internals.js' -import { CONTRACTS_MODIFIED, CONTRACT_REGISTERED } from './events.js' -import { createClient, NOTIFICATION_TYPE } from '~/shared/pubsub.js' +import './internals.ts' +import { CONTRACTS_MODIFIED, CONTRACT_REGISTERED } from './events.ts' +import { createClient, NOTIFICATION_TYPE, PubsubClient } from '~/shared/pubsub.ts' import { merge, cloneDeep, randomHexString, intersection, difference } from '~/frontend/model/contracts/shared/giLodash.js' -import { b64ToStr } from '~/shared/functions.js' +import { b64ToStr } from '~/shared/functions.ts' import { handleFetchResult } from '~/frontend/controller/utils/misc.js' // TODO: rename this to ChelMessage -import { GIMessage } from './GIMessage.js' -import { ChelErrorUnrecoverable } from './errors.js' -import type { GIOpContract, GIOpActionUnencrypted } from './GIMessage.js' +import { GIMessage } from './GIMessage.ts' +import { ChelErrorUnrecoverable } from './errors.ts' +import type { GIOpActionUnencrypted } from './GIMessage.ts' -// TODO: define ChelContractType for /defineContract +declare const process: { + env: Record +} +type JSONType = ReturnType -export type ChelRegParams = { - contractName: string; +export type ChelActionParams = { + action: string; server?: string; // TODO: implement! - data: Object; + contractID: string; + data: JSONType; hooks?: { - prepublishContract?: (GIMessage) => void; - prepublish?: (GIMessage) => void; - postpublish?: (GIMessage) => void; + prepublishContract?: (msg: GIMessage) => void; + prepublish?: (msg: GIMessage) => void; + postpublish?: (msg: GIMessage) => void; }; publishOptions?: { maxAttempts: number }; } -export type ChelActionParams = { - action: string; +export type ChelRegParams = { + contractName: string; server?: string; // TODO: implement! - contractID: string; - data: Object; + data: JSONType; hooks?: { - prepublishContract?: (GIMessage) => void; - prepublish?: (GIMessage) => void; - postpublish?: (GIMessage) => void; + prepublishContract?: (msg: GIMessage) => void; + prepublish?: (msg: GIMessage) => void; + postpublish?: (msg: GIMessage) => void; }; publishOptions?: { maxAttempts: number }; } +export type CheloniaConfig = { + connectionOptions: { + maxRetries: number + reconnectOnTimeout: boolean + timeout: number + } + connectionURL: null | string + contracts: { + defaults: { + modules: Record + exposedGlobals: Record + allowedDomains: string[] + allowedSelectors: string[] + preferSlim: boolean + } + manifests: Record // Contract names -> manifest hashes + overrides: Record // Override default values per-contract. + } + decryptFn: (arg: string) => JSONType + encryptFn: (arg: JSONType) => string + hooks: { + preHandleEvent: null | ((message: GIMessage) => Promise) + postHandleEvent: null | ((message: GIMessage) => Promise) + processError: null | ((e: Error, message: GIMessage) => void) + sideEffectError: null | ((e: Error, message: GIMessage) => void) + handleEventError: null | ((e: Error, message: GIMessage) => void) + syncContractError: null | ((e: Error, contractID: string) => void) + pubsubError: null | ((e: Error, socket: WebSocket) => void) + } + postOp?: (state: unknown, message: unknown) => boolean + postOp_ae?: PostOp + postOp_au?: PostOp + postOp_c?: PostOp + postOp_ka?: PostOp + postOp_kd?: PostOp + postOp_pd?: PostOp + postOp_ps?: PostOp + postOp_pu?: PostOp + preOp?: PreOp + preOp_ae?: PreOp + preOp_au?: PreOp + preOp_c?: PreOp + preOp_ka?: PreOp + preOp_kd?: PreOp + preOp_pd?: PreOp + preOp_ps?: PreOp + preOp_pu?: PreOp + reactiveDel: (obj: Record, key: string) => void + reactiveSet: (obj: Record, key: string, value: unknown) => typeof value + skipActionProcessing: boolean + skipSideEffects: boolean + skipProcessing?: boolean + stateSelector: string // Override to integrate with, for example, Vuex. + whitelisted: (action: string) => boolean +} + +export type CheloniaInstance = { + config: CheloniaConfig; + contractsModifiedListener?: () => void; + contractSBP?: unknown; + currentSyncs: Record; + defContract: ContractDefinition; + defContractManifest?: string; + defContractSBP?: SBP; + defContractSelectors?: string[]; + manifestToContract: Record + sideEffectStack: (contractID: string) => SBPCallParameters[]; + sideEffectStacks: Record; + state: CheloniaState; + whitelistedActions: Record; +} + +export interface CheloniaState { + [contractID: string]: unknown + contracts: Record // contractIDs => { type:string, HEAD:string } (for contracts we've successfully subscribed to) + pending: string[] // Prevents processing unexpected data from a malicious server. +} + +type Action = { + validate: (data: JSONType, { state, getters, meta, contractID }: { + state: CheloniaState + getters: Getters + meta: JSONType + contractID: string + }) => boolean | void + process: (message: Mutation, { state, getters }: { + state: CheloniaState + getters: Getters + }) => void + sideEffect?: (message: Mutation, { state, getters }: { + state: CheloniaState + getters: Getters + }) => void +} + +export type Mutation = { + data: JSONType + meta: JSONType + hash: string + contractID: string +} + +type PostOp = (state: unknown, message: unknown) => boolean +type PreOp = (state: unknown, message: unknown) => boolean + +type SBP = (selector: string, ...args: unknown[]) => unknown + +type SBPCallParameters = [string, ...unknown[]] + +export type ContractDefinition = { + actions: Record + getters: Getters + manifest: string + metadata: { + create(): JSONType + validate(meta: JSONType, args: { + state: CheloniaState + getters: Getters + contractID: string + }): void + validate(): void + } + methods: Record unknown> + name: string + sbp: SBP + state (contractID: string): CheloniaState // Contract instance state +} + +export type ContractInfo = { + file: string + hash: string +} + export { GIMessage } -export const ACTION_REGEX: RegExp = /^((([\w.]+)\/([^/]+))(?:\/(?:([^/]+)\/)?)?)\w*/ +export const ACTION_REGEX = /^((([\w.]+)\/([^/]+))(?:\/(?:([^/]+)\/)?)?)\w*/ // ACTION_REGEX.exec('gi.contracts/group/payment/process') // 0 => 'gi.contracts/group/payment/process' // 1 => 'gi.contracts/group/payment/' @@ -52,7 +191,7 @@ export const ACTION_REGEX: RegExp = /^((([\w.]+)\/([^/]+))(?:\/(?:([^/]+)\/)?)?) // 4 => 'group' // 5 => 'payment' -export default (sbp('sbp/selectors/register', { +export default sbp('sbp/selectors/register', { // https://www.wordnik.com/words/chelonia // https://gitlab.okturtles.org/okturtles/group-income/-/wikis/E2E-Protocol/Framework.md#alt-names 'chelonia/_init': function () { @@ -74,8 +213,8 @@ export default (sbp('sbp/selectors/register', { manifests: {} // override! contract names => manifest hashes }, whitelisted: (action: string): boolean => !!this.whitelistedActions[action], - reactiveSet: (obj, key, value) => { obj[key] = value; return value }, // example: set to Vue.set - reactiveDel: (obj, key) => { delete obj[key] }, + reactiveSet: (obj: CheloniaState, key: string, value: unknown): typeof value => { obj[key] = value; return value }, // example: set to Vue.set + reactiveDel: (obj: CheloniaState, key: string): void => { delete obj[key] }, skipActionProcessing: false, skipSideEffects: false, connectionOptions: { @@ -93,15 +232,15 @@ export default (sbp('sbp/selectors/register', { pubsubError: null // (e:Error, socket: Socket) } } + this.currentSyncs = {} this.state = { contracts: {}, // contractIDs => { type, HEAD } (contracts we've subscribed to) pending: [] // prevents processing unexpected data from a malicious server } this.manifestToContract = {} this.whitelistedActions = {} - this.currentSyncs = {} - this.sideEffectStacks = {} // [contractID]: Array<*> - this.sideEffectStack = (contractID: string): Array<*> => { + this.sideEffectStacks = {} // [contractID]: Array + this.sideEffectStack = (contractID: string): Array => { let stack = this.sideEffectStacks[contractID] if (!stack) { this.sideEffectStacks[contractID] = stack = [] @@ -109,10 +248,10 @@ export default (sbp('sbp/selectors/register', { return stack } }, - 'chelonia/config': function () { + 'chelonia/config': function (): CheloniaConfig { return cloneDeep(this.config) }, - 'chelonia/configure': async function (config: Object) { + 'chelonia/configure': async function (config: CheloniaConfig) { merge(this.config, config) // merge will strip the hooks off of config.hooks when merging from the root of the object // because they are functions and cloneDeep doesn't clone functions @@ -126,7 +265,7 @@ export default (sbp('sbp/selectors/register', { } }, // TODO: allow connecting to multiple servers at once - 'chelonia/connect': function (): Object { + 'chelonia/connect': function (): PubsubClient { if (!this.config.connectionURL) throw new Error('config.connectionURL missing') if (!this.config.connectionOptions) throw new Error('config.connectionOptions missing') if (this.pubsub) { @@ -141,14 +280,14 @@ export default (sbp('sbp/selectors/register', { this.pubsub = createClient(pubsubURL, { ...this.config.connectionOptions, messageHandlers: { - [NOTIFICATION_TYPE.ENTRY] (msg) { + [NOTIFICATION_TYPE.ENTRY] (msg: { data: JSONType }) { // We MUST use 'chelonia/private/in/enqueueHandleEvent' to ensure handleEvent() // is called AFTER any currently-running calls to 'chelonia/contract/sync' // to prevent gi.db from throwing "bad previousHEAD" errors. // Calling via SBP also makes it simple to implement 'test/backend.js' sbp('chelonia/private/in/enqueueHandleEvent', GIMessage.deserialize(msg.data)) }, - [NOTIFICATION_TYPE.APP_VERSION] (msg) { + [NOTIFICATION_TYPE.APP_VERSION] (msg: { data: JSONType }) { const ourVersion = process.env.GI_VERSION const theirVersion = msg.data @@ -165,11 +304,11 @@ export default (sbp('sbp/selectors/register', { } return this.pubsub }, - 'chelonia/defineContract': function (contract: Object) { + 'chelonia/defineContract': function (contract: ContractDefinition) { if (!ACTION_REGEX.exec(contract.name)) throw new Error(`bad contract name: ${contract.name}`) if (!contract.metadata) contract.metadata = { validate () {}, create: () => ({}) } if (!contract.getters) contract.getters = {} - contract.state = (contractID) => sbp(this.config.stateSelector)[contractID] + contract.state = (contractID: string) => sbp(this.config.stateSelector)[contractID] contract.manifest = this.defContractManifest contract.sbp = this.defContractSBP this.defContractSelectors = [] @@ -179,7 +318,7 @@ export default (sbp('sbp/selectors/register', { [`${contract.manifest}/${contract.name}/getters`]: () => contract.getters, // 2 ways to cause sideEffects to happen: by defining a sideEffect function in the // contract, or by calling /pushSideEffect w/async SBP call. Can also do both. - [`${contract.manifest}/${contract.name}/pushSideEffect`]: (contractID: string, asyncSbpCall: Array<*>) => { + [`${contract.manifest}/${contract.name}/pushSideEffect`]: (contractID: string, asyncSbpCall: SBPCallParameters) => { // if this version of the contract is pushing a sideEffect to a function defined by the // contract itself, make sure that it calls the same version of the sideEffect const [sel] = asyncSbpCall @@ -199,7 +338,7 @@ export default (sbp('sbp/selectors/register', { // - whatever keys should be passed in as well // base it off of the design of encryptedAction() this.defContractSelectors.push(...sbp('sbp/selectors/register', { - [`${contract.manifest}/${action}/process`]: (message: Object, state: Object) => { + [`${contract.manifest}/${action}/process`]: (message: Mutation, state: CheloniaState) => { const { meta, data, contractID } = message // TODO: optimize so that you're creating a proxy object only when needed const gProxy = gettersProxy(state, contract.getters) @@ -208,23 +347,25 @@ export default (sbp('sbp/selectors/register', { contract.actions[action].validate(data, { state, ...gProxy, meta, contractID }) contract.actions[action].process(message, { state, ...gProxy }) }, - // 'mutation' is an object that's similar to 'message', but not identical - [`${contract.manifest}/${action}/sideEffect`]: async (mutation: Object, state: ?Object) => { - const sideEffects = this.sideEffectStack(mutation.contractID) + [`${contract.manifest}/${action}/sideEffect`]: async (message: Mutation, state: CheloniaState | void) => { + const sideEffects = this.sideEffectStack(message.contractID) while (sideEffects.length > 0) { const sideEffect = sideEffects.shift() try { - await contract.sbp(...sideEffect) + const [selector, ...args] = sideEffect + await contract.sbp(selector, ...args) } catch (e) { - console.error(`[chelonia] ERROR: '${e.name}' ${e.message}, for pushed sideEffect of ${mutation.description}:`, sideEffect) - this.sideEffectStacks[mutation.contractID] = [] // clear the side effects + // @ts-expect-error: TS2339 Property 'description' does not exist on type 'Mutation'. + console.error(`[chelonia] ERROR: '${e.name}' ${e.message}, for pushed sideEffect of ${message.description()}:`, sideEffect) + this.sideEffectStacks[message.contractID] = [] // clear the side effects throw e } } - if (contract.actions[action].sideEffect) { - state = state || contract.state(mutation.contractID) + const { sideEffect } = contract.actions[action] + if (sideEffect) { + state = state || contract.state(message.contractID) const gProxy = gettersProxy(state, contract.getters) - await contract.actions[action].sideEffect(mutation, { state, ...gProxy }) + await sideEffect(message, { state, ...gProxy }) } } })) @@ -262,7 +403,7 @@ export default (sbp('sbp/selectors/register', { } }, // resolves when all pending actions for these contractID(s) finish - 'chelonia/contract/wait': function (contractIDs?: string | string[]): Promise<*> { + 'chelonia/contract/wait': function (contractIDs?: string | string[]): Promise { const listOfIds = contractIDs ? (typeof contractIDs === 'string' ? [contractIDs] : contractIDs) : Object.keys(sbp(this.config.stateSelector).contracts) @@ -272,7 +413,7 @@ export default (sbp('sbp/selectors/register', { }, // 'chelonia/contract' - selectors related to injecting remote data and monitoring contracts // TODO: add an optional parameter to "retain" the contract (see #828) - 'chelonia/contract/sync': function (contractIDs: string | string[]): Promise<*> { + 'chelonia/contract/sync': function (contractIDs: string | string[]): Promise { const listOfIds = typeof contractIDs === 'string' ? [contractIDs] : contractIDs return Promise.all(listOfIds.map(contractID => { // enqueue this invocation in a serial queue to ensure @@ -282,7 +423,7 @@ export default (sbp('sbp/selectors/register', { // This prevents handleEvent getting called with the wrong previousHEAD for an event. return sbp('chelonia/queueInvocation', contractID, [ 'chelonia/private/in/syncContract', contractID - ]).catch((err) => { + ]).catch((err: unknown) => { console.error(`[chelonia] failed to sync ${contractID}:`, err) throw err // re-throw the error }) @@ -293,7 +434,7 @@ export default (sbp('sbp/selectors/register', { }, // TODO: implement 'chelonia/contract/release' (see #828) // safer version of removeImmediately that waits to finish processing events for contractIDs - 'chelonia/contract/remove': function (contractIDs: string | string[]): Promise<*> { + 'chelonia/contract/remove': function (contractIDs: string | string[]): Promise { const listOfIds = typeof contractIDs === 'string' ? [contractIDs] : contractIDs return Promise.all(listOfIds.map(contractID => { return sbp('chelonia/queueInvocation', contractID, [ @@ -312,43 +453,41 @@ export default (sbp('sbp/selectors/register', { // TODO: r.body is a stream.Transform, should we use a callback to process // the events one-by-one instead of converting to giant json object? // however, note if we do that they would be processed in reverse... - 'chelonia/out/eventsSince': async function (contractID: string, since: string) { + 'chelonia/out/eventsSince': async function (contractID: string, since: string): Promise { const events = await fetch(`${this.config.connectionURL}/eventsSince/${contractID}/${since}`) .then(handleFetchResult('json')) if (Array.isArray(events)) { return events.reverse().map(b64ToStr) } }, - 'chelonia/out/latestHash': function (contractID: string) { + 'chelonia/out/latestHash': function (contractID: string): Promise { return fetch(`${this.config.connectionURL}/latestHash/${contractID}`, { cache: 'no-store' }).then(handleFetchResult('text')) }, - 'chelonia/out/eventsBefore': async function (before: string, limit: number) { + 'chelonia/out/eventsBefore': async function (before: string, limit: number): Promise { if (limit <= 0) { console.error('[chelonia] invalid params error: "limit" needs to be positive integer') return } - const events = await fetch(`${this.config.connectionURL}/eventsBefore/${before}/${limit}`) .then(handleFetchResult('json')) if (Array.isArray(events)) { return events.reverse().map(b64ToStr) } }, - 'chelonia/out/eventsBetween': async function (startHash: string, endHash: string, offset: number = 0) { + 'chelonia/out/eventsBetween': async function (startHash: string, endHash: string, offset = 0): Promise { if (offset < 0) { console.error('[chelonia] invalid params error: "offset" needs to be positive integer or zero') return } - const events = await fetch(`${this.config.connectionURL}/eventsBetween/${startHash}/${endHash}?offset=${offset}`) .then(handleFetchResult('json')) if (Array.isArray(events)) { return events.reverse().map(b64ToStr) } }, - 'chelonia/latestContractState': async function (contractID: string) { + 'chelonia/latestContractState': async function (contractID: string): Promise { const events = await sbp('chelonia/out/eventsSince', contractID, contractID) let state = {} // fast-path @@ -375,7 +514,7 @@ export default (sbp('sbp/selectors/register', { return state }, // 'chelonia/out' - selectors that send data out to the server - 'chelonia/out/registerContract': async function (params: ChelRegParams) { + 'chelonia/out/registerContract': async function (params: ChelRegParams): Promise { const { contractName, hooks, publishOptions } = params const manifestHash = this.config.contracts.manifests[contractName] const contractInfo = this.manifestToContract[manifestHash] @@ -383,10 +522,10 @@ export default (sbp('sbp/selectors/register', { const contractMsg = GIMessage.createV1_0(null, null, [ GIMessage.OP_CONTRACT, - ({ + { type: contractName, keyJSON: 'TODO: add group public key here' - }: GIOpContract) + } ], manifestHash ) @@ -424,7 +563,7 @@ export default (sbp('sbp/selectors/register', { 'chelonia/out/propDel': async function () { } -}): string[]) +}) function contractNameFromAction (action: string): string { const regexResult = ACTION_REGEX.exec(action) @@ -434,9 +573,10 @@ function contractNameFromAction (action: string): string { } async function outEncryptedOrUnencryptedAction ( + this: CheloniaInstance, opType: 'ae' | 'au', params: ChelActionParams -) { +): Promise { const { action, contractID, data, hooks, publishOptions } = params const contractName = contractNameFromAction(action) const manifestHash = this.config.contracts.manifests[contractName] @@ -447,7 +587,7 @@ async function outEncryptedOrUnencryptedAction ( const gProxy = gettersProxy(state, contract.getters) contract.metadata.validate(meta, { state, ...gProxy, contractID }) contract.actions[action].validate(data, { state, ...gProxy, meta, contractID }) - const unencMessage = ({ action, data, meta }: GIOpActionUnencrypted) + const unencMessage: GIOpActionUnencrypted = { action, data, meta } const message = GIMessage.createV1_0(contractID, previousHEAD, [ opType, @@ -462,16 +602,18 @@ async function outEncryptedOrUnencryptedAction ( return message } +type Getters = Record unknown> + // The gettersProxy is what makes Vue-like getters possible. In other words, // we want to make sure that the getter functions that we defined in each // contract get passed the 'state' when a getter is accessed. // The only way to pass in the state is by creating a Proxy object that does // that for us. This allows us to maintain compatibility with Vue.js and integrate // the contract getters into the Vue-facing getters. -function gettersProxy (state: Object, getters: Object) { - const proxyGetters = new Proxy({}, { - get (target, prop) { - return getters[prop](state, proxyGetters) +function gettersProxy (state: unknown, getters: Getters): { getters: Getters } { + const proxyGetters: Getters = new Proxy({} as Getters, { + get (target: Getters, prop: string) { + return (getters[prop])(state, proxyGetters) } }) return { getters: proxyGetters } diff --git a/shared/domains/chelonia/db.js b/shared/domains/chelonia/db.ts similarity index 75% rename from shared/domains/chelonia/db.js rename to shared/domains/chelonia/db.ts index 68818fa86f..e237984f0b 100644 --- a/shared/domains/chelonia/db.js +++ b/shared/domains/chelonia/db.ts @@ -1,10 +1,12 @@ -'use strict' - import sbp from '@sbp/sbp' import '@sbp/okturtles.data' import '@sbp/okturtles.eventqueue' -import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { ChelErrorDBBadPreviousHEAD, ChelErrorDBConnection } from './errors.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.ts' +import { ChelErrorDBBadPreviousHEAD, ChelErrorDBConnection } from './errors.ts' + +declare const process: { + env: Record +} const headSuffix = '-HEAD' @@ -15,46 +17,47 @@ sbp('sbp/selectors/unsafe', ['chelonia/db/get', 'chelonia/db/set', 'chelonia/db/ const dbPrimitiveSelectors = process.env.LIGHTWEIGHT_CLIENT === 'true' ? { - 'chelonia/db/get': function (key): Promise<*> { + 'chelonia/db/get': function (key: string): Promise { const id = sbp('chelonia/db/contractIdFromLogHEAD', key) + // @ts-expect-error Property 'config' does not exist. return Promise.resolve(id ? sbp(this.config.stateSelector).contracts[id]?.HEAD : null) }, - 'chelonia/db/set': function (key, value): Promise { return Promise.resolve(value) }, + 'chelonia/db/set': function (key: string, value: unknown): Promise { return Promise.resolve(value) }, 'chelonia/db/delete': function (): Promise { return Promise.resolve() } } : { - 'chelonia/db/get': function (key: string): Promise<*> { + 'chelonia/db/get': function (key: unknown): unknown { return Promise.resolve(sbp('okTurtles.data/get', key)) }, - 'chelonia/db/set': function (key: string, value: string): Promise { + 'chelonia/db/set': function (key: unknown, value: unknown) { return Promise.resolve(sbp('okTurtles.data/set', key, value)) }, - 'chelonia/db/delete': function (key: string): Promise { + 'chelonia/db/delete': function (key: unknown) { return Promise.resolve(sbp('okTurtles.data/delete', key)) } } export default (sbp('sbp/selectors/register', { ...dbPrimitiveSelectors, - 'chelonia/db/logHEAD': function (contractID: string): string { + 'chelonia/db/logHEAD': function (contractID: string) { return `${contractID}${headSuffix}` }, - 'chelonia/db/contractIdFromLogHEAD': function (key: string): ?string { + 'chelonia/db/contractIdFromLogHEAD': function (key: string) { return key.endsWith(headSuffix) ? key.slice(0, -headSuffix.length) : null }, - 'chelonia/db/latestHash': function (contractID: string): Promise { + 'chelonia/db/latestHash': function (contractID: string) { return sbp('chelonia/db/get', sbp('chelonia/db/logHEAD', contractID)) }, - 'chelonia/db/getEntry': async function (hash: string): Promise { + 'chelonia/db/getEntry': async function (hash: string) { try { - const value: string = await sbp('chelonia/db/get', hash) + const value = await sbp('chelonia/db/get', hash) if (!value) throw new Error(`no entry for ${hash}!`) return GIMessage.deserialize(value) } catch (e) { throw new ChelErrorDBConnection(`${e.name} during getEntry: ${e.message}`) } }, - 'chelonia/db/addEntry': function (entry: GIMessage): Promise { + 'chelonia/db/addEntry': function (entry: GIMessage) { // because addEntry contains multiple awaits - we want to make sure it gets executed // "atomically" to minimize the chance of a contract fork return sbp('okTurtles.eventQueue/queueEvent', `chelonia/db/${entry.contractID()}`, [ @@ -62,10 +65,11 @@ export default (sbp('sbp/selectors/register', { ]) }, // NEVER call this directly yourself! _always_ call 'chelonia/db/addEntry' instead + // @throws ChelErrorDBConnection, ChelErrorDBConnection 'chelonia/private/db/addEntry': async function (entry: GIMessage): Promise { try { const { previousHEAD } = entry.message() - const contractID: string = entry.contractID() + const contractID = entry.contractID() if (await sbp('chelonia/db/get', entry.hash())) { console.warn(`[chelonia.db] entry exists: ${entry.hash()}`) return entry.hash() @@ -86,7 +90,7 @@ export default (sbp('sbp/selectors/register', { throw new ChelErrorDBConnection(`${e.name} during addEntry: ${e.message}`) } }, - 'chelonia/db/lastEntry': async function (contractID: string): Promise { + 'chelonia/db/lastEntry': async function (contractID: string) { try { const hash = await sbp('chelonia/db/latestHash', contractID) if (!hash) throw new Error(`contract ${contractID} has no latest hash!`) @@ -95,4 +99,4 @@ export default (sbp('sbp/selectors/register', { throw new ChelErrorDBConnection(`${e.name} during lastEntry: ${e.message}`) } } -}): any) +})) diff --git a/shared/domains/chelonia/errors.js b/shared/domains/chelonia/errors.ts similarity index 95% rename from shared/domains/chelonia/errors.js rename to shared/domains/chelonia/errors.ts index be14c18384..5d79ee25fb 100644 --- a/shared/domains/chelonia/errors.js +++ b/shared/domains/chelonia/errors.ts @@ -1,5 +1,4 @@ -'use strict' - +/* eslint-disable @typescript-eslint/no-explicit-any */ export class ChelErrorDBBadPreviousHEAD extends Error { // ugly boilerplate because JavaScript is stupid // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types diff --git a/shared/domains/chelonia/events.js b/shared/domains/chelonia/events.ts similarity index 100% rename from shared/domains/chelonia/events.js rename to shared/domains/chelonia/events.ts diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.ts similarity index 85% rename from shared/domains/chelonia/internals.js rename to shared/domains/chelonia/internals.ts index f745e2900c..84ff23ecd7 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.ts @@ -1,25 +1,61 @@ -'use strict' - import sbp, { domainFromSelector } from '@sbp/sbp' -import './db.js' -import { GIMessage } from './GIMessage.js' +import './db.ts' +import { + GIMessage, + GIOpActionEncrypted, + GIOpActionUnencrypted, + GIOpContract, + GIOpKeyAdd, + GIOpPropSet +} from './GIMessage.ts' +import type { CheloniaConfig, CheloniaInstance, CheloniaState, ContractDefinition, ContractInfo } from './chelonia.ts' + import { randomIntFromRange, delay, cloneDeep, debounce, pick } from '~/frontend/model/contracts/shared/giLodash.js' -import { ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.js' -import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from './events.js' +import { ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.ts' +import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from './events.ts' import { handleFetchResult } from '~/frontend/controller/utils/misc.js' -import { blake32Hash } from '~/shared/functions.js' +import { blake32Hash } from '~/shared/functions.ts' // import 'ses' -import type { GIOpContract, GIOpType, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpPropSet, GIOpKeyAdd } from './GIMessage.js' +type BoolCallback = (...args: unknown[]) => boolean | void + +type ContractState = { + _vm?: VM +} + +type Key = { + context: string + key: string +} + +type RevertProcessParameters = { + message: GIMessage + state: CheloniaState + contractID: string + contractStateCopy: ContractState +} + +type RevertSideEffectParameters = { + message: GIMessage + state: CheloniaState + contractID: string + contractStateCopy: ContractState + stateCopy: CheloniaState +} + +type VM = { + authorizedKeys: Key[] + props: Record +} // export const FERAL_FUNCTION = Function -export default (sbp('sbp/selectors/register', { +export default sbp('sbp/selectors/register', { // DO NOT CALL ANY OF THESE YOURSELF! - 'chelonia/private/state': function () { + 'chelonia/private/state': function (this: CheloniaInstance): CheloniaState { return this.state }, - 'chelonia/private/loadManifest': async function (manifestHash: string) { + 'chelonia/private/loadManifest': async function (this: CheloniaInstance, manifestHash: string) { if (this.manifestToContract[manifestHash]) { console.warn('[chelonia]: already loaded manifest', manifestHash) return @@ -27,7 +63,7 @@ export default (sbp('sbp/selectors/register', { const manifestURL = `${this.config.connectionURL}/file/${manifestHash}` const manifest = await fetch(manifestURL).then(handleFetchResult('json')) const body = JSON.parse(manifest.body) - const contractInfo = (this.config.contracts.defaults.preferSlim && body.contractSlim) || body.contract + const contractInfo: ContractInfo = (this.config.contracts.defaults.preferSlim && body.contractSlim) || body.contract console.info(`[chelonia] loading contract '${contractInfo.file}'@'${body.version}' from manifest: ${manifestHash}`) const source = await fetch(`${this.config.connectionURL}/file/${contractInfo.hash}`) .then(handleFetchResult('text')) @@ -35,14 +71,14 @@ export default (sbp('sbp/selectors/register', { if (sourceHash !== contractInfo.hash) { throw new Error(`bad hash ${sourceHash} for contract '${contractInfo.file}'! Should be: ${contractInfo.hash}`) } - function reduceAllow (acc, v) { acc[v] = true; return acc } + function reduceAllow (acc: Record, v: string) { acc[v] = true; return acc } const allowedSels = ['okTurtles.events/on', 'chelonia/defineContract'] .concat(this.config.contracts.defaults.allowedSelectors) .reduce(reduceAllow, {}) const allowedDoms = this.config.contracts.defaults.allowedDomains .reduce(reduceAllow, {}) let contractName: string // eslint-disable-line prefer-const - const contractSBP = (selector: string, ...args) => { + const contractSBP = (selector: string, ...args: unknown[]) => { const domain = domainFromSelector(selector) if (selector.startsWith(contractName)) { selector = `${manifestHash}/${selector}` @@ -55,7 +91,7 @@ export default (sbp('sbp/selectors/register', { } // const saferEval: Function = new FERAL_FUNCTION(` // eslint-disable-next-line no-new-func - const saferEval: Function = new Function(` + const saferEval = new Function(` return function (globals) { // almost a real sandbox // stops (() => this)().fetch @@ -66,7 +102,7 @@ export default (sbp('sbp/selectors/register', { has (o, p) { /* console.log('has', p); */ return true } })) { (function () { - 'use strict' + 'use strict'; ${source} })() } @@ -84,12 +120,12 @@ export default (sbp('sbp/selectors/register', { console, Object, Error, + Function, // TODO: remove this TypeError, Math, Symbol, Date, Array, - // $FlowFixMe BigInt, Boolean, String, @@ -102,7 +138,7 @@ export default (sbp('sbp/selectors/register', { parseInt, Promise, ...this.config.contracts.defaults.exposedGlobals, - require: (dep) => { + require: (dep: string) => { return dep === '@sbp/sbp' ? contractSBP : this.config.contracts.defaults.modules[dep] @@ -117,8 +153,8 @@ export default (sbp('sbp/selectors/register', { return fetch(`${this.config.connectionURL}/time`).then(handleFetchResult('text')) } }) - contractName = this.defContract.name - this.defContractSelectors.forEach(s => { allowedSels[s] = true }) + contractName = (this.defContract as ContractDefinition).name + ;(this.defContractSelectors as string[]).forEach((s: string) => { allowedSels[s] = true }) this.manifestToContract[manifestHash] = { slim: contractInfo === body.contractSlim, info: contractInfo, @@ -166,23 +202,24 @@ export default (sbp('sbp/selectors/register', { } } }, - 'chelonia/private/in/processMessage': async function (message: GIMessage, state: Object) { + 'chelonia/private/in/processMessage': async function (this: CheloniaInstance, message: GIMessage, state: ContractState) { const [opT, opV] = message.op() const hash = message.hash() const contractID = message.contractID() const manifestHash = message.manifest() const config = this.config - if (!state._vm) state._vm = {} - const opFns: { [GIOpType]: (any) => void } = { + if (!state._vm) state._vm = {} as VM + const { _vm } = state + const opFns = { [GIMessage.OP_CONTRACT] (v: GIOpContract) { // TODO: shouldn't each contract have its own set of authorized keys? - if (!state._vm.authorizedKeys) state._vm.authorizedKeys = [] + if (!_vm.authorizedKeys) _vm.authorizedKeys = [] // TODO: we probably want to be pushing the de-JSON-ified key here - state._vm.authorizedKeys.push({ key: v.keyJSON, context: 'owner' }) + _vm.authorizedKeys.push({ key: v.keyJSON, context: 'owner' }) }, [GIMessage.OP_ACTION_ENCRYPTED] (v: GIOpActionEncrypted) { if (!config.skipActionProcessing) { - const decrypted = message.decryptedValue(config.decryptFn) + const decrypted = message.decryptedValue(config.decryptFn) as GIOpActionUnencrypted opFns[GIMessage.OP_ACTION_UNENCRYPTED](decrypted) } }, @@ -197,8 +234,8 @@ export default (sbp('sbp/selectors/register', { }, [GIMessage.OP_PROP_DEL]: notImplemented, [GIMessage.OP_PROP_SET] (v: GIOpPropSet) { - if (!state._vm.props) state._vm.props = {} - state._vm.props[v.key] = v.value + if (!_vm.props) _vm.props = {} + _vm.props[v.key] = v.value }, [GIMessage.OP_KEY_ADD] (v: GIOpKeyAdd) { // TODO: implement this. consider creating a function so that @@ -216,16 +253,17 @@ export default (sbp('sbp/selectors/register', { if (config.preOp) { processOp = config.preOp(message, state) !== false && processOp } - if (config[`preOp_${opT}`]) { - processOp = config[`preOp_${opT}`](message, state) !== false && processOp + if (`preOp_${opT}` in config) { + processOp = (config[`preOp_${opT}`] as BoolCallback)(message, state) !== false && processOp } if (processOp && !config.skipProcessing) { + // @ts-expect-error TS2345: Argument of type 'GIOpValue' is not assignable. opFns[opT](opV) config.postOp && config.postOp(message, state) - config[`postOp_${opT}`] && config[`postOp_${opT}`](message, state) + ;(`postOp_${opT}` in config) && (config[`postOp_${opT}`] as BoolCallback)(message, state) } }, - 'chelonia/private/in/enqueueHandleEvent': function (event: GIMessage) { + 'chelonia/private/in/enqueueHandleEvent': function (this: CheloniaInstance, event: GIMessage) { // make sure handleEvent is called AFTER any currently-running invocations // to 'chelonia/contract/sync', to prevent gi.db from throwing // "bad previousHEAD" errors @@ -233,7 +271,7 @@ export default (sbp('sbp/selectors/register', { 'chelonia/private/in/handleEvent', event ]) }, - 'chelonia/private/in/syncContract': async function (contractID: string) { + 'chelonia/private/in/syncContract': async function (this: CheloniaInstance, contractID: string) { const state = sbp(this.config.stateSelector) const latest = await sbp('chelonia/out/latestHash', contractID) console.debug(`[chelonia] syncContract: ${contractID} latestHash is: ${latest}`) @@ -274,7 +312,7 @@ export default (sbp('sbp/selectors/register', { throw e } }, - 'chelonia/private/in/handleEvent': async function (message: GIMessage) { + 'chelonia/private/in/handleEvent': async function (this: CheloniaInstance, message: GIMessage) { const state = sbp(this.config.stateSelector) const contractID = message.contractID() const hash = message.hash() @@ -346,13 +384,13 @@ export default (sbp('sbp/selectors/register', { } } } -}): string[]) +}) -const eventsToReinjest = [] -const reprocessDebounced = debounce((contractID) => sbp('chelonia/contract/sync', contractID), 1000) +const eventsToReinjest: string[] = [] +const reprocessDebounced = debounce((contractID: string) => sbp('chelonia/contract/sync', contractID), 1000, undefined) const handleEvent = { - async addMessageToDB (message: GIMessage) { + async addMessageToDB (message: GIMessage): Promise { const contractID = message.contractID() const hash = message.hash() try { @@ -381,14 +419,13 @@ const handleEvent = { throw e } }, - async processMutation (message: GIMessage, state: Object) { + async processMutation (this: CheloniaInstance, message: GIMessage, state: CheloniaState) { const contractID = message.contractID() if (message.isFirstMessage()) { - // Flow doesn't understand that a first message must be a contract, - // so we have to help it a bit in order to acces the 'type' property. - const { type } = ((message.opValue(): any): GIOpContract) + const { type } = message.opValue() as GIOpContract if (!state[contractID]) { console.debug(`contract ${type} registered for ${contractID}`) + ;(this.config as CheloniaConfig) this.config.reactiveSet(state, contractID, {}) this.config.reactiveSet(state.contracts, contractID, { type, HEAD: contractID }) } @@ -399,17 +436,17 @@ const handleEvent = { } await sbp('chelonia/private/in/processMessage', message, state[contractID]) }, - async processSideEffects (message: GIMessage) { - if ([GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(message.opType())) { + async processSideEffects (this: CheloniaInstance, message: GIMessage) { + if (([GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED] as string[]).includes(message.opType())) { const contractID = message.contractID() const manifestHash = message.manifest() const hash = message.hash() - const { action, data, meta } = message.decryptedValue() + const { action, data, meta } = message.decryptedValue() as GIOpActionUnencrypted const mutation = { data, meta, hash, contractID, description: message.description() } await sbp(`${manifestHash}/${action}/sideEffect`, mutation) } }, - revertProcess ({ message, state, contractID, contractStateCopy }) { + revertProcess (this: CheloniaInstance, { message, state, contractID, contractStateCopy }: RevertProcessParameters) { console.warn(`[chelonia] reverting mutation ${message.description()}: ${message.serialize()}. Any side effects will be skipped!`) if (!contractStateCopy) { console.warn(`[chelonia] mutation reversion on very first message for contract ${contractID}! Your contract may be too damaged to be useful and should be redeployed with bugfixes.`) @@ -417,7 +454,7 @@ const handleEvent = { } this.config.reactiveSet(state, contractID, contractStateCopy) }, - revertSideEffect ({ message, state, contractID, contractStateCopy, stateCopy }) { + revertSideEffect (this: CheloniaInstance, { message, state, contractID, contractStateCopy, stateCopy }: RevertSideEffectParameters) { console.warn(`[chelonia] reverting entire state because failed sideEffect for ${message.description()}: ${message.serialize()}`) if (!contractStateCopy) { this.config.reactiveDel(state, contractID) @@ -430,7 +467,7 @@ const handleEvent = { } } -const notImplemented = (v) => { +const notImplemented = (v: unknown) => { throw new Error(`chelonia: action not implemented to handle: ${JSON.stringify(v)}.`) } diff --git a/shared/domains/chelonia/types.flow.js b/shared/domains/chelonia/types.flow.js new file mode 100644 index 0000000000..364a59543b --- /dev/null +++ b/shared/domains/chelonia/types.flow.js @@ -0,0 +1,45 @@ +import type { JSONArray, JSONObject, JSONType } from '~/shared/types.flow.js' + +export type GIKeyType = '' + +export type GIKey = { + type: GIKeyType; + data: Object; // based on GIKeyType this will change + meta: Object; +} +// Allows server to check if the user is allowed to register this type of contract +// TODO: rename 'type' to 'contractName': +export type GIOpContract = { type: string; keyJSON: string, parentContract?: string } +export type GIOpActionEncrypted = string // encrypted version of GIOpActionUnencrypted +export type GIOpActionUnencrypted = { action: string; data: JSONType; meta: JSONObject } +export type GIOpKeyAdd = { keyHash: string, keyJSON: ?string, context: string } +export type GIOpPropSet = { key: string, value: JSONType } + +export type GIOpType = 'c' | 'ae' | 'au' | 'ka' | 'kd' | 'pu' | 'ps' | 'pd' +export type GIOpValue = GIOpContract | GIOpActionEncrypted | GIOpActionUnencrypted | GIOpKeyAdd | GIOpPropSet +export type GIOp = [GIOpType, GIOpValue] + +export type GIMessage = { + _decrypted: GIOpValue; + _mapping: Object; + _message: Object; + + decryptedValue (fn?: Function): any; + + message (): Object; + + op (): GIOp; + + opType (): GIOpType; + + opValue (): GIOpValue; + + manifest (): string; + + description (): string; + + isFirstMessage (): boolean; + contractID (): string; + serialize (): string; + hash (): string; +} diff --git a/shared/functions.js b/shared/functions.ts similarity index 58% rename from shared/functions.js rename to shared/functions.ts index 25886d5501..a1c8d69048 100644 --- a/shared/functions.js +++ b/shared/functions.ts @@ -4,14 +4,11 @@ import multihash from 'multihashes' import nacl from 'tweetnacl' import blake from 'blakejs' -// Makes the `Buffer` global available in the browser if needed. -if (typeof window === 'object' && typeof Buffer === 'undefined') { - // Only import `Buffer` to hopefully help treeshaking. - const { Buffer } = require('buffer') - window.Buffer = Buffer -} +import { Buffer } from 'buffer' + +(self as typeof self & { Buffer: typeof Buffer }).Buffer = Buffer -export function blake32Hash (data: string | Buffer | Uint8Array): string { +export function blake32Hash (data: unknown) { // TODO: for node/electron, switch to: https://github.com/ludios/node-blake2 const uint8array = blake.blake2b(data, null, 32) // TODO: if we switch to webpack we may need: https://github.com/feross/buffer @@ -26,18 +23,23 @@ export function blake32Hash (data: string | Buffer | Uint8Array): string { // and you have to jump through some hoops to get it to work: // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa#Unicode_strings // These hoops might result in inconsistencies between Node.js and the frontend. -export const b64ToBuf = (b64: string): Buffer => Buffer.from(b64, 'base64') -export const b64ToStr = (b64: string): string => b64ToBuf(b64).toString('utf8') -export const bufToB64 = (buf: Buffer): string => Buffer.from(buf).toString('base64') -export const strToBuf = (str: string): Buffer => Buffer.from(str, 'utf8') -export const strToB64 = (str: string): string => strToBuf(str).toString('base64') -export const bytesToB64 = (ary: Uint8Array): string => Buffer.from(ary).toString('base64') +export const b64ToBuf = (b64: string) => Buffer.from(b64, 'base64') +export const b64ToStr = (b64: string) => b64ToBuf(b64).toString('utf8') +export const bufToB64 = (buf: ArrayBuffer) => Buffer.from(buf).toString('base64') +export const strToBuf = (str: string) => Buffer.from(str, 'utf8') +export const strToB64 = (str: string) => strToBuf(str).toString('base64') +export const bytesToB64 = (ary: ArrayBuffer) => Buffer.from(ary).toString('base64') + +type KeyPair = { + publicKey: string + secretKey: string +} export function sign ( - { publicKey, secretKey }: {publicKey: string, secretKey: string}, - msg: string = 'hello!', - futz: string = '' -): string { + { publicKey, secretKey }: KeyPair, + msg = 'hello!', + futz = '' +) { return strToB64(JSON.stringify({ msg: msg + futz, key: publicKey, @@ -47,6 +49,6 @@ export function sign ( export function verify ( msg: string, key: string, sig: string -): any { +) { return nacl.sign.detached.verify(strToBuf(msg), b64ToBuf(sig), b64ToBuf(key)) } diff --git a/shared/pubsub.test.js b/shared/pubsub.test.js deleted file mode 100644 index 2cf442b6a0..0000000000 --- a/shared/pubsub.test.js +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-env mocha */ -'use strict' - -import { createClient } from './pubsub.js' - -const should = require('should') // eslint-disable-line - -const client = createClient('ws://localhost:8080', { - manual: true, - reconnectOnDisconnection: false, - reconnectOnOnline: false, - reconnectOnTimeout: false -}) -const { - maxReconnectionDelay, - minReconnectionDelay -} = client.options - -const createRandomDelays = (number) => { - return [...new Array(number)].map((_, i) => { - client.failedConnectionAttempts = i - return client.getNextRandomDelay() - }) -} -const delays1 = createRandomDelays(10) -const delays2 = createRandomDelays(10) - -describe('Test getNextRandomDelay()', function () { - it('every delay should be longer than the previous one', function () { - // In other words, the delays should be sorted in ascending numerical order. - should(delays1).deepEqual([...delays1].sort((a, b) => a - b)) - should(delays2).deepEqual([...delays2].sort((a, b) => a - b)) - }) - - it('no delay should be shorter than the minimal reconnection delay', function () { - delays1.forEach((delay) => { - should(delay).be.greaterThanOrEqual(minReconnectionDelay) - }) - delays2.forEach((delay) => { - should(delay).be.greaterThanOrEqual(minReconnectionDelay) - }) - }) - - it('no delay should be longer than the maximal reconnection delay', function () { - delays1.forEach((delay) => { - should(delay).be.lessThanOrEqual(maxReconnectionDelay) - }) - delays2.forEach((delay) => { - should(delay).be.lessThanOrEqual(maxReconnectionDelay) - }) - }) -}) diff --git a/shared/pubsub.test.ts b/shared/pubsub.test.ts new file mode 100644 index 0000000000..46a5723416 --- /dev/null +++ b/shared/pubsub.test.ts @@ -0,0 +1,61 @@ +import { + assert, + assertEquals +} from 'asserts' + +import '~/scripts/process-shim.ts' + +import { createClient } from './pubsub.ts' + +const client = createClient('ws://localhost:8080', { + manual: true, + reconnectOnDisconnection: false, + reconnectOnOnline: false, + reconnectOnTimeout: false +}) +const { + maxReconnectionDelay, + minReconnectionDelay +} = client.options + +const createRandomDelays = (number: number) => { + return [...new Array(number)].map((_, i) => { + client.failedConnectionAttempts = i + return client.getNextRandomDelay() + }) +} +const delays1 = createRandomDelays(10) +const delays2 = createRandomDelays(10) + +// Test steps must be async, but we don't always use `await` in them. +/* eslint-disable require-await */ +Deno.test({ + name: 'Test getNextRandomDelay()', + fn: async function (tests) { + await tests.step('every delay should be longer than the previous one', async function () { + // In other words, the delays should be sorted in ascending numerical order. + assertEquals(delays1, [...delays1].sort((a, b) => a - b)) + assertEquals(delays2, [...delays2].sort((a, b) => a - b)) + }) + + await tests.step('no delay should be shorter than the minimal reconnection delay', async function () { + delays1.forEach((delay) => { + assert(delay >= minReconnectionDelay) + }) + delays2.forEach((delay) => { + assert(delay >= minReconnectionDelay) + }) + }) + + await tests.step('no delay should be longer than the maximal reconnection delay', async function () { + delays1.forEach((delay) => { + assert(delay <= maxReconnectionDelay) + }) + delays2.forEach((delay) => { + assert(delay <= maxReconnectionDelay) + }) + }) + }, + sanitizeResources: false, + sanitizeOps: false +}) diff --git a/shared/pubsub.js b/shared/pubsub.ts similarity index 74% rename from shared/pubsub.js rename to shared/pubsub.ts index 5257d849d4..4d6e2f7c99 100644 --- a/shared/pubsub.js +++ b/shared/pubsub.ts @@ -1,75 +1,47 @@ -'use strict' - import sbp from '@sbp/sbp' import '@sbp/okturtles.events' -import type { JSONObject, JSONType } from '~/shared/types.js' -// ====== Event name constants ====== // +declare const process: { + env: Record +} -export const PUBSUB_ERROR = 'pubsub-error' -export const PUBSUB_RECONNECTION_ATTEMPT = 'pubsub-reconnection-attempt' -export const PUBSUB_RECONNECTION_FAILED = 'pubsub-reconnection-failed' -export const PUBSUB_RECONNECTION_SCHEDULED = 'pubsub-reconnection-scheduled' -export const PUBSUB_RECONNECTION_SUCCEEDED = 'pubsub-reconnection-succeeded' +type JSONType = ReturnType; // ====== Types ====== // -/* - * Flowtype usage notes: - * - * - The '+' prefix indicates properties that should not be re-assigned or - * deleted after their initialization. - * - * - 'TimeoutID' is an opaque type declared in Flow's core definition file, - * used as the return type of the core setTimeout() function. - */ +type Callback = (this: PubsubClient, ...args: unknown[]) => void -export type Message = { - [key: string]: JSONType, - +type: string +type Message = { + [key: string]: JSONType; + type: string } -export type PubSubClient = { - connectionTimeoutID: TimeoutID | void, - +customEventHandlers: Object, - failedConnectionAttempts: number, - +isLocal: boolean, - isNew: boolean, - +listeners: Object, - +messageHandlers: Object, - nextConnectionAttemptDelayID: TimeoutID | void, - +options: Object, - +pendingSubscriptionSet: Set, - pendingSyncSet: Set, - +pendingUnsubscriptionSet: Set, - pingTimeoutID: TimeoutID | void, - shouldReconnect: boolean, - socket: WebSocket | null, - +subscriptionSet: Set, - +url: string, - // Methods - clearAllTimers(): void, - connect(): void, - destroy(): void, - pub(contractID: string, data: JSONType): void, - scheduleConnectionAttempt(): void, - sub(contractID: string): void, - unsub(contractID: string): void +type MessageHandler = (this: PubsubClient, msg: Message) => void + +type PubsubClientOptions = { + handlers?: Record + eventHandlers?: Record + logPingMessages?: boolean + manual?: boolean + maxReconnectionDelay?: number + maxRetries?: number + messageHandlers?: Record + minReconnectionDelay?: number + pingTimeout?: number + reconnectOnDisconnection?: boolean + reconnectOnOnline?: boolean + reconnectOnTimeout?: boolean + reconnectionDelayGrowFactor?: number + timeout?: number } -export type SubMessage = { - [key: string]: JSONType, - +type: 'sub', - +contractID: string, - +dontBroadcast: boolean -} +// ====== Event name constants ====== // -export type UnsubMessage = { - [key: string]: JSONType, - +type: 'unsub', - +contractID: string, - +dontBroadcast: boolean -} +export const PUBSUB_ERROR = 'pubsub-error' +export const PUBSUB_RECONNECTION_ATTEMPT = 'pubsub-reconnection-attempt' +export const PUBSUB_RECONNECTION_FAILED = 'pubsub-reconnection-failed' +export const PUBSUB_RECONNECTION_SCHEDULED = 'pubsub-reconnection-scheduled' +export const PUBSUB_RECONNECTION_SUCCEEDED = 'pubsub-reconnection-succeeded' // ====== Enums ====== // @@ -94,9 +66,256 @@ export const RESPONSE_TYPE = Object.freeze({ SUCCESS: 'success' }) -export type NotificationTypeEnum = $Values -export type RequestTypeEnum = $Values -export type ResponseTypeEnum = $Values +// TODO: verify these are good defaults +const defaultOptions = { + logPingMessages: process.env.NODE_ENV === 'development' && !process.env.CI, + manual: false, + maxReconnectionDelay: 60000, + maxRetries: 10, + pingTimeout: 45000, + minReconnectionDelay: 500, + reconnectOnDisconnection: true, + reconnectOnOnline: true, + // Defaults to false to avoid reconnection attempts in case the server doesn't + // respond because of a failed authentication. + reconnectOnTimeout: false, + reconnectionDelayGrowFactor: 2, + timeout: 5000 +} + +export class PubsubClient { + connectionTimeoutID?: number + customEventHandlers: Record + // The current number of connection attempts that failed. + // Reset to 0 upon successful connection. + // Used to compute how long to wait before the next reconnection attempt. + failedConnectionAttempts: number + isLocal: boolean + // True if this client has never been connected yet. + isNew: boolean + listeners: Record + messageHandlers: Record + nextConnectionAttemptDelayID?: number + options: typeof defaultOptions + // Requested subscriptions for which we didn't receive a response yet. + pendingSubscriptionSet: Set + pendingSyncSet: Set + pendingUnsubscriptionSet: Set + pingTimeoutID?: number + shouldReconnect: boolean + // The underlying WebSocket object. + // A new one is necessary for every connection or reconnection attempt. + socket: WebSocket | null = null + subscriptionSet: Set + url: string + + constructor (url: string, options: PubsubClientOptions = {}) { + this.customEventHandlers = options.handlers ?? {} + this.failedConnectionAttempts = 0 + this.isLocal = /\/\/(localhost|127\.0\.0\.1)([:?/]|$)/.test(url) + // True if this client has never been connected yet. + this.isNew = true + this.listeners = Object.create(null) + this.messageHandlers = { ...defaultMessageHandlers, ...options.messageHandlers } + this.options = { ...defaultOptions, ...options } + // Requested subscriptions for which we didn't receive a response yet. + this.pendingSubscriptionSet = new Set() + this.pendingSyncSet = new Set() + this.pendingUnsubscriptionSet = new Set() + this.shouldReconnect = true + this.subscriptionSet = new Set() + this.url = url.replace(/^http/, 'ws') + + const client = this + // Create and save references to reusable event listeners. + // Every time a new underlying WebSocket object will be created for this + // client instance, these event listeners will be detached from the older + // socket then attached to the new one, hereby avoiding both unnecessary + // allocations and garbage collections of a bunch of functions every time. + // Another benefit is the ability to patch the client protocol at runtime by + // updating the client's custom event handler map. + for (const name of Object.keys(defaultClientEventHandlers)) { + client.listeners[name] = (event: Event) => { + try { + // Use `.call()` to pass the client via the 'this' binding. + // @ts-expect-error TS2684 + defaultClientEventHandlers[name]?.call(client, event) + client.customEventHandlers[name]?.call(client, event) + } catch (error) { + // Do not throw any error but emit an `error` event instead. + sbp('okTurtles.events/emit', PUBSUB_ERROR, client, error.message) + } + } + } + // Add global event listeners before the first connection. + if (typeof window === 'object') { + for (const name of globalEventNames) { + window.addEventListener(name, client.listeners[name]) + } + } + if (!client.options.manual) { + client.connect() + } + } + + clearAllTimers () { + clearTimeout(this.connectionTimeoutID) + clearTimeout(this.nextConnectionAttemptDelayID) + clearTimeout(this.pingTimeoutID) + this.connectionTimeoutID = undefined + this.nextConnectionAttemptDelayID = undefined + this.pingTimeoutID = undefined + } + + // Performs a connection or reconnection attempt. + connect () { + const client = this + + if (client.socket !== null) { + throw new Error('connect() can only be called if there is no current socket.') + } + if (client.nextConnectionAttemptDelayID) { + throw new Error('connect() must not be called during a reconnection delay.') + } + if (!client.shouldReconnect) { + throw new Error('connect() should no longer be called on this instance.') + } + client.socket = new WebSocket(client.url) + + if (client.options.timeout) { + client.connectionTimeoutID = setTimeout(() => { + client.connectionTimeoutID = undefined + client.socket?.close(4000, 'timeout') + }, client.options.timeout) + } + // Attach WebSocket event listeners. + for (const name of socketEventNames) { + client.socket.addEventListener(name, client.listeners[name]) + } + } + + /** + * Immediately close the socket, stop listening for events and clear any cache. + * + * This method is used in unit tests. + * - In particular, no 'close' event handler will be called. + * - Any incoming or outgoing buffered data will be discarded. + * - Any pending messages will be discarded. + */ + destroy () { + const client = this + + client.clearAllTimers() + // Update property values. + // Note: do not clear 'client.options'. + client.pendingSubscriptionSet.clear() + client.pendingUnsubscriptionSet.clear() + client.subscriptionSet.clear() + // Remove global event listeners. + if (typeof window === 'object') { + for (const name of globalEventNames) { + window.removeEventListener(name, client.listeners[name]) + } + } + // Remove WebSocket event listeners. + if (client.socket) { + for (const name of socketEventNames) { + client.socket.removeEventListener(name, client.listeners[name]) + } + client.socket.close(4001, 'terminated') + } + client.listeners = {} + client.socket = null + client.shouldReconnect = false + } + + getNextRandomDelay (): number { + const client = this + + const { + maxReconnectionDelay, + minReconnectionDelay, + reconnectionDelayGrowFactor + } = client.options + + const minDelay = minReconnectionDelay * reconnectionDelayGrowFactor ** client.failedConnectionAttempts + const maxDelay = minDelay * reconnectionDelayGrowFactor + + return Math.min(maxReconnectionDelay, Math.round(minDelay + Math.random() * (maxDelay - minDelay))) + } + + // Schedules a connection attempt to happen after a delay computed according to + // a randomized exponential backoff algorithm variant. + scheduleConnectionAttempt () { + const client = this + + if (!client.shouldReconnect) { + throw new Error('Cannot call `scheduleConnectionAttempt()` when `shouldReconnect` is false.') + } + if (client.nextConnectionAttemptDelayID) { + return console.warn('[pubsub] A reconnection attempt is already scheduled.') + } + const delay = client.getNextRandomDelay() + const nth = client.failedConnectionAttempts + 1 + + client.nextConnectionAttemptDelayID = setTimeout(() => { + sbp('okTurtles.events/emit', PUBSUB_RECONNECTION_ATTEMPT, client) + client.nextConnectionAttemptDelayID = undefined + client.connect() + }, delay) + sbp('okTurtles.events/emit', PUBSUB_RECONNECTION_SCHEDULED, client, { delay, nth }) + } + + // Unused for now. + pub (contractID: string, data: JSONType) { + } + + /** + * Sends a SUB request to the server as soon as possible. + * + * - The given contract ID will be cached until we get a relevant server + * response, allowing us to resend the same request if necessary. + * - Any identical UNSUB request that has not been sent yet will be cancelled. + * - Calling this method again before the server has responded has no effect. + * @param contractID - The ID of the contract whose updates we want to subscribe to. + */ + sub (contractID: string, dontBroadcast = false) { + const client = this + const { socket } = this + + if (!client.pendingSubscriptionSet.has(contractID)) { + client.pendingSubscriptionSet.add(contractID) + client.pendingUnsubscriptionSet.delete(contractID) + + if (socket?.readyState === WebSocket.OPEN) { + socket.send(createRequest(REQUEST_TYPE.SUB, { contractID }, dontBroadcast)) + } + } + } + + /** + * Sends an UNSUB request to the server as soon as possible. + * + * - The given contract ID will be cached until we get a relevant server + * response, allowing us to resend the same request if necessary. + * - Any identical SUB request that has not been sent yet will be cancelled. + * - Calling this method again before the server has responded has no effect. + * @param contractID - The ID of the contract whose updates we want to unsubscribe from. + */ + unsub (contractID: string, dontBroadcast = false) { + const client = this + const { socket } = this + + if (!client.pendingUnsubscriptionSet.has(contractID)) { + client.pendingSubscriptionSet.delete(contractID) + client.pendingUnsubscriptionSet.add(contractID) + + if (socket?.readyState === WebSocket.OPEN) { + socket.send(createRequest(REQUEST_TYPE.UNSUB, { contractID }, dontBroadcast)) + } + } + } +} // ====== API ====== // @@ -117,70 +336,15 @@ export type ResponseTypeEnum = $Values * {number?} timeout=5_000 - Connection timeout duration in milliseconds. * @returns {PubSubClient} */ -export function createClient (url: string, options?: Object = {}): PubSubClient { - const client: PubSubClient = { - customEventHandlers: options.handlers || {}, - // The current number of connection attempts that failed. - // Reset to 0 upon successful connection. - // Used to compute how long to wait before the next reconnection attempt. - failedConnectionAttempts: 0, - isLocal: /\/\/(localhost|127\.0\.0\.1)([:?/]|$)/.test(url), - // True if this client has never been connected yet. - isNew: true, - listeners: Object.create(null), - messageHandlers: { ...defaultMessageHandlers, ...options.messageHandlers }, - nextConnectionAttemptDelayID: undefined, - options: { ...defaultOptions, ...options }, - // Requested subscriptions for which we didn't receive a response yet. - pendingSubscriptionSet: new Set(), - pendingSyncSet: new Set(), - pendingUnsubscriptionSet: new Set(), - pingTimeoutID: undefined, - shouldReconnect: true, - // The underlying WebSocket object. - // A new one is necessary for every connection or reconnection attempt. - socket: null, - subscriptionSet: new Set(), - connectionTimeoutID: undefined, - url: url.replace(/^http/, 'ws'), - ...publicMethods - } - // Create and save references to reusable event listeners. - // Every time a new underlying WebSocket object will be created for this - // client instance, these event listeners will be detached from the older - // socket then attached to the new one, hereby avoiding both unnecessary - // allocations and garbage collections of a bunch of functions every time. - // Another benefit is the ability to patch the client protocol at runtime by - // updating the client's custom event handler map. - for (const name of Object.keys(defaultClientEventHandlers)) { - client.listeners[name] = (event) => { - try { - // Use `.call()` to pass the client via the 'this' binding. - defaultClientEventHandlers[name]?.call(client, event) - client.customEventHandlers[name]?.call(client, event) - } catch (error) { - // Do not throw any error but emit an `error` event instead. - sbp('okTurtles.events/emit', PUBSUB_ERROR, client, error.message) - } - } - } - // Add global event listeners before the first connection. - if (typeof window === 'object') { - for (const name of globalEventNames) { - window.addEventListener(name, client.listeners[name]) - } - } - if (!client.options.manual) { - client.connect() - } - return client +export function createClient (url: string, options: PubsubClientOptions = {}): PubsubClient { + return new PubsubClient(url, options) } export function createMessage (type: string, data: JSONType): string { return JSON.stringify({ type, data }) } -export function createRequest (type: RequestTypeEnum, data: JSONObject, dontBroadcast: boolean = false): string { +export function createRequest (type: string, data: JSONType, dontBroadcast = false): string { // Had to use Object.assign() instead of object spreading to make Flow happy. return JSON.stringify(Object.assign({ type, dontBroadcast }, data)) } @@ -188,7 +352,7 @@ export function createRequest (type: RequestTypeEnum, data: JSONObject, dontBroa // These handlers receive the PubSubClient instance through the `this` binding. const defaultClientEventHandlers = { // Emitted when the connection is closed. - close (event: CloseEvent) { + close (this: PubsubClient, event: CloseEvent) { const client = this console.debug('[pubsub] Event: close', event.code, event.reason) @@ -245,7 +409,7 @@ const defaultClientEventHandlers = { // Emitted when an error has occured. // The socket will be closed automatically by the engine if necessary. - error (event: Event) { + error (this: PubsubClient, event: Event) { const client = this // Not all error events should be logged with console.error, for example every // failed connection attempt generates one such event. @@ -256,7 +420,7 @@ const defaultClientEventHandlers = { // Emitted when a message is received. // The connection will be terminated if the message is malformed or has an // unexpected data type (e.g. binary instead of text). - message (event: MessageEvent) { + message (this: PubsubClient, event: MessageEvent) { const client = this const { data } = event @@ -266,7 +430,7 @@ const defaultClientEventHandlers = { }) return client.destroy() } - let msg: Message = { type: '' } + let msg = { type: '' } try { msg = messageParser(data) @@ -285,7 +449,7 @@ const defaultClientEventHandlers = { } }, - offline (event: Event) { + offline (this: PubsubClient, event: Event) { console.info('[pubsub] Event: offline') const client = this @@ -293,10 +457,10 @@ const defaultClientEventHandlers = { // Reset the connection attempt counter so that we'll start a new // reconnection loop when we are back online. client.failedConnectionAttempts = 0 - client.socket?.close() + client.socket?.close(4002, 'offline') }, - online (event: Event) { + online (this: PubsubClient, event: Event) { console.info('[pubsub] Event: online') const client = this @@ -309,7 +473,7 @@ const defaultClientEventHandlers = { }, // Emitted when the connection is established. - open (event: Event) { + open (this: PubsubClient, event: Event) { console.debug('[pubsub] Event: open') const client = this const { options } = this @@ -324,7 +488,8 @@ const defaultClientEventHandlers = { // It will close the connection if we don't get any message from the server. if (options.pingTimeout > 0 && options.pingTimeout < Infinity) { client.pingTimeoutID = setTimeout(() => { - client.socket?.close() + console.debug('[pubsub] Closing the connection because of ping timeout') + client.socket?.close(4000, 'timeout') }, options.pingTimeout) } // We only need to handle contract resynchronization here when reconnecting. @@ -339,15 +504,15 @@ const defaultClientEventHandlers = { // There should be no pending unsubscription since we just got connected. }, - 'reconnection-attempt' (event: CustomEvent) { + 'reconnection-attempt' (this: PubsubClient, event: CustomEvent) { console.info('[pubsub] Trying to reconnect...') }, - 'reconnection-succeeded' (event: CustomEvent) { + 'reconnection-succeeded' (this: PubsubClient, event: CustomEvent) { console.info('[pubsub] Connection re-established') }, - 'reconnection-failed' (event: CustomEvent) { + 'reconnection-failed' (this: PubsubClient, event: CustomEvent) { console.warn('[pubsub] Reconnection failed') const client = this @@ -362,11 +527,11 @@ const defaultClientEventHandlers = { // These handlers receive the PubSubClient instance through the `this` binding. const defaultMessageHandlers = { - [NOTIFICATION_TYPE.ENTRY] (msg) { + [NOTIFICATION_TYPE.ENTRY] (this: PubsubClient, msg: Message) { console.debug('[pubsub] Received ENTRY:', msg) }, - [NOTIFICATION_TYPE.PING] ({ data }) { + [NOTIFICATION_TYPE.PING] (this: PubsubClient, { data }: Message) { const client = this if (client.options.logPingMessages) { @@ -377,24 +542,24 @@ const defaultMessageHandlers = { // Refresh the ping timer, waiting for the next ping. clearTimeout(client.pingTimeoutID) client.pingTimeoutID = setTimeout(() => { - client.socket?.close() + client.socket?.close(4000, 'timeout') }, client.options.pingTimeout) }, // PUB can be used to send ephemeral messages outside of any contract log. - [NOTIFICATION_TYPE.PUB] (msg) { + [NOTIFICATION_TYPE.PUB] (msg: Message) { console.debug(`[pubsub] Ignoring ${msg.type} message:`, msg.data) }, - [NOTIFICATION_TYPE.SUB] (msg) { + [NOTIFICATION_TYPE.SUB] (msg: Message) { console.debug(`[pubsub] Ignoring ${msg.type} message:`, msg.data) }, - [NOTIFICATION_TYPE.UNSUB] (msg) { + [NOTIFICATION_TYPE.UNSUB] (msg: Message) { console.debug(`[pubsub] Ignoring ${msg.type} message:`, msg.data) }, - [RESPONSE_TYPE.ERROR] ({ data: { type, contractID } }) { + [RESPONSE_TYPE.ERROR] (this: PubsubClient, { data: { type, contractID } }: Message) { console.warn(`[pubsub] Received ERROR response for ${type} request to ${contractID}`) const client = this @@ -416,7 +581,7 @@ const defaultMessageHandlers = { } }, - [RESPONSE_TYPE.SUCCESS] ({ data: { type, contractID } }) { + [RESPONSE_TYPE.SUCCESS] (this: PubsubClient, { data: { type, contractID } }: Message) { const client = this switch (type) { @@ -443,28 +608,13 @@ const defaultMessageHandlers = { } } -// TODO: verify these are good defaults -const defaultOptions = { - logPingMessages: process.env.NODE_ENV === 'development' && !process.env.CI, - pingTimeout: 45000, - maxReconnectionDelay: 60000, - maxRetries: 10, - minReconnectionDelay: 500, - reconnectOnDisconnection: true, - reconnectOnOnline: true, - // Defaults to false to avoid reconnection attempts in case the server doesn't - // respond because of a failed authentication. - reconnectOnTimeout: false, - reconnectionDelayGrowFactor: 2, - timeout: 5000 -} - const globalEventNames = ['offline', 'online'] const socketEventNames = ['close', 'error', 'message', 'open'] // `navigator.onLine` can give confusing false positives when `true`, // so we'll define `isDefinetelyOffline()` rather than `isOnline()` or `isOffline()`. // See https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine +// @ts-expect-error TS2339 [ERROR]: Property 'onLine' does not exist on type 'Navigator'. const isDefinetelyOffline = () => typeof navigator === 'object' && navigator.onLine === false // Parses and validates a received message. @@ -482,173 +632,11 @@ export const messageParser = (data: string): Message => { return msg } -const publicMethods = { - clearAllTimers () { - const client = this - - clearTimeout(client.connectionTimeoutID) - clearTimeout(client.nextConnectionAttemptDelayID) - clearTimeout(client.pingTimeoutID) - client.connectionTimeoutID = undefined - client.nextConnectionAttemptDelayID = undefined - client.pingTimeoutID = undefined - }, - - // Performs a connection or reconnection attempt. - connect () { - const client = this - - if (client.socket !== null) { - throw new Error('connect() can only be called if there is no current socket.') - } - if (client.nextConnectionAttemptDelayID) { - throw new Error('connect() must not be called during a reconnection delay.') - } - if (!client.shouldReconnect) { - throw new Error('connect() should no longer be called on this instance.') - } - client.socket = new WebSocket(client.url) - - if (client.options.timeout) { - client.connectionTimeoutID = setTimeout(() => { - client.connectionTimeoutID = undefined - client.socket?.close(4000, 'timeout') - }, client.options.timeout) - } - // Attach WebSocket event listeners. - for (const name of socketEventNames) { - client.socket.addEventListener(name, client.listeners[name]) - } - }, - - /** - * Immediately close the socket, stop listening for events and clear any cache. - * - * This method is used in unit tests. - * - In particular, no 'close' event handler will be called. - * - Any incoming or outgoing buffered data will be discarded. - * - Any pending messages will be discarded. - */ - destroy () { - const client = this - - client.clearAllTimers() - // Update property values. - // Note: do not clear 'client.options'. - client.pendingSubscriptionSet.clear() - client.pendingUnsubscriptionSet.clear() - client.subscriptionSet.clear() - // Remove global event listeners. - if (typeof window === 'object') { - for (const name of globalEventNames) { - window.removeEventListener(name, client.listeners[name]) - } - } - // Remove WebSocket event listeners. - if (client.socket) { - for (const name of socketEventNames) { - client.socket.removeEventListener(name, client.listeners[name]) - } - client.socket.close() - } - client.listeners = {} - client.socket = null - client.shouldReconnect = false - }, - - getNextRandomDelay (): number { - const client = this - - const { - maxReconnectionDelay, - minReconnectionDelay, - reconnectionDelayGrowFactor - } = client.options - - const minDelay = minReconnectionDelay * reconnectionDelayGrowFactor ** client.failedConnectionAttempts - const maxDelay = minDelay * reconnectionDelayGrowFactor - - return Math.min(maxReconnectionDelay, Math.round(minDelay + Math.random() * (maxDelay - minDelay))) - }, - - // Schedules a connection attempt to happen after a delay computed according to - // a randomized exponential backoff algorithm variant. - scheduleConnectionAttempt () { - const client = this - - if (!client.shouldReconnect) { - throw new Error('Cannot call `scheduleConnectionAttempt()` when `shouldReconnect` is false.') - } - if (client.nextConnectionAttemptDelayID) { - return console.warn('[pubsub] A reconnection attempt is already scheduled.') - } - const delay = client.getNextRandomDelay() - const nth = client.failedConnectionAttempts + 1 - - client.nextConnectionAttemptDelayID = setTimeout(() => { - sbp('okTurtles.events/emit', PUBSUB_RECONNECTION_ATTEMPT, client) - client.nextConnectionAttemptDelayID = undefined - client.connect() - }, delay) - sbp('okTurtles.events/emit', PUBSUB_RECONNECTION_SCHEDULED, client, { delay, nth }) - }, - - // Unused for now. - pub (contractID: string, data: JSONType, dontBroadcast = false) { - }, - - /** - * Sends a SUB request to the server as soon as possible. - * - * - The given contract ID will be cached until we get a relevant server - * response, allowing us to resend the same request if necessary. - * - Any identical UNSUB request that has not been sent yet will be cancelled. - * - Calling this method again before the server has responded has no effect. - * @param contractID - The ID of the contract whose updates we want to subscribe to. - */ - sub (contractID: string, dontBroadcast = false) { - const client = this - const { socket } = this - - if (!client.pendingSubscriptionSet.has(contractID)) { - client.pendingSubscriptionSet.add(contractID) - client.pendingUnsubscriptionSet.delete(contractID) - - if (socket?.readyState === WebSocket.OPEN) { - socket.send(createRequest(REQUEST_TYPE.SUB, { contractID }, dontBroadcast)) - } - } - }, - - /** - * Sends an UNSUB request to the server as soon as possible. - * - * - The given contract ID will be cached until we get a relevant server - * response, allowing us to resend the same request if necessary. - * - Any identical SUB request that has not been sent yet will be cancelled. - * - Calling this method again before the server has responded has no effect. - * @param contractID - The ID of the contract whose updates we want to unsubscribe from. - */ - unsub (contractID: string, dontBroadcast = false) { - const client = this - const { socket } = this - - if (!client.pendingUnsubscriptionSet.has(contractID)) { - client.pendingSubscriptionSet.delete(contractID) - client.pendingUnsubscriptionSet.add(contractID) - - if (socket?.readyState === WebSocket.OPEN) { - socket.send(createRequest(REQUEST_TYPE.UNSUB, { contractID }, dontBroadcast)) - } - } - } -} - // Register custom SBP event listeners before the first connection. for (const name of Object.keys(defaultClientEventHandlers)) { if (name === 'error' || !socketEventNames.includes(name)) { - sbp('okTurtles.events/on', `pubsub-${name}`, (target, detail?: Object) => { - target.listeners[name]({ type: name, target, detail }) + sbp('okTurtles.events/on', `pubsub-${name}`, (target: PubsubClient, detail: unknown) => { + target.listeners[name](({ type: name, target, detail } as unknown) as Event) }) } } diff --git a/shared/string.js b/shared/string.ts similarity index 100% rename from shared/string.js rename to shared/string.ts diff --git a/shared/types.flow.js b/shared/types.flow.js new file mode 100644 index 0000000000..8beae8a6bb --- /dev/null +++ b/shared/types.flow.js @@ -0,0 +1,3 @@ +export type JSONType = string | number | boolean | null | JSONObject | JSONArray +export interface JSONObject { [key: string]: JSONType } +export type JSONArray = Array diff --git a/shared/types.js b/shared/types.ts similarity index 87% rename from shared/types.js rename to shared/types.ts index 891431fb41..a211a80ee7 100644 --- a/shared/types.js +++ b/shared/types.ts @@ -8,9 +8,9 @@ // https://flowtype.org/docs/modules.html#import-type // https://flowtype.org/docs/advanced-configuration.html -export type JSONType = string | number | boolean | null | JSONObject | JSONArray; -export type JSONObject = { [key:string]: JSONType }; -export type JSONArray = Array; +export type JSONType = string | number | boolean | null | JSONObject | JSONArray +export type JSONObject = { [key: string]: JSONType } +export type JSONArray = Array export type ResType = | ResTypeErr | ResTypeOK | ResTypeAlready @@ -27,7 +27,7 @@ export type ResTypeEntry = 'entry' // https://github.com/facebook/flow/issues/3041 export type Response = { // export interface Response { - type: ResType; - err?: string; + type: ResType + err?: string data?: JSONType } diff --git a/test/avatar-caching.test.js b/test/avatar-caching.test.js deleted file mode 100644 index 6b70ae4694..0000000000 --- a/test/avatar-caching.test.js +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-env mocha */ - -const assert = require('assert') -const { copyFile } = require('fs/promises') - -describe('avatar file serving', function () { - const apiURL = process.env.API_URL - const hash = '21XWnNX5exusmJoJNWNNqjhWPqxGURryWbkUhYVsGT5NFtSGKs' - - before('manually upload a test avatar to the file database', async () => { - await copyFile(`./test/data/${hash}`, `./data/${hash}`) - }) - - it('Should serve our test avatar with correct headers', async function () { - const { headers } = await fetch(`${apiURL}/file/${hash}`) - - assert.match(headers.get('cache-control'), /immutable/) - assert.doesNotMatch(headers.get('cache-control'), /no-cache/) - assert.equal(headers.get('content-length'), '405') - assert.equal(headers.get('content-type'), 'application/octet-stream') - assert.equal(headers.get('etag'), `"${hash}"`) - assert(headers.has('last-modified')) - assert.equal(headers.get('x-frame-options'), 'deny') - }) -}) diff --git a/test/avatar-caching.test.ts b/test/avatar-caching.test.ts new file mode 100644 index 0000000000..d84ed8ecbe --- /dev/null +++ b/test/avatar-caching.test.ts @@ -0,0 +1,33 @@ +import { + assert, + assertEquals, + assertMatch, + assertNotMatch +} from 'asserts' + +import '~/scripts/process-shim.ts' + +Deno.test({ + name: 'Avatar file serving', + fn: async function (tests) { + const apiURL = Deno.env.get('API_URL') ?? 'http://localhost:8000' + const hash = '21XWnNX5exusmJoJNWNNqjhWPqxGURryWbkUhYVsGT5NFtSGKs' + + // Manually upload a test avatar to the file database. + await Deno.copyFile(`./test/data/${hash}`, `./data/${hash}`) + + await tests.step('Should serve our test avatar with correct headers', async function () { + const { headers } = await fetch(`${apiURL}/file/${hash}`) + + assertMatch(headers.get('cache-control') ?? '', /immutable/) + assertNotMatch(headers.get('cache-control') ?? '', /no-cache/) + assertEquals(headers.get('content-length'), '405') + assertEquals(headers.get('content-type'), 'application/octet-stream') + assertEquals(headers.get('etag'), `"${hash}"`) + assert(headers.has('last-modified')) + assertEquals(headers.get('x-frame-options'), 'deny') + }) + }, + sanitizeResources: false, + sanitizeOps: false +}) diff --git a/test/backend.test.js b/test/backend.test.js deleted file mode 100644 index 75237c4b24..0000000000 --- a/test/backend.test.js +++ /dev/null @@ -1,364 +0,0 @@ -/* eslint-env mocha */ - -import sbp from '@sbp/sbp' -import '@sbp/okturtles.events' -import '@sbp/okturtles.eventqueue' -import '~/shared/domains/chelonia/chelonia.js' -import { handleFetchResult } from '~/frontend/controller/utils/misc.js' -import { blake32Hash } from '~/shared/functions.js' -import * as Common from '@common/common.js' -import proposals from '~/frontend/model/contracts/shared/voting/proposals.js' -import { PAYMENT_PENDING, PAYMENT_TYPE_MANUAL } from '~/frontend/model/contracts/shared/payments/index.js' -import { INVITE_INITIAL_CREATOR, INVITE_EXPIRES_IN_DAYS, PROPOSAL_INVITE_MEMBER, PROPOSAL_REMOVE_MEMBER, PROPOSAL_GROUP_SETTING_CHANGE, PROPOSAL_PROPOSAL_SETTING_CHANGE, PROPOSAL_GENERIC } from '~/frontend/model/contracts/shared/constants.js' -import { createInvite } from '~/frontend/model/contracts/shared/functions.js' -import '~/frontend/controller/namespace.js' -import chalk from 'chalk' -import { THEME_LIGHT } from '~/frontend/model/settings/themes.js' -import manifests from '~/frontend/model/contracts/manifests.json' - -// Necessary since we are going to use a WebSocket pubsub client in the backend. -global.WebSocket = require('ws') -const should = require('should') // eslint-disable-line - -// Remove this when dropping support for Node versions lower than v18. -const Blob = require('buffer').Blob -const fs = require('fs') -const path = require('path') -// const { PassThrough, Readable } = require('stream') - -chalk.level = 2 // for some reason it's not detecting that terminal supports colors -const { bold } = chalk - -// var unsignedMsg = sign(personas[0], 'futz') - -// TODO: replay attacks? (need server-provided challenge for `msg`?) -// nah, this should be taken care of by TLS. However, for message -// passing we should be using a forward-secure protocol. See -// MessageRelay in interface.js. - -// TODO: the request for members of a group should be made with a group -// key or a group signature. There should not be a mapping of a -// member's key to all the groups that they're in (that's unweildy -// and compromises privacy). - -const vuexState = { - currentGroupId: null, - currentChatRoomIDs: {}, - contracts: {}, // contractIDs => { type:string, HEAD:string } (for contracts we've successfully subscribed to) - pending: [], // contractIDs we've just published but haven't received back yet - loggedIn: false, // false | { username: string, identityContractID: string } - theme: THEME_LIGHT, - fontSize: 1, - increasedContrast: false, - namespaceLookups: Object.create(null), - reducedMotion: false, - appLogsFilter: ['error', 'info', 'warn'] -} - -// this is to ensure compatibility between frontend and test/backend.test.js -sbp('okTurtles.data/set', 'API_URL', process.env.API_URL) -sbp('sbp/selectors/register', { - // for handling the loggedIn metadata() in Contracts.js - 'state/vuex/state': () => { - return vuexState - } -}) - -sbp('sbp/selectors/register', { - 'backend.tests/postEntry': async function (entry) { - console.log(bold.yellow('sending entry with hash:'), entry.hash()) - const res = await sbp('chelonia/private/out/publishEvent', entry) - should(res).equal(entry.hash()) - return res - } -}) - -// uncomment this to help with debugging: -// sbp('sbp/filters/global/add', (domain, selector, data) => { -// console.log(`[sbp] ${selector}:`, data) -// }) - -describe('Full walkthrough', function () { - const users = {} - const groups = {} - - it('Should configure chelonia', async function () { - await sbp('chelonia/configure', { - connectionURL: process.env.API_URL, - stateSelector: 'state/vuex/state', - skipSideEffects: true, - connectionOptions: { - reconnectOnDisconnection: false, - reconnectOnOnline: false, - reconnectOnTimeout: false, - timeout: 3000 - }, - contracts: { - ...manifests, - defaults: { - modules: { '@common/common.js': Common }, - allowedSelectors: [ - 'state/vuex/state', 'state/vuex/commit', 'state/vuex/getters', - 'chelonia/contract/sync', 'chelonia/contract/remove', 'controller/router', - 'chelonia/queueInvocation', 'gi.actions/identity/updateLoginStateUponLogin', - 'gi.actions/chatroom/leave', 'gi.notifications/emit' - ], - allowedDomains: ['okTurtles.data', 'okTurtles.events', 'okTurtles.eventQueue'], - preferSlim: true - } - } - }) - }) - - function login (user) { - // we set this so that the metadata on subsequent messages is properly filled in - // currently group and mailbox contracts use this to determine message sender - vuexState.loggedIn = { - username: user.decryptedValue().data.attributes.username, - identityContractID: user.contractID() - } - } - - async function createIdentity (username, email, testFn) { - // append random id to username to prevent conflict across runs - // when GI_PERSIST environment variable is defined - username = `${username}-${Math.floor(Math.random() * 1000)}` - const msg = await sbp('chelonia/out/registerContract', { - contractName: 'gi.contracts/identity', - data: { - // authorizations: [Events.CanModifyAuths.dummyAuth(name)], - attributes: { username, email } - }, - hooks: { - prepublish: (message) => { message.decryptedValue(JSON.parse) }, - postpublish: (message) => { testFn && testFn(message) } - } - }) - return msg - } - function createGroup (name: string, hooks: Object = {}): Promise { - const initialInvite = createInvite({ - quantity: 60, - creator: INVITE_INITIAL_CREATOR, - expires: INVITE_EXPIRES_IN_DAYS.ON_BOARDING - }) - return sbp('chelonia/out/registerContract', { - contractName: 'gi.contracts/group', - data: { - invites: { - [initialInvite.inviteSecret]: initialInvite - }, - settings: { - // authorizations: [Events.CanModifyAuths.dummyAuth(name)], - groupName: name, - groupPicture: '', - sharedValues: 'our values', - mincomeAmount: 1000, - mincomeCurrency: 'USD', - distributionDate: new Date().toISOString(), - minimizeDistribution: true, - proposals: { - [PROPOSAL_GROUP_SETTING_CHANGE]: proposals[PROPOSAL_GROUP_SETTING_CHANGE].defaults, - [PROPOSAL_INVITE_MEMBER]: proposals[PROPOSAL_INVITE_MEMBER].defaults, - [PROPOSAL_REMOVE_MEMBER]: proposals[PROPOSAL_REMOVE_MEMBER].defaults, - [PROPOSAL_PROPOSAL_SETTING_CHANGE]: proposals[PROPOSAL_PROPOSAL_SETTING_CHANGE].defaults, - [PROPOSAL_GENERIC]: proposals[PROPOSAL_GENERIC].defaults - } - } - }, - hooks - }) - } - function createPaymentTo (to, amount, contractID, currency = 'USD'): Promise { - return sbp('chelonia/out/actionEncrypted', { - action: 'gi.contracts/group/payment', - data: { - toUser: to.decryptedValue().data.attributes.username, - amount: amount, - currency: currency, - txid: String(parseInt(Math.random() * 10000000)), - status: PAYMENT_PENDING, - paymentType: PAYMENT_TYPE_MANUAL - }, - contractID - }) - } - - async function createMailboxFor (user) { - const { username } = users.bob.decryptedValue().data.attributes - const mailbox = await sbp('chelonia/out/registerContract', { - contractName: 'gi.contracts/mailbox', - data: { username } - }) - await sbp('chelonia/out/actionEncrypted', { - action: 'gi.contracts/identity/setAttributes', - data: { mailbox: mailbox.contractID() }, - contractID: user.contractID() - }) - user.mailbox = mailbox - await sbp('chelonia/contract/sync', mailbox.contractID()) - return mailbox - } - - describe('Identity tests', function () { - it('Should create identity contracts for Alice and Bob', async function () { - users.bob = await createIdentity('bob', 'bob@okturtles.com') - users.alice = await createIdentity('alice', 'alice@okturtles.org') - // verify attribute creation and state initialization - users.bob.decryptedValue().data.attributes.username.should.match(/^bob/) - users.bob.decryptedValue().data.attributes.email.should.equal('bob@okturtles.com') - }) - - it('Should register Alice and Bob in the namespace', async function () { - const { alice, bob } = users - let res = await sbp('namespace/register', alice.decryptedValue().data.attributes.username, alice.contractID()) - // NOTE: don't rely on the return values for 'namespace/register' - // too much... in the future we might remove these checks - res.value.should.equal(alice.contractID()) - res = await sbp('namespace/register', bob.decryptedValue().data.attributes.username, bob.contractID()) - res.value.should.equal(bob.contractID()) - alice.socket = 'hello' - should(alice.socket).equal('hello') - }) - - it('Should verify namespace lookups work', async function () { - const { alice } = users - const res = await sbp('namespace/lookup', alice.decryptedValue().data.attributes.username) - res.should.equal(alice.contractID()) - const contractID = await sbp('namespace/lookup', 'susan') - should(contractID).equal(null) - }) - - it('Should open socket for Alice', async function () { - users.alice.socket = await sbp('chelonia/connect') - }) - - it('Should create mailboxes for Alice and Bob and subscribe', async function () { - // Object.values(users).forEach(async user => await createMailboxFor(user)) - await createMailboxFor(users.alice) - await createMailboxFor(users.bob) - }) - }) - - describe('Group tests', function () { - it('Should create a group & subscribe Alice', async function () { - // set user Alice as being logged in so that metadata on messages is properly set - login(users.alice) - groups.group1 = await createGroup('group1') - await sbp('chelonia/contract/sync', groups.group1.contractID()) - }) - - // NOTE: The frontend needs to use the `fetch` API instead of superagent because - // superagent doesn't support streaming, whereas fetch does. - // TODO: We should also remove superagent as a dependency since `fetch` does - // everything we need. Use fetch from now on. - it('Should get mailbox info for Bob', async function () { - // 1. look up bob's username to get his identity contract - const { bob } = users - const bobsName = bob.decryptedValue().data.attributes.username - const bobsContractId = await sbp('namespace/lookup', bobsName) - should(bobsContractId).equal(bob.contractID()) - // 2. fetch all events for his identity contract to get latest state for it - const state = await sbp('chelonia/latestContractState', bobsContractId) - console.log(bold.red('FINAL STATE:'), state) - // 3. get bob's mailbox contractID from his identity contract attributes - should(state.attributes.mailbox).equal(bob.mailbox.contractID()) - // 4. fetch the latest hash for bob's mailbox. - // we don't need latest state for it just latest hash - const res = await sbp('chelonia/out/latestHash', state.attributes.mailbox) - should(res).equal(bob.mailbox.hash()) - }) - - it('Should post an event', function () { - return createPaymentTo(users.bob, 100, groups.group1.contractID()) - }) - - it('Should sync group and verify payments in state', async function () { - await sbp('chelonia/contract/sync', groups.group1.contractID()) - should(Object.keys(vuexState[groups.group1.contractID()].payments).length).equal(1) - }) - - it('Should fail with wrong contractID', async function () { - try { - await createPaymentTo(users.bob, 100, '') - return Promise.reject(new Error("shouldn't get here!")) - } catch (e) { - return Promise.resolve() - } - }) - - // TODO: these events, as well as all messages sent over the sockets - // should all be authenticated and identified by the user's - // identity contract - }) - - describe('File upload', function () { - it('Should upload "avatar-default.png" as "multipart/form-data"', async function () { - const form = new FormData() - const filepath = './frontend/assets/images/user-avatar-default.png' - // const context = blake2bInit(32, null) - // const stream = fs.createReadStream(filepath) - // // the problem here is that we need to get the hash of the file - // // but doing so consumes the stream, invalidating it and making it - // // so that we can't simply do `form.append('data', stream)` - // // I tried creating a secondary piped stream and sending that instead, - // // however that didn't work. - // // const pass = new PassThrough() // couldn't get this or Readable to work no matter how I tried - // // So instead we save the raw buffer and send that, using a hack - // // to work around a weird bug in hapi or form-data where we have to - // // specify the filename or otherwise the backend treats the data as a string, - // // resulting in the wrong hash for some reason. By specifying `filename` the backend - // // treats it as a Buffer, and we get the correct file hash. - // // We could of course simply read the file twice, but that seems even more hackish. - // var buffer - // const hash = await new Promise((resolve, reject) => { - // stream.on('error', e => reject(e)) - // stream.on('data', chunk => { - // buffer = buffer ? Buffer.concat([buffer, chunk]) : chunk - // blake2bUpdate(context, chunk) - // }) - // stream.on('end', () => { - // const uint8array = blake2bFinal(context) - // resolve(multihash.toB58String(multihash.encode(Buffer.from(uint8array.buffer), 'blake2b-32', 32))) - // }) - // }) - // since we're just saving the buffer now, we might as well use the simpler readFileSync API - const buffer = fs.readFileSync(filepath) - const hash = blake32Hash(buffer) - console.log(`hash for ${path.basename(filepath)}: ${hash}`) - form.append('hash', hash) - form.append('data', new Blob([buffer]), path.basename(filepath)) - await fetch(`${process.env.API_URL}/file`, { method: 'POST', body: form }) - .then(handleFetchResult('text')) - .then(r => should(r).equal(`/file/${hash}`)) - }) - }) - - describe('Cleanup', function () { - it('Should destroy all opened sockets', function () { - // The code below was originally Object.values(...) but changed to .keys() - // due to a similar flow issue to https://github.com/facebook/flow/issues/2221 - Object.keys(users).forEach((userKey) => { - users[userKey].socket && users[userKey].socket.destroy() - }) - }) - }) -}) - -// Potentially useful for dealing with fetch API: -// function streamToPromise (stream, dataHandler) { -// return new Promise((resolve, reject) => { -// stream.on('data', (...args) => { -// try { dataHandler(...args) } catch (err) { reject(err) } -// }) -// stream.on('end', resolve) -// stream.on('error', reject) -// }) -// } -// see: https://github.com/bitinn/node-fetch/blob/master/test/test.js -// This used to be in the 'Should get mailbox info for Bob' test, before the -// server manually created a JSON array out of the objects being streamed. -// await streamToPromise(res.body, chunk => { -// console.log(bold.red('CHUNK:'), chunk.toString()) -// events.push(JSON.parse(chunk.toString())) -// }) diff --git a/test/backend.test.ts b/test/backend.test.ts new file mode 100644 index 0000000000..cc467ea61e --- /dev/null +++ b/test/backend.test.ts @@ -0,0 +1,421 @@ +import { assertEquals, assertMatch, unreachable } from 'asserts' +import { bold, red, yellow } from 'fmt/colors.ts' +import * as pathlib from 'path' + +import '~/scripts/process-shim.ts' + +import sbp from '@sbp/sbp' +import '@sbp/okturtles.events' +import '@sbp/okturtles.eventqueue' + +import applyPortShift from '~/scripts/applyPortShift.ts' +// eslint-disable-next-line import/no-duplicates +import '~/shared/domains/chelonia/chelonia.ts' +// eslint-disable-next-line import/no-duplicates +import { type CheloniaState, type GIMessage } from '~/shared/domains/chelonia/chelonia.ts' +import { type GIOpActionUnencrypted } from '~/shared/domains/chelonia/GIMessage.ts' +import { blake32Hash } from '~/shared/functions.ts' +import { type PubsubClient } from '~/shared/pubsub.ts' + +import * as Common from '@common/common.js' +import { + createInvite, + proposals, + INVITE_INITIAL_CREATOR, + INVITE_EXPIRES_IN_DAYS, + PAYMENT_PENDING, + PAYMENT_TYPE_MANUAL, + PROPOSAL_INVITE_MEMBER, + PROPOSAL_REMOVE_MEMBER, + PROPOSAL_GROUP_SETTING_CHANGE, + PROPOSAL_PROPOSAL_SETTING_CHANGE, + PROPOSAL_GENERIC +} from '@test-contracts/shared.js' + +import '~/frontend/controller/namespace.js' +import { handleFetchResult } from '~/frontend/controller/utils/misc.js' +import manifests from '~/frontend/model/contracts/manifests.json' assert { type: 'json' } +import { THEME_LIGHT } from '~/frontend/model/settings/themes.js' + +import packageJSON from '~/package.json' assert { type: 'json' } + +type TestUser = GIMessage & { mailbox: GIMessage; socket: PubsubClient } + +declare const process: { + env: Record +} + +const { version } = packageJSON + +// var unsignedMsg = sign(personas[0], 'futz') + +// TODO: replay attacks? (need server-provided challenge for `msg`?) +// nah, this should be taken care of by TLS. However, for message +// passing we should be using a forward-secure protocol. See +// MessageRelay in interface.js. + +// TODO: the request for members of a group should be made with a group +// key or a group signature. There should not be a mapping of a +// member's key to all the groups that they're in (that's unweildy +// and compromises privacy). + +Object.assign(process.env, applyPortShift(process.env)) + +Deno.env.set('GI_VERSION', `${version}@${new Date().toISOString()}`) + +const GI_VERSION = Deno.env.get('GI_VERSION') +const NODE_ENV = Deno.env.get('NODE_ENV') ?? 'development' + +console.info('GI_VERSION:', GI_VERSION) +console.info('NODE_ENV:', NODE_ENV) + +const vuexState: CheloniaState = { + currentGroupId: null, + currentChatRoomIDs: {}, + contracts: {}, // contractIDs => { type:string, HEAD:string } (for contracts we've successfully subscribed to) + pending: [], // contractIDs we've just published but haven't received back yet + loggedIn: false, // false | { username: string, identityContractID: string } + theme: THEME_LIGHT, + fontSize: 1, + increasedContrast: false, + namespaceLookups: Object.create(null), + reducedMotion: false, + appLogsFilter: ['error', 'info', 'warn'] +} + +// this is to ensure compatibility between frontend and test/backend.test.js +sbp('okTurtles.data/set', 'API_URL', process.env.API_URL) + +sbp('sbp/selectors/register', { + // for handling the loggedIn metadata() in Contracts.js + 'state/vuex/state': () => { + return vuexState + } +}) + +sbp('sbp/selectors/register', { + 'backend.tests/postEntry': async function (entry: GIMessage) { + console.log(bold(yellow('sending entry with hash:')), entry.hash()) + const res = await sbp('chelonia/private/out/publishEvent', entry) + assertEquals(res, entry.hash()) + return res + } +}) + +// uncomment this to help with debugging: +// sbp('sbp/filters/global/add', (domain, selector, data) => { +// console.log(`[sbp] ${selector}:`, data) +// }) + +Deno.test({ + name: 'Full walkthrough', + fn: async function (tests) { + const users: Record = {} + const groups: Record = {} + + // Wait for the server to be ready. + const t0 = Date.now() + const timeout = 30000 + await new Promise((resolve, reject) => { + (function ping () { + fetch(process.env.API_URL).then(resolve).catch(() => { + if (Date.now() > t0 + timeout) { + reject(new Error('Test setup timed out.')) + } else { + setTimeout(ping, 100) + } + }) + })() + }) + + await tests.step('Should configure chelonia', async function () { + await sbp('chelonia/configure', { + connectionURL: process.env.API_URL, + stateSelector: 'state/vuex/state', + skipSideEffects: true, + connectionOptions: { + reconnectOnDisconnection: false, + reconnectOnOnline: false, + reconnectOnTimeout: false, + timeout: 3000 + }, + contracts: { + ...manifests, + defaults: { + modules: { '@common/common.js': Common }, + allowedSelectors: [ + 'state/vuex/state', 'state/vuex/commit', 'state/vuex/getters', + 'chelonia/contract/sync', 'chelonia/contract/remove', 'controller/router', + 'chelonia/queueInvocation', 'gi.actions/identity/updateLoginStateUponLogin', + 'gi.actions/chatroom/leave', 'gi.notifications/emit' + ], + allowedDomains: ['okTurtles.data', 'okTurtles.events', 'okTurtles.eventQueue'], + preferSlim: true + } + } + }) + }) + + function login (user: GIMessage) { + // we set this so that the metadata on subsequent messages is properly filled in + // currently group and mailbox contracts use this to determine message sender + vuexState.loggedIn = { + username: decryptedValue(user).data.attributes.username, + identityContractID: user.contractID() + } + } + + async function createIdentity (username: string, email: string, testFn?: ((msg: GIMessage) => boolean)) { + // append random id to username to prevent conflict across runs + // when GI_PERSIST environment variable is defined + username = `${username}-${Math.floor(Math.random() * 1000)}` + const msg = await sbp('chelonia/out/registerContract', { + contractName: 'gi.contracts/identity', + data: { + // authorizations: [Events.CanModifyAuths.dummyAuth(name)], + attributes: { username, email } + }, + hooks: { + prepublish: (message: GIMessage) => { message.decryptedValue(JSON.parse) }, + postpublish: (message: GIMessage) => { testFn && testFn(message) } + } + }) + return msg + } + function createGroup (name: string, hooks: Record = {}): Promise { + const initialInvite = createInvite({ + quantity: 60, + creator: INVITE_INITIAL_CREATOR, + expires: INVITE_EXPIRES_IN_DAYS.ON_BOARDING, + invitee: undefined + }) + return sbp('chelonia/out/registerContract', { + contractName: 'gi.contracts/group', + data: { + invites: { + [initialInvite.inviteSecret]: initialInvite + }, + settings: { + // authorizations: [Events.CanModifyAuths.dummyAuth(name)], + groupName: name, + groupPicture: '', + sharedValues: 'our values', + mincomeAmount: 1000, + mincomeCurrency: 'USD', + distributionDate: new Date().toISOString(), + minimizeDistribution: true, + proposals: { + [PROPOSAL_GROUP_SETTING_CHANGE]: proposals[PROPOSAL_GROUP_SETTING_CHANGE].defaults, + [PROPOSAL_INVITE_MEMBER]: proposals[PROPOSAL_INVITE_MEMBER].defaults, + [PROPOSAL_REMOVE_MEMBER]: proposals[PROPOSAL_REMOVE_MEMBER].defaults, + [PROPOSAL_PROPOSAL_SETTING_CHANGE]: proposals[PROPOSAL_PROPOSAL_SETTING_CHANGE].defaults, + [PROPOSAL_GENERIC]: proposals[PROPOSAL_GENERIC].defaults + } + } + }, + hooks + }) + } + function createPaymentTo (to: GIMessage, amount: number, contractID: string, currency = 'USD'): Promise { + return sbp('chelonia/out/actionEncrypted', { + action: 'gi.contracts/group/payment', + data: { + toUser: decryptedValue(to).data.attributes.username, + amount: amount, + currency: currency, + txid: String(Math.floor(Math.random() * 10000000)), + status: PAYMENT_PENDING, + paymentType: PAYMENT_TYPE_MANUAL + }, + contractID + }) + } + + async function createMailboxFor (user: TestUser): Promise { + const { username } = decryptedValue(users.bob).data.attributes + const mailbox = await sbp('chelonia/out/registerContract', { + contractName: 'gi.contracts/mailbox', + data: { username } + }) + await sbp('chelonia/out/actionEncrypted', { + action: 'gi.contracts/identity/setAttributes', + data: { mailbox: mailbox.contractID() }, + contractID: user.contractID() + }) + user.mailbox = mailbox + await sbp('chelonia/contract/sync', mailbox.contractID()) + return mailbox + } + + function decryptedValue (msg: GIMessage): GIOpActionUnencrypted { + return msg.decryptedValue() as GIOpActionUnencrypted + } + + await tests.step('Identity tests', async function (t) { + await t.step('Should create identity contracts for Alice and Bob', async function () { + users.bob = await createIdentity('bob', 'bob@okturtles.com') + users.alice = await createIdentity('alice', 'alice@okturtles.org') + // verify attribute creation and state initialization + assertMatch(decryptedValue(users.bob).data.attributes.username, /^bob/) + assertEquals(decryptedValue(users.bob).data.attributes.email, 'bob@okturtles.com') + }) + + await t.step('Should register Alice and Bob in the namespace', async function () { + const { alice, bob } = users + let res = await sbp('namespace/register', decryptedValue(alice).data.attributes.username, alice.contractID()) + // NOTE: don't rely on the return values for 'namespace/register' + // too much... in the future we might remove these checks + assertEquals(res.value, alice.contractID()) + res = await sbp('namespace/register', decryptedValue(bob).data.attributes.username, bob.contractID()) + assertEquals(res.value, bob.contractID()) + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'PubsubClient'. + alice.socket = 'hello' + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'PubsubClient'. + assertEquals(alice.socket, 'hello') + }) + + await t.step('Should verify namespace lookups work', async function () { + const { alice } = users + const username = decryptedValue(alice).data.attributes.username + const res = await sbp('namespace/lookup', username) + assertEquals(res, alice.contractID()) + const contractID = await sbp('namespace/lookup', 'susan') + assertEquals(contractID, null) + }) + + await t.step('Should open socket for Alice', async function () { + users.alice.socket = await sbp('chelonia/connect') + }) + + await t.step('Should create mailboxes for Alice and Bob and subscribe', async function () { + await createMailboxFor(users.alice) + await createMailboxFor(users.bob) + }) + }) + + await tests.step('Group tests', async function (t) { + await t.step('Should create a group & subscribe Alice', async function () { + // Set user Alice as being logged in so that metadata on messages is properly set. + login(users.alice) + groups.group1 = await createGroup('group1') + await sbp('chelonia/contract/sync', groups.group1.contractID()) + }) + + // NOTE: The frontend needs to use the `fetch` API instead of superagent because + // superagent doesn't support streaming, whereas fetch does. + // TODO: We should also remove superagent as a dependency since `fetch` does + // everything we need. Use fetch from now on. + await t.step('Should get mailbox info for Bob', async function () { + // 1. look up bob's username to get his identity contract + const { bob } = users + const bobsName = decryptedValue(bob).data.attributes.username + const bobsContractId = await sbp('namespace/lookup', bobsName) + assertEquals(bobsContractId, bob.contractID()) + // 2. fetch all events for his identity contract to get latest state for it + const state = await sbp('chelonia/latestContractState', bobsContractId) + console.log(bold(red('FINAL STATE:')), state) + // 3. get bob's mailbox contractID from his identity contract attributes + assertEquals(state.attributes.mailbox, bob.mailbox.contractID()) + // 4. fetch the latest hash for bob's mailbox. + // we don't need latest state for it just latest hash + const res = await sbp('chelonia/out/latestHash', state.attributes.mailbox) + assertEquals(res, bob.mailbox.hash()) + }) + + await t.step('Should post an event', async function () { + await createPaymentTo(users.bob, 100, groups.group1.contractID()) + }) + + await t.step('Should sync group and verify payments in state', async function () { + await sbp('chelonia/contract/sync', groups.group1.contractID()) + // @ts-expect-error TS2571 [ERROR]: Object is of type 'unknown'. + assertEquals(Object.keys(vuexState[groups.group1.contractID()].payments).length, 1) + }) + + await t.step('Should fail with wrong contractID', async function () { + try { + await createPaymentTo(users.bob, 100, '') + unreachable() + } catch { + } + }) + + // TODO: these events, as well as all messages sent over the sockets + // should all be authenticated and identified by the user's + // identity contract + }) + + await tests.step('File upload', async function (t) { + await t.step('Should upload "avatar-default.png" as "multipart/form-data"', async function () { + const form = new FormData() + const filepath = './frontend/assets/images/user-avatar-default.png' + // const context = blake2bInit(32, null) + // const stream = fs.createReadStream(filepath) + // // the problem here is that we need to get the hash of the file + // // but doing so consumes the stream, invalidating it and making it + // // so that we can't simply do `form.append('data', stream)` + // // I tried creating a secondary piped stream and sending that instead, + // // however that didn't work. + // // const pass = new PassThrough() // couldn't get this or Readable to work no matter how I tried + // // So instead we save the raw buffer and send that, using a hack + // // to work around a weird bug in hapi or form-data where we have to + // // specify the filename or otherwise the backend treats the data as a string, + // // resulting in the wrong hash for some reason. By specifying `filename` the backend + // // treats it as a Buffer, and we get the correct file hash. + // // We could of course simply read the file twice, but that seems even more hackish. + // var buffer + // const hash = await new Promise((resolve, reject) => { + // stream.on('error', e => reject(e)) + // stream.on('data', chunk => { + // buffer = buffer ? Buffer.concat([buffer, chunk]) : chunk + // blake2bUpdate(context, chunk) + // }) + // stream.on('end', () => { + // const uint8array = blake2bFinal(context) + // resolve(multihash.toB58String(multihash.encode(Buffer.from(uint8array.buffer), 'blake2b-32', 32))) + // }) + // }) + // since we're just saving the buffer now, we might as well use the simpler readFileSync API + const buffer = Deno.readFileSync(filepath) + const hash = blake32Hash(buffer) + console.log(`hash for ${pathlib.basename(filepath)}: ${hash}`) + form.append('hash', hash) + const blob = new Blob([buffer]) + form.append('data', blob, pathlib.basename(filepath)) + await fetch(`${process.env.API_URL}/file`, { method: 'POST', body: form }) + .then(handleFetchResult('text')) + .then(r => assertEquals(r, `/file/${hash}`)) + }) + }) + + await tests.step('Cleanup', async function (t) { + await t.step('Should destroy all opened sockets', function () { + // The code below was originally Object.values(...) but changed to .keys() + // due to a similar flow issue to https://github.com/facebook/flow/issues/2221 + Object.keys(users).forEach((userKey) => { + users[userKey].socket && users[userKey].socket.destroy() + }) + }) + }) + }, + sanitizeResources: false, + sanitizeOps: false +}) + +// Potentially useful for dealing with fetch API: +// function streamToPromise (stream, dataHandler) { +// return new Promise((resolve, reject) => { +// stream.on('data', (...args) => { +// try { dataHandler(...args) } catch (err) { reject(err) } +// }) +// stream.on('end', resolve) +// stream.on('error', reject) +// }) +// } +// see: https://github.com/bitinn/node-fetch/blob/master/test/test.js +// This used to be in the 'Should get mailbox info for Bob' test, before the +// server manually created a JSON array out of the objects being streamed. +// await streamToPromise(res.body, chunk => { +// console.log(bold.red('CHUNK:'), chunk.toString()) +// events.push(JSON.parse(chunk.toString())) +// }) diff --git a/test/cypress/support/output-logs.js b/test/cypress/support/output-logs.js index 05fcb86f97..7dd1b222f3 100644 --- a/test/cypress/support/output-logs.js +++ b/test/cypress/support/output-logs.js @@ -3,6 +3,7 @@ // Copied directly from: https://github.com/cypress-io/cypress/issues/3199#issuecomment-466593084 // *********** +// eslint-disable-next-line @typescript-eslint/no-var-requires const APPLICATION_NAME = require('../../../package.json').name let logs = '' diff --git a/test/disallow-vhtml-directive.test.js b/test/disallow-vhtml-directive.test.js deleted file mode 100644 index c6df2ab4a1..0000000000 --- a/test/disallow-vhtml-directive.test.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict' - -const assert = require('assert') - -const PugLinter = require('pug-lint') - -const linter = new PugLinter() - -linter.configure( - { - additionalRules: ['scripts/disallow-vhtml-directive.js'], - disallowVHTMLDirective: true - } -) - -const errorCode = 'PUG:LINT_DISALLOWVHTMLDIRECTIVE' - -function outdent (str) { - const lines = str.slice(1).split('\n') - const indent = (lines[0].match(/^\s*/) || [''])[0] - - if (indent === '') { - return lines.join('\n') - } - return lines.map( - line => line.startsWith(indent) ? line.slice(indent.length) : line - ).join('\n') -} - -it('should allow usage of the `v-safe-html` directive', function () { - const validUseCase = outdent( - String.raw` - p.p-description - span.has-text-1(v-safe-html='introTitle')` - ) - - assert.equal(linter.checkString(validUseCase).length, 0, validUseCase) -}) - -it('should disallow any usage of the `v-html` directive', function () { - const invalidUseCase = outdent( - String.raw` - p.p-description - span.has-text-1(v-html='introTitle')` - ) - - const errors = linter.checkString(invalidUseCase) - assert.equal(errors.length, 1) - assert.equal(errors[0].code, errorCode) -}) diff --git a/test/disallow-vhtml-directive.test.ts b/test/disallow-vhtml-directive.test.ts new file mode 100644 index 0000000000..08be829243 --- /dev/null +++ b/test/disallow-vhtml-directive.test.ts @@ -0,0 +1,55 @@ +import { assertEquals } from 'asserts' + +import { createRequire } from 'https://deno.land/std/node/module.ts' +import PugLinter from 'pug-lint' + +// HACK for 'dynamic require is not supported' error in 'linter.configure()'. +// @ts-expect-error Element implicitly has an 'any' type +globalThis.require = createRequire(import.meta.url) + +Deno.test({ + name: 'Tests for the disallow-vhtml-directive linter rule', + fn: async function (tests) { + const errorCode = 'PUG:LINT_DISALLOWVHTMLDIRECTIVE' + const linter = new PugLinter() + + linter.configure( + { + additionalRules: ['scripts/disallow-vhtml-directive.js'], + disallowVHTMLDirective: true + } + ) + const outdent = (str: string) => { + const lines = str.slice(1).split('\n') + const indent = (lines[0].match(/^\s*/) || [''])[0] + + if (indent === '') { + return lines.join('\n') + } + return lines.map( + (line: string) => line.startsWith(indent) ? line.slice(indent.length) : line + ).join('\n') + } + + await tests.step('should allow usage of the `v-safe-html` directive', async function () { + const validUseCase = outdent( + String.raw` + p.p-description + span.has-text-1(v-safe-html='introTitle')` + ) + + assertEquals(linter.checkString(validUseCase).length, 0, validUseCase) + }) + + await tests.step('should disallow any usage of the `v-html` directive', async function () { + const invalidUseCase = outdent( + String.raw` + p.p-description + span.has-text-1(v-html='introTitle')` + ) + const errors = linter.checkString(invalidUseCase) + assertEquals(errors.length, 1) + assertEquals(errors[0].code, errorCode) + }) + } +}) diff --git a/test/validate-i18n.test.js b/test/validate-i18n.test.js deleted file mode 100644 index 9505cc90e5..0000000000 --- a/test/validate-i18n.test.js +++ /dev/null @@ -1,214 +0,0 @@ -'use strict' - -const assert = require('assert') - -const PugLinter = require('pug-lint') - -const linter = new PugLinter() - -linter.configure( - { - additionalRules: ['scripts/validate-i18n.js'], - validateI18n: true - } -) - -const errorCode = 'PUG:LINT_VALIDATEI18N' - -function outdent (str) { - const lines = str.slice(1).split('\n') - const indent = (lines[0].match(/^\s*/) || [''])[0] - - if (indent === '') { - return lines.join('\n') - } - return lines.map( - line => line.startsWith(indent) ? line.slice(indent.length) : line - ).join('\n') -} - -it('should allow valid usage of a simple string', function () { - const validUseCase = 'i18n Hello world' - - assert.equal(linter.checkString(validUseCase).length, 0, validUseCase) -}) - -it('should allow valid usage of a string with named arguments', function () { - const validUseCase = outdent( - String.raw` - i18n( - :args='{ name: ourUsername }' - ) Hello {name}!` - ) - - assert.equal(linter.checkString(validUseCase).length, 0, validUseCase) -}) - -it('should allow valid usage of a string containing HTML markup', function () { - const validUseCase = outdent( - String.raw` - i18n( - :args='{ ...LTags("strong", "span"), name: ourUsername }' - ) Hello {strong_}{name}{_strong}, today it's a {span_}nice day{_span}!` - ) - - assert.equal(linter.checkString(validUseCase).length, 0, validUseCase) -}) - -it('should allow valid usage of a string continaing Vue markup', function () { - const validUseCase = outdent( - String.raw` - i18n( - compile - :args='{ r1: \'\', r2: ""}' - ) Go to {r1}login{r2} page.` - ) - - assert.equal(linter.checkString(validUseCase).length, 0, validUseCase) -}) - -// ====== Invalid usages ====== // - -it('should report syntax errors in the `:args` attribute', function () { - let errors = [] - let invalidUseCase = '' - - // Example with a missing colon. - invalidUseCase = outdent( - String.raw` - i18n( - :args='{ name ourUsername }' - ) Hello {name}!` - ) - - errors = linter.checkString(invalidUseCase) - assert.equal(errors.length, 2) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /unexpected token/i) - - assert.equal(errors[1].code, errorCode) - assert.match(errors[1].message, /undefined named argument 'name'/i) - - // Example with a missing curly brace. - invalidUseCase = outdent( - String.raw` - i18n( - :args='{ name: ourUsername' - ) Hello {name}!` - ) - - errors = linter.checkString(invalidUseCase) - - assert.equal(errors.length, 2) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /unexpected token/i) - - assert.equal(errors[1].code, errorCode) - assert.match(errors[1].message, /undefined named argument 'name'/i) - - // Example with an extraneous semicolon. - invalidUseCase = outdent( - String.raw` - i18n( - :args='{ name: ourUsername };' - ) Hello {name}!` - ) - - errors = linter.checkString(invalidUseCase) - assert.equal(errors.length, 1) - - // Example with an invalid property key. - invalidUseCase = outdent( - String.raw` - i18n( - :args='{ 0name: ourUsername };' - ) Hello {name}!` - ) - - errors = linter.checkString(invalidUseCase) - assert(linter.checkString(invalidUseCase).length > 0, invalidUseCase) -}) - -it('should report undefined ltags', function () { - const invalidUseCase = outdent( - String.raw` - i18n( - :args='{ count: 5 }' - ) Invite {strong_}{count} members{_strong} to the party!` - ) - const errors = linter.checkString(invalidUseCase) - - assert.equal(errors.length, 2) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /undefined named argument 'strong_'/i) - assert.equal(errors[1].code, errorCode) - assert.match(errors[1].message, /undefined named argument '_strong'/i) -}) - -it('should report undefined named arguments', function () { - const invalidUseCase = outdent( - String.raw` - i18n( - :args='{ ...LTags("strong") }' - ) Invite {strong_}{count} members{_strong} to the party!` - ) - const errors = linter.checkString(invalidUseCase) - - assert.equal(errors.length, 1) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /undefined named argument 'count'/i) -}) - -it('should report unused ltags', function () { - const invalidUseCase = outdent( - String.raw` - i18n( - :args='{ ...LTags("strong") }' - ) Invite your friends to the party!` - ) - const errors = linter.checkString(invalidUseCase) - - assert.equal(errors.length, 2) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /unused named argument 'strong_'/i) - assert.equal(errors[1].code, errorCode) - assert.match(errors[1].message, /unused named argument '_strong'/i) -}) - -it('should report unused named arguments', function () { - const invalidUseCase = outdent( - String.raw` - i18n( - :args='{ age, name }' - ) Hello {name}!` - ) - const errors = linter.checkString(invalidUseCase) - - assert.equal(errors.length, 1) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /unused named argument 'age'/i) -}) - -it('should report usage of the `html` attribute', function () { - const invalidUseCase = outdent( - String.raw` - i18n( - tag='p' - html='My great text' - ) Hello` - ) - - const errors = linter.checkString(invalidUseCase) - assert.equal(errors.length, 1) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /html attribute/) -}) - -it('should report usage of double curly braces', function () { - const invalidUseCase = 'i18n Replying to {{replyingTo}}' - const errors = linter.checkString(invalidUseCase) - - assert.equal(errors.length, 1) - assert.equal(errors[0].code, errorCode) - assert.match(errors[0].message, /double curly braces/i) -}) diff --git a/test/validate-i18n.test.ts b/test/validate-i18n.test.ts new file mode 100644 index 0000000000..b710b942b9 --- /dev/null +++ b/test/validate-i18n.test.ts @@ -0,0 +1,226 @@ +import { + assert, + assertEquals, + assertMatch +} from 'asserts' + +import { createRequire } from 'https://deno.land/std/node/module.ts' +import PugLinter from 'pug-lint' + +// HACK for 'dynamic require is not supported' error in 'linter.configure()'. +// @ts-expect-error Element implicitly has an 'any' type. +globalThis.require = createRequire(import.meta.url) + +Deno.test({ + name: 'i18n tag validation', + fn: async function (tests) { + const linter = new PugLinter() + + linter.configure( + { + additionalRules: ['scripts/validate-i18n.js'], + validateI18n: true + } + ) + + const errorCode = 'PUG:LINT_VALIDATEI18N' + + function outdent (str: string) { + const lines = str.slice(1).split('\n') + const indent = (lines[0].match(/^\s*/) || [''])[0] + + if (indent === '') { + return lines.join('\n') + } + return lines.map( + line => line.startsWith(indent) ? line.slice(indent.length) : line + ).join('\n') + } + + await tests.step('should allow valid usage of a simple string', async function () { + const validUseCase = 'i18n Hello world' + + assertEquals(linter.checkString(validUseCase).length, 0, validUseCase) + }) + + await tests.step('should allow valid usage of a string with named arguments', async function () { + const validUseCase = outdent( + String.raw` + i18n( + :args='{ name: ourUsername }' + ) Hello {name}!` + ) + + assertEquals(linter.checkString(validUseCase).length, 0, validUseCase) + }) + + await tests.step('should allow valid usage of a string containing HTML markup', async function () { + const validUseCase = outdent( + String.raw` + i18n( + :args='{ ...LTags("strong", "span"), name: ourUsername }' + ) Hello {strong_}{name}{_strong}, today it's a {span_}nice day{_span}!` + ) + + assertEquals(linter.checkString(validUseCase).length, 0, validUseCase) + }) + + await tests.step('should allow valid usage of a string containing Vue markup', async function () { + const validUseCase = outdent( + String.raw` + i18n( + compile + :args='{ r1: \'\', r2: ""}' + ) Go to {r1}login{r2} page.` + ) + + assertEquals(linter.checkString(validUseCase).length, 0, validUseCase) + }) + + // ====== Invalid usages ====== // + + await tests.step('should report syntax errors in the `:args` attribute', async function () { + let errors = [] + let invalidUseCase = '' + + // Example with a missing colon. + invalidUseCase = outdent( + String.raw` + i18n( + :args='{ name ourUsername }' + ) Hello {name}!` + ) + + errors = linter.checkString(invalidUseCase) + assertEquals(errors.length, 2) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /unexpected token/i) + + assertEquals(errors[1].code, errorCode) + assertMatch(errors[1].message, /undefined named argument 'name'/i) + + // Example with a missing curly brace. + invalidUseCase = outdent( + String.raw` + i18n( + :args='{ name: ourUsername' + ) Hello {name}!` + ) + + errors = linter.checkString(invalidUseCase) + + assertEquals(errors.length, 2) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /unexpected token/i) + + assertEquals(errors[1].code, errorCode) + assertMatch(errors[1].message, /undefined named argument 'name'/i) + + // Example with an extraneous semicolon. + invalidUseCase = outdent( + String.raw` + i18n( + :args='{ name: ourUsername };' + ) Hello {name}!` + ) + + errors = linter.checkString(invalidUseCase) + assertEquals(errors.length, 1) + + // Example with an invalid property key. + invalidUseCase = outdent( + String.raw` + i18n( + :args='{ 0name: ourUsername };' + ) Hello {name}!` + ) + + errors = linter.checkString(invalidUseCase) + assert(linter.checkString(invalidUseCase).length > 0, invalidUseCase) + }) + + await tests.step('should report undefined ltags', async function () { + const invalidUseCase = outdent( + String.raw` + i18n( + :args='{ count: 5 }' + ) Invite {strong_}{count} members{_strong} to the party!` + ) + const errors = linter.checkString(invalidUseCase) + + assertEquals(errors.length, 2) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /undefined named argument 'strong_'/i) + assertEquals(errors[1].code, errorCode) + assertMatch(errors[1].message, /undefined named argument '_strong'/i) + }) + + await tests.step('should report undefined named arguments', async function () { + const invalidUseCase = outdent( + String.raw` + i18n( + :args='{ ...LTags("strong") }' + ) Invite {strong_}{count} members{_strong} to the party!` + ) + const errors = linter.checkString(invalidUseCase) + + assertEquals(errors.length, 1) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /undefined named argument 'count'/i) + }) + + await tests.step('should report unused ltags', async function () { + const invalidUseCase = outdent( + String.raw` + i18n( + :args='{ ...LTags("strong") }' + ) Invite your friends to the party!` + ) + const errors = linter.checkString(invalidUseCase) + + assertEquals(errors.length, 2) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /unused named argument 'strong_'/i) + assertEquals(errors[1].code, errorCode) + assertMatch(errors[1].message, /unused named argument '_strong'/i) + }) + + await tests.step('should report unused named arguments', async function () { + const invalidUseCase = outdent( + String.raw` + i18n( + :args='{ age, name }' + ) Hello {name}!` + ) + const errors = linter.checkString(invalidUseCase) + + assertEquals(errors.length, 1) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /unused named argument 'age'/i) + }) + + await tests.step('should report usage of the `html` attribute', async function () { + const invalidUseCase = outdent( + String.raw` + i18n( + tag='p' + html='My great text' + ) Hello` + ) + + const errors = linter.checkString(invalidUseCase) + assertEquals(errors.length, 1) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /html attribute/) + }) + + await tests.step('should report usage of double curly braces', async function () { + const invalidUseCase = 'i18n Replying to {{replyingTo}}' + const errors = linter.checkString(invalidUseCase) + + assertEquals(errors.length, 1) + assertEquals(errors[0].code, errorCode) + assertMatch(errors[0].message, /double curly braces/i) + }) + } +})