From 3f21eb7860b65106f937689b4031e1af6d25da0f Mon Sep 17 00:00:00 2001 From: William Killerud Date: Tue, 23 Jul 2024 13:55:37 +0200 Subject: [PATCH] feat: initial release --- .editorconfig | 11 +++ .github/workflows/publish.yml | 40 ++++++++++ .github/workflows/test.yml | 33 +++++++++ .gitignore | 16 ++++ .npmrc | 2 + .prettierrc.json | 13 ++++ LICENSE | 21 ++++++ README.md | 133 ++++++++++++++++++++++++++++++++++ eslint.config.js | 22 ++++++ lib/main.js | 128 ++++++++++++++++++++++++++++++++ package.json | 47 ++++++++++++ release.config.cjs | 21 ++++++ renovate.json | 6 ++ tests/main.test.js | 87 ++++++++++++++++++++++ tsconfig.json | 16 ++++ tsconfig.test.json | 9 +++ 16 files changed, 605 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 lib/main.js create mode 100644 package.json create mode 100644 release.config.cjs create mode 100644 renovate.json create mode 100644 tests/main.test.js create mode 100644 tsconfig.json create mode 100644 tsconfig.test.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3385fe0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +; http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cfeb8d6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Release and Publish + +on: + push: + branches: + - main + - alpha + - beta + - next + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Lint files + run: npm run lint + + - name: Generate type definitions + run: npm run types + + - name: Run tests + run: npm run test + + - name: Run semantic release + run: npx semantic-release + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2580cee --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Run Lint and Tests + +on: + push: + branches-ignore: + - main + - alpha + - beta + - next + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macOS-latest, windows-latest] + node-version: [18, 20] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - run: npm install + + - run: npm run lint + + - run: npm run types + + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da095c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules +package-lock.json +uploads +tmp +gcloud.json +node_modules/**/* +.DS_Store +tmp/**/* +.idea +.idea/**/* +*.iml +*.log +coverage +.vscode +.nyc_output/ +.tap/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..0ca8d2a --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +package-lock=false +save-exact=true diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..649f132 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,13 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 4, + "overrides": [ + { + "files": ["*.json", "*.yml"], + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1df593 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Eik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f808a2f --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# @eik/sink-memory + +A Sink implementation that runs completely in memory, designed for automated tests. + +## Usage + +```sh +npm install @eik/sink-memory +``` + +```js +const { pipeline } = require('stream'); +const express = require('express'); +const Sink = require('@eik/sink-memory'); + +const app = express(); +const sink = new Sink(); + +app.get('/file.js', async (req, res, next) => { + try { + const file = await sink.read('/path/to/file/file.js'); + pipeline(file.stream, res, (error) => { + if (error) return next(error); + }); + } catch (error) { + next(error); + } +}); + +app.listen(8000); +``` + +## API + +The sink instance has the following API: + +### .write(filePath, contentType) + +Method for writing a file to in-memory storage. + +This method takes the following arguments: + +- `filePath` - String - Path to the file to be stored - Required. +- `contentType` - String - The content type of the file - Required. + +Resolves with a writable stream. + +```js +const { pipeline } = require('stream); + +const fromStream = new SomeReadableStream(); +const sink = new Sink({ ... }); + +try { + const file = await sink.write('/path/to/file/file.js', 'application/javascript'); + pipeline(fromStream, file.stream, (error) => { + if (error) console.log(error); + }); +} catch (error) { + console.log(error); +} +``` + +### .read(filePath) + +Method for reading a file from storage. + +This method takes the following arguments: + +- `filePath` - String - Path to the file to be read - Required. + +Resolves with a [ReadFile][read-file] object which holds metadata about +the file and a readable stream with the byte stream of the file on the +`.stream` property. + +```js +const { pipeline } = require('stream); + +const toStream = new SomeWritableStream(); +const sink = new Sink({ ... }); + +try { + const file = await sink.read('/path/to/file/file.js'); + pipeline(file.stream, toStream, (error) => { + if (error) console.log(error); + }); +} catch (error) { + console.log(error); +} +``` + +### .delete(filePath) + +Method for deleting a file in storage. + +This method takes the following arguments: + +- `filePath` - String - Path to the file to be deleted - Required. + +Resolves if file is deleted and rejects if file could not be deleted. + +```js +const sink = new Sink({ ... }); + +try { + await sink.delete('/path/to/file/file.js'); +} catch (error) { + console.log(error); +} +``` + +### .exist(filePath) + +Method for checking if a file exist in the storage. + +This method takes the following arguments: + +- `filePath` - String - Path to the file to be checked for existence - Required. + +Resolves if file exists and rejects if file does not exist. + +```js +const sink = new Sink({ ... }); + +try { + await sink.exist('/path/to/file/file.js'); +} catch (error) { + console.log(error); +} +``` + +[eik]: https://github.com/eik-lib +[read-file]: https://github.com/eik-lib/common/blob/master/lib/classes/read-file.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..30aa733 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +import prettierConfig from 'eslint-config-prettier'; +import prettierPlugin from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + prettierConfig, + prettierPlugin, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + global: true, + }, + }, + }, + { + ignores: ['coverage/*', 'dist/*'], + }, +]; diff --git a/lib/main.js b/lib/main.js new file mode 100644 index 0000000..310bca3 --- /dev/null +++ b/lib/main.js @@ -0,0 +1,128 @@ +import { ReadFile } from '@eik/common'; +import Sink from '@eik/sink'; +import { Readable, Writable } from 'node:stream'; + +/** @type {Map} */ +let content = new Map(); +/** @type {Map} */ +let mimetypes = new Map(); + +export default class SinkMemory extends Sink { + /** + * @param {string} filePath + * @param {string} contentType + * @returns {Promise} + */ + write(filePath, contentType) { + return new Promise((resolve, reject) => { + try { + Sink.validateFilePath(filePath); + Sink.validateContentType(contentType); + } catch (error) { + reject(error); + return; + } + + if (!content.has(filePath)) { + content.set(filePath, ''); + mimetypes.set(filePath, contentType); + } + + resolve( + new Writable({ + write(chunk, encoding, callback) { + try { + content.set( + filePath, + content.get(filePath) + chunk.toString(), + ); + callback(); + } catch (e) { + callback(e); + } + }, + }), + ); + }); + } + + /** + * @param {string} filePath + * @throws {Error} if the file does not exist + * @returns {Promise} + */ + read(filePath) { + return new Promise((resolve, reject) => { + try { + Sink.validateFilePath(filePath); + } catch (error) { + reject(error); + return; + } + + if (!content.has(filePath)) { + reject(new Error(`${filePath} does not exist`)); + } + + let stream = new Readable({ + read() { + this.push(content.get(filePath)); + this.push(null); + }, + }); + + const file = new ReadFile({ + mimeType: mimetypes.get(filePath), + }); + file.stream = stream; + + resolve(file); + }); + } + + /** + * @param {string} filePath + * @throws {Error} if the file does not exist + * @returns {Promise} + */ + delete(filePath) { + return new Promise((resolve, reject) => { + try { + Sink.validateFilePath(filePath); + } catch (error) { + reject(error); + return; + } + + if (!content.has(filePath)) { + reject(new Error(`${filePath} does not exist`)); + } + + content.delete(filePath); + + resolve(); + }); + } + + /** + * @param {string} filePath + * @throws {Error} if the file does not exist + * @returns {Promise} + */ + exist(filePath) { + return new Promise((resolve, reject) => { + try { + Sink.validateFilePath(filePath); + } catch (error) { + reject(error); + return; + } + + if (!content.has(filePath)) { + reject(new Error(`${filePath} does not exist`)); + } + + resolve(); + }); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..429b8b0 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "@eik/sink-memory", + "version": "1.0.0", + "description": "In-memory sink designed for tests.", + "main": "lib/main.js", + "type": "module", + "files": [ + "CHANGELOG.md", + "package.json", + "README.md", + "lib" + ], + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "run-s test:*", + "test:unit": "tap --disable-coverage --allow-empty-coverage tests/**/*.js", + "test:types": "tsc --project tsconfig.test.json", + "types": "tsc --declaration --emitDeclarationOnly" + }, + "repository": { + "type": "git", + "url": "git@github.com:eik-lib/sink-memory.git" + }, + "author": "Trygve Lie", + "license": "MIT", + "bugs": { + "url": "https://github.com/eik-lib/sink-memory/issues" + }, + "homepage": "https://github.com/eik-lib/sink-memory#readme", + "dependencies": { + "@eik/common": "3.0.1", + "@eik/sink": "1.2.3" + }, + "devDependencies": { + "@semantic-release/changelog": "6.0.3", + "@semantic-release/git": "10.0.1", + "eslint": "9.1.1", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-prettier": "5.1.3", + "globals": "15.0.0", + "npm-run-all": "4.1.5", + "prettier": "3.3.2", + "semantic-release": "24.0.0", + "tap": "18.8.0" + } +} diff --git a/release.config.cjs b/release.config.cjs new file mode 100644 index 0000000..8329b44 --- /dev/null +++ b/release.config.cjs @@ -0,0 +1,21 @@ +module.exports = { + plugins: [ + '@semantic-release/commit-analyzer', + '@semantic-release/release-notes-generator', + '@semantic-release/changelog', + [ + '@semantic-release/npm', + { + tarballDir: 'release', + }, + ], + [ + '@semantic-release/github', + { + assets: 'release/*.tgz', + }, + ], + '@semantic-release/git', + ], + preset: 'angular', +}; diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..a5e0b9e --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>eik-lib/renovate-presets:top-level-module" + ] +} diff --git a/tests/main.test.js b/tests/main.test.js new file mode 100644 index 0000000..03332ae --- /dev/null +++ b/tests/main.test.js @@ -0,0 +1,87 @@ +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import tap from 'tap'; +import Sink from '../lib/main.js'; + +tap.test('Sink() - Object type', (t) => { + const sink = new Sink(); + const name = Object.prototype.toString.call(sink); + t.ok(name.startsWith('[object Sink'), 'should begin with Sink'); + t.end(); +}); + +tap.test('Sink() - .write()', async (t) => { + const sink = new Sink(); + const writable = await sink.write('/mem/foo/bar.txt', 'text/plain'); + const readable = Readable.from(['Hello, World!']); + t.resolves(pipeline(readable, writable)); + t.end(); +}); + +tap.test('Sink() - .read() - File exists', async (t) => { + const sink = new Sink(); + + const path = '/mem/foo/bar.txt'; + const type = 'text/plain'; + const writable = await sink.write(path, type); + const readable = Readable.from(['Hello, World!']); + t.resolves(pipeline(readable, writable)); + + const file = await sink.read(path); + t.equal(file.mimeType, type); + t.ok(file.stream); + + const chunks = []; + for await (const chunk of file.stream) { + chunks.push(Buffer.from(chunk)); + } + + t.equal(Buffer.concat(chunks).toString('utf-8'), 'Hello, World!'); + t.end(); +}); + +tap.test('Sink() - .read() - File does not exist', (t) => { + const sink = new Sink(); + t.rejects(sink.read('/does/not/exist.txt')); + t.end(); +}); + +tap.test('Sink() - .delete() - File exists', async (t) => { + const sink = new Sink(); + const path = '/mem/foo/bar.txt'; + const writable = await sink.write(path, 'text/plain'); + const readable = Readable.from(['Hello, World!']); + + t.resolves(pipeline(readable, writable)); + t.resolves(sink.delete(path)); + + t.rejects( + sink.delete(path), + 'Second call to delete should reject if file is successfully deleted', + ); + + t.end(); +}); + +tap.test('Sink() - .delete() - File does not exist', (t) => { + const sink = new Sink(); + t.rejects(sink.delete('/does/not/exist.txt')); + t.end(); +}); + +tap.test('Sink() - .exist() - File exists', async (t) => { + const sink = new Sink(); + const path = '/mem/foo/bar.txt'; + const writable = await sink.write(path, 'text/plain'); + const readable = Readable.from(['Hello, World!']); + + t.resolves(pipeline(readable, writable)); + t.resolves(sink.exist(path)); + t.end(); +}); + +tap.test('Sink() - .exist() - File does not exist', (t) => { + const sink = new Sink(); + t.rejects(sink.exist('/does/not/exist.txt')); + t.end(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..574a3cf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["es2020", "DOM"], + "module": "nodenext", + "target": "es2020", + "resolveJsonModule": true, + "checkJs": true, + "allowJs": true, + "moduleResolution": "nodenext", + "declaration": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "outDir": "types" + }, + "include": ["./lib/**/*.js"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..801ba2e --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["./tests/**/*.js"], + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true + } +}