├── examples ├── .gitignore ├── basic │ ├── tsconfig.json │ ├── index.html │ ├── client.ts │ ├── package.json │ ├── readme.md │ └── server.ts └── bare │ ├── package.json │ ├── index.html │ ├── readme.md │ └── example.ts ├── pnpm-workspace.yaml ├── src ├── shared.d.ts ├── browser.d.ts ├── node.d.ts ├── browser.types.ts ├── node.types.ts ├── index.types.ts ├── node.ts └── browser.ts ├── test ├── node.test.ts ├── browser.test.ts ├── _polyfill.ts ├── suites │ ├── index.ts │ ├── binary.ts │ ├── api.ts │ ├── headers.ts │ ├── use-cases.ts │ ├── body.ts │ ├── chunking.ts │ └── boundary.ts └── mocks.ts ├── bundt.config.ts ├── bench ├── package.json ├── pnpm-lock.yaml └── index.ts ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── license ├── index.d.ts ├── package.json ├── logo.svg ├── readme.md └── pnpm-lock.yaml /examples/.gitignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - esbuild 3 | -------------------------------------------------------------------------------- /src/shared.d.ts: -------------------------------------------------------------------------------- 1 | export type Arrayable = T | ReadonlyArray; 2 | -------------------------------------------------------------------------------- /test/node.test.ts: -------------------------------------------------------------------------------- 1 | import { meros } from '../src/node'; 2 | import { mockResponseNode } from './mocks'; 3 | 4 | import suites from './suites'; 5 | 6 | suites(meros, mockResponseNode); 7 | -------------------------------------------------------------------------------- /bundt.config.ts: -------------------------------------------------------------------------------- 1 | import { define } from 'bundt/config'; 2 | 3 | export default define((input) => { 4 | // ignore "index.ts" build attempt 5 | if (input.export === '.') return false; 6 | }); 7 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@marais/tsconfig", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "lib": ["DOM", "ESNext"] 6 | }, 7 | "files": ["*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /test/browser.test.ts: -------------------------------------------------------------------------------- 1 | import { meros } from '../src/browser'; 2 | import { mockResponseBrowser } from './mocks'; 3 | 4 | import suites from './suites'; 5 | 6 | suites(meros, mockResponseBrowser); 7 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "dependencies": { 5 | "@marais/bench": "0.0.8", 6 | "fetch-multipart-graphql": "3.2.1", 7 | "it-multipart": "1.0.9" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *-lock.json 4 | *.lock 5 | *.log 6 | 7 | /coverage 8 | /.nyc_output 9 | 10 | # Editors 11 | *.iml 12 | /.idea 13 | /.vscode 14 | 15 | # Code 16 | /browser 17 | /node 18 | /types 19 | /lib 20 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/_polyfill.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | // Mocks for Node@10 4 | const { TextDecoder, TextEncoder } = require('util'); 5 | 6 | global['TextDecoder'] = global['TextDecoder'] || TextDecoder; 7 | global['TextEncoder'] = global['TextEncoder'] || TextEncoder; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 4 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,yaml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@marais/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "meros": ["index.d.ts"], 7 | "meros/*": ["src/*.d.ts"] 8 | } 9 | }, 10 | "include": ["index.d.ts", "src", "test"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /examples/bare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meros-example-bare", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "tsm example.ts" 7 | }, 8 | "dependencies": { 9 | "meros": "workspace:*", 10 | "piecemeal": "^0.1.0" 11 | }, 12 | "devDependencies": { 13 | "tsm": "2.3.0" 14 | }, 15 | "engines": { 16 | "node": ">=16.11" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/bare/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | -------------------------------------------------------------------------------- /examples/basic/client.ts: -------------------------------------------------------------------------------- 1 | import { meros } from 'meros'; 2 | 3 | async function run() { 4 | const app = document.querySelector('#app'); 5 | 6 | const parts = await fetch('/data').then((r) => 7 | meros<{ letter: string }>(r), 8 | ); 9 | 10 | for await (let part of parts) { 11 | const el = document.createElement('div'); 12 | el.innerText = part.body.letter; 13 | app.appendChild(el); 14 | } 15 | } 16 | 17 | if (document.readyState !== 'complete') run(); 18 | else window.addEventListener('load', run); 19 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meros-example-basic", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "tsm server.ts" 7 | }, 8 | "dependencies": { 9 | "meros": "workspace:*", 10 | "piecemeal": "^0.1.0", 11 | "polka": "1.0.0-next.22" 12 | }, 13 | "devDependencies": { 14 | "@marais/tsconfig": "0.0.4", 15 | "tsm": "2.3.0", 16 | "typescript": "5.0.4", 17 | "vite": "4.3.7" 18 | }, 19 | "engines": { 20 | "node": ">=16.11" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/bare/readme.md: -------------------------------------------------------------------------------- 1 | # Example: Bare 2 | 3 | This example demonstrates how to use `meros` reading a multipart response generated with nothing but node. 4 | 5 | ## Getting started 6 | 7 | _Setup_ 8 | 9 | ```sh 10 | npm install 11 | ``` 12 | 13 | _Start_ 14 | 15 | ```sh 16 | npm start 17 | ``` 18 | 19 | Running the above command will spin up a node server 20 | 21 | - [_localhost:8080_](http://localhost:8080/) — will run a very simple client-runtime ~ [see index.html](index.html) 22 | - [_localhost:8080/data_](http://localhost:8080/data) — responds with our multipart response ~ 23 | [see example.ts](example.ts) 24 | -------------------------------------------------------------------------------- /test/suites/index.ts: -------------------------------------------------------------------------------- 1 | import { type Meros, type Responder } from '../mocks'; 2 | 3 | import { default as API } from './api'; 4 | import { default as Boundary } from './boundary'; 5 | import { default as Chunking } from './chunking'; 6 | import { default as Headers } from './headers'; 7 | import { default as Body } from './body'; 8 | import { default as Binary } from './binary'; 9 | import { default as UseCases } from './use-cases'; 10 | 11 | export default (meros: Meros, responder: Responder) => { 12 | API(meros, responder); 13 | Boundary(meros, responder); 14 | 15 | Chunking(meros, responder); 16 | 17 | Headers(meros, responder); 18 | Body(meros, responder); 19 | 20 | Binary(meros, responder); 21 | 22 | UseCases(meros, responder); 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: {} 7 | 8 | jobs: 9 | test: 10 | name: Node.js v${{ matrix.node }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node: [20, 22, 24] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: (env) setup pnpm 19 | uses: pnpm/action-setup@v4 20 | with: 21 | version: 10.13.1 22 | 23 | - name: (env) setup node v${{ matrix.node }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node }} 27 | cache: pnpm 28 | 29 | - run: pnpm install 30 | - run: pnpm run build 31 | - run: pnpm run test 32 | - run: pnpm run typecheck 33 | -------------------------------------------------------------------------------- /examples/basic/readme.md: -------------------------------------------------------------------------------- 1 | # Example: Basic 2 | 3 | This example demonstrates how to use `meros` reading a multipart response generated by a 4 | [`polka`](https://github.com/lukeed/polka) server. 5 | 6 | But really works with any server, put simply; this example shows how `meros` can be used reading a multipart response. 7 | 8 | ## Getting started 9 | 10 | _Setup_ 11 | 12 | ```sh 13 | npm install 14 | ``` 15 | 16 | _Start_ 17 | 18 | ```sh 19 | npm start 20 | ``` 21 | 22 | Running the above command will spin up a [`vite`](https://vitejs.dev/) client-side bundler wrapped up in a 23 | [`polka`](https://github.com/lukeed/polka) server. 24 | 25 | - [_localhost:8080_](http://localhost:8080/) — will run a very simple client-runtime ~ [see client.ts](client.ts) 26 | - [_localhost:8080/data_](http://localhost:8080/data) — responds with our multipart response 27 | -------------------------------------------------------------------------------- /examples/basic/server.ts: -------------------------------------------------------------------------------- 1 | import polka from 'polka'; 2 | import { createServer as viteServer } from 'vite'; 3 | 4 | import * as Piecemeal from 'piecemeal/node'; 5 | 6 | const vite_app = await viteServer({ 7 | resolve: { 8 | mainFields: ['browser', 'main'], 9 | }, 10 | server: { 11 | middlewareMode: 'html', 12 | }, 13 | }); 14 | 15 | // ~ Server 16 | 17 | const app = polka({ 18 | onError(e, req, res) { 19 | console.log(e); 20 | res.end(); 21 | }, 22 | }); 23 | 24 | async function* alphabet() { 25 | for (let letter = 65; letter <= 90; letter++) { 26 | await new Promise((resolve) => setTimeout(resolve, 150)); 27 | yield { letter: String.fromCharCode(letter) }; 28 | } 29 | } 30 | 31 | app.add('GET', '/data', async (_req, res) => { 32 | const stream = Piecemeal.stream(alphabet()); 33 | 34 | await stream.pipe(res); 35 | }); 36 | 37 | app.use(vite_app.middlewares.handle.bind(vite_app.middlewares)); 38 | 39 | app.listen(8080, (e) => { 40 | if (e) throw e; 41 | console.log('Ready 🕺'); 42 | }); 43 | -------------------------------------------------------------------------------- /src/browser.d.ts: -------------------------------------------------------------------------------- 1 | import type { Options, Part } from 'meros'; 2 | 3 | export { Options, Part } from 'meros'; 4 | 5 | /** 6 | * Yield immediately for every part made available on the response. If the `content-type` of the 7 | * response isn't a multipart body, then we'll resolve with {@link Response}. 8 | * 9 | * @example 10 | * 11 | * ```js 12 | * const parts = await fetch('/fetch-multipart') 13 | * .then(meros); 14 | * 15 | * for await (const part of parts) { 16 | * // do something with this part 17 | * } 18 | * ``` 19 | */ 20 | export function meros( 21 | response: Response, 22 | options: { multiple: true }, 23 | ): Promise>>>; 24 | export function meros( 25 | response: Response, 26 | options?: { multiple: false }, 27 | ): Promise>>; 28 | export function meros( 29 | response: Response, 30 | options?: Options, 31 | ): Promise>>; 32 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Marais Rossouw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/node.d.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'node:http'; 2 | import type { Options, Part } from 'meros'; 3 | 4 | export { Options, Part } from 'meros'; 5 | 6 | /** 7 | * Yield immediately for every part made available on the response. If the `content-type` of the 8 | * response isn't a multipart body, then we'll resolve with {@link IncomingMessage}. 9 | * 10 | * @example 11 | * 12 | * ```js 13 | * const response = await new Promise((resolve) => { 14 | * const request = http.get(`http://my-domain/mock-ep`, (response) => { 15 | * resolve(response); 16 | * }); 17 | * request.end(); 18 | * }); 19 | * 20 | * const parts = await meros(response); 21 | * 22 | * for await (const part of parts) { 23 | * // do something with this part 24 | * } 25 | * ``` 26 | */ 27 | export function meros( 28 | response: IncomingMessage, 29 | options: { multiple: true }, 30 | ): Promise>>>; 31 | export function meros( 32 | response: IncomingMessage, 33 | options?: { multiple: false }, 34 | ): Promise>>; 35 | export function meros( 36 | response: IncomingMessage, 37 | options?: Options, 38 | ): Promise>>; 39 | -------------------------------------------------------------------------------- /examples/bare/example.ts: -------------------------------------------------------------------------------- 1 | import type { ServerResponse } from 'node:http'; 2 | 3 | import { readFile } from 'node:fs/promises'; 4 | import { createServer } from 'node:http'; 5 | 6 | import * as Piecemeal from 'piecemeal/node'; 7 | 8 | const index_doc = await readFile('./index.html', 'utf8'); 9 | 10 | const not_found = (res: ServerResponse) => { 11 | res.statusCode = 404; 12 | res.end('Not found'); 13 | }; 14 | 15 | // ~> The HTML document 16 | const serve_index = (res: ServerResponse) => { 17 | res.setHeader('content-type', 'text/html'); 18 | res.end(index_doc); 19 | }; 20 | 21 | async function* alphabet() { 22 | for (let letter = 65; letter <= 90; letter++) { 23 | await new Promise((resolve) => setTimeout(resolve, 150)); 24 | yield { letter: String.fromCharCode(letter) }; 25 | } 26 | } 27 | 28 | // ~> The multipart responder 29 | const serve_data = async (res: ServerResponse) => { 30 | const stream = Piecemeal.stream(alphabet()); 31 | 32 | await stream.pipe(res); 33 | }; 34 | 35 | createServer((req, res) => { 36 | if (req.method !== 'GET') return void not_found(res); 37 | 38 | if (req.url === '/') return void serve_index(res); 39 | if (req.url === '/data') return void serve_data(res); 40 | 41 | not_found(res); 42 | }).listen(8080, (e) => { 43 | if (e) throw e; 44 | console.log('Ready 🕺'); 45 | }); 46 | -------------------------------------------------------------------------------- /test/suites/binary.ts: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import { 4 | bodies, 5 | makePart, 6 | preamble, 7 | tail, 8 | wrap, 9 | test_helper, 10 | type Meros, 11 | type Responder, 12 | } from '../mocks'; 13 | 14 | export default (meros: Meros, responder: Responder) => { 15 | const make_test = test_helper.bind(0, meros, responder); 16 | 17 | const Binary = suite('binary'); 18 | 19 | // 1x1 transparent png 20 | const blob = Buffer.from([ 21 | 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 22 | 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00, 23 | 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 24 | 0x00, 0x02, 0x01, 0x44, 0x00, 0x3b, 25 | ]); 26 | 27 | Binary('works', async () => { 28 | const collection = await make_test((push) => { 29 | push([ 30 | preamble, 31 | wrap, 32 | makePart(new TextDecoder('utf8').decode(blob), [ 33 | 'content-type: image/gif', 34 | ]), 35 | tail, 36 | ]); 37 | }); 38 | 39 | const values = bodies(collection); 40 | assert.is(values.length, 1); 41 | 42 | const img = Buffer.from(values[0]); 43 | assert.equal(img.toString(), blob.toString()); 44 | 45 | // No clue, but bitwise they are different, but functionally equiv. 46 | //assert.ok(img.equals(blob)); 47 | }); 48 | 49 | Binary.run(); 50 | }; 51 | -------------------------------------------------------------------------------- /src/browser.types.ts: -------------------------------------------------------------------------------- 1 | import { type Part } from 'meros'; 2 | import { meros } from 'meros/browser'; 3 | 4 | declare function assert(thing: T): void; 5 | 6 | declare const response: Response; 7 | 8 | type Result = { name: string }; 9 | type ThePart = Part; 10 | type Unwrapped = Promise>>; 11 | 12 | assert>(meros(response)); 13 | 14 | { 15 | const result = await meros(response); 16 | if (!(result instanceof Response)) { 17 | for await (let item of result) { 18 | assert>(item); 19 | 20 | if (item.json) assert(item.body); 21 | else assert(item.body); 22 | 23 | // @ts-expect-error 24 | assert<{ foo: string }>(item.body); 25 | 26 | assert(item.json); 27 | assert>(item.headers); 28 | } 29 | } 30 | } 31 | 32 | { 33 | const result = await meros(response, { multiple: true }); 34 | if (!(result instanceof Response)) { 35 | for await (let parts of result) { 36 | assert[]>(parts); 37 | 38 | for (let item of parts) { 39 | assert>(item); 40 | 41 | if (item.json) assert(item.body); 42 | else assert(item.body); 43 | 44 | // @ts-expect-error 45 | assert<{ foo: string }>(item.body); 46 | 47 | assert(item.json); 48 | assert>(item.headers); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/node.types.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import type { Part } from 'meros'; 3 | import { meros } from 'meros/node'; 4 | 5 | declare function assert(thing: T): void; 6 | 7 | declare const response: IncomingMessage; 8 | 9 | type Result = { name: string }; 10 | type ThePart = Part; 11 | type Unwrapped = Promise>>; 12 | 13 | assert>(meros(response)); 14 | 15 | { 16 | const result = await meros(response); 17 | if (!(result instanceof IncomingMessage)) { 18 | for await (let item of result) { 19 | assert>(item); 20 | 21 | if (item.json) assert(item.body); 22 | else assert(item.body); 23 | 24 | // @ts-expect-error 25 | assert<{ foo: string }>(item.body); 26 | 27 | assert(item.json); 28 | assert>(item.headers); 29 | } 30 | } 31 | } 32 | 33 | { 34 | const result = await meros(response, { multiple: true }); 35 | if (!(result instanceof IncomingMessage)) { 36 | for await (let parts of result) { 37 | assert[]>(parts); 38 | 39 | for (let item of parts) { 40 | assert>(item); 41 | 42 | if (item.json) assert(item.body); 43 | else assert(item.body); 44 | 45 | // @ts-expect-error 46 | assert<{ foo: string }>(item.body); 47 | 48 | assert(item.json); 49 | assert>(item.headers); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/suites/api.ts: -------------------------------------------------------------------------------- 1 | import { makePushPullAsyncIterableIterator } from '@n1ru4l/push-pull-async-iterable-iterator'; 2 | import { suite } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | import { 5 | makePart, 6 | preamble, 7 | tail, 8 | wrap, 9 | type Meros, 10 | type Responder, 11 | } from '../mocks'; 12 | 13 | export default (meros: Meros, responder: Responder) => { 14 | const API = suite('api'); 15 | 16 | API('should export a function', () => { 17 | assert.type(meros, 'function'); 18 | }); 19 | 20 | API('should resolve to an AsyncGenerator', async () => { 21 | const { asyncIterableIterator } = makePushPullAsyncIterableIterator(); 22 | const response = await responder(asyncIterableIterator, '-'); 23 | const parts = await meros(response); 24 | 25 | assert.type(parts[Symbol.asyncIterator], 'function'); 26 | }); 27 | 28 | API('should cleanup when `returns` fires', async () => { 29 | const { asyncIterableIterator, pushValue } = 30 | makePushPullAsyncIterableIterator(); 31 | const response = await responder(asyncIterableIterator, '-'); 32 | const parts = await meros(response); 33 | 34 | pushValue([preamble, wrap, makePart('test'), wrap]); 35 | 36 | let r = await parts.next(); 37 | assert.equal(r.done, false); 38 | assert.equal(String(r.value.body), 'test'); 39 | 40 | await asyncIterableIterator.return(); 41 | 42 | pushValue([makePart('test'), tail]); 43 | 44 | r = await parts.next(); 45 | assert.equal(r.done, true); 46 | assert.equal(r.value, undefined); 47 | }); 48 | 49 | API.run(); 50 | }; 51 | -------------------------------------------------------------------------------- /src/index.types.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import type { Part } from 'meros'; 3 | import { meros } from 'meros'; 4 | 5 | declare function assert(thing: T): void; 6 | 7 | type Wrapped = Promise>>; 8 | 9 | type Result = { name: string }; 10 | 11 | declare const responseNode: IncomingMessage; 12 | assert>(meros(responseNode)); 13 | 14 | { 15 | type P = Part; 16 | const result = await meros(responseNode); 17 | if (!(result instanceof IncomingMessage)) { 18 | for await (let item of result) { 19 | assert

(item); 20 | 21 | if (item.json) assert(item.body); 22 | else assert(item.body); 23 | 24 | // @ts-expect-error 25 | assert<{ foo: string }>(item.body); 26 | 27 | assert(item.json); 28 | assert>(item.headers); 29 | } 30 | } 31 | } 32 | 33 | declare const responseBrowser: Response; 34 | assert>(meros(responseBrowser)); 35 | 36 | { 37 | type P = Part; 38 | const result = await meros(responseBrowser); 39 | if (!(result instanceof Response)) { 40 | for await (let item of result) { 41 | assert

(item); 42 | 43 | if (item.json) assert(item.body); 44 | else assert(item.body); 45 | 46 | // @ts-expect-error 47 | assert<{ foo: string }>(item.body); 48 | 49 | assert(item.json); 50 | assert>(item.headers); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | /** 3 | * Setting this to true will yield an array. In other words; instead of yielding once for every payload—we collect 4 | * all complete payloads for a chunk and then yield. 5 | * 6 | * @default false 7 | */ 8 | multiple: boolean; 9 | } 10 | 11 | export type Part = 12 | | { json: false; headers: Record; body: Fallback } 13 | | { json: true; headers: Record; body: Body }; 14 | 15 | // TODO: is there a way to compose the `meros/{node,browser}` here without having to duplicate the entire signature? And maintain jsdocs 16 | 17 | // -- NODE 18 | 19 | import type { IncomingMessage } from 'node:http'; 20 | 21 | export function meros( 22 | response: IncomingMessage, 23 | options: { multiple: true }, 24 | ): Promise>>>; 25 | export function meros( 26 | response: IncomingMessage, 27 | options?: { multiple: false }, 28 | ): Promise>>; 29 | export function meros( 30 | response: IncomingMessage, 31 | options?: Options, 32 | ): Promise>>; 33 | 34 | // -- BROWSER 35 | 36 | export function meros( 37 | response: Response, 38 | options: { multiple: true }, 39 | ): Promise>>>; 40 | export function meros( 41 | response: Response, 42 | options?: { multiple: false }, 43 | ): Promise>>; 44 | export function meros( 45 | response: Response, 46 | options?: Options, 47 | ): Promise>>; 48 | -------------------------------------------------------------------------------- /bench/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | '@marais/bench': 9 | specifier: 0.0.8 10 | version: 0.0.8 11 | fetch-multipart-graphql: 12 | specifier: 3.2.1 13 | version: 3.2.1 14 | it-multipart: 15 | specifier: 1.0.9 16 | version: 1.0.9 17 | 18 | packages: 19 | 20 | /@marais/bench@0.0.8: 21 | resolution: {integrity: sha512-DZ3TqK1eUN77E2nV2Aj2aady61rveJGqGAPNQglzoJL001HJq9elFLRS6pw25e6geIEmRld9cJ5F/EHFUYa+DQ==} 22 | dependencies: 23 | '@thi.ng/bench': 3.2.11 24 | dev: false 25 | 26 | /@thi.ng/api@8.8.1: 27 | resolution: {integrity: sha512-ugTtl3dvOuRsLAF9hZcd/ULBXDG0cAacEQ26jRY00JEEwdy24WR1DOO4iL2mHei0vm2HyvfJ8IlJoRR7mSSqUA==} 28 | engines: {node: '>=12.7'} 29 | dev: false 30 | 31 | /@thi.ng/bench@3.2.11: 32 | resolution: {integrity: sha512-1SSCfwbIXJ9KUIjAaLZEmKSC5OCQf5hDpmzLXlhDFr1SlOaBYXgtu+k8GrSRqmu2kezTV0u23nfW7LSJ5YX2ZA==} 33 | engines: {node: '>=12.7'} 34 | dependencies: 35 | '@thi.ng/api': 8.8.1 36 | dev: false 37 | 38 | /base64-js@1.5.1: 39 | resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 40 | dev: false 41 | 42 | /buffer-indexof@1.1.1: 43 | resolution: {integrity: sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==} 44 | dev: false 45 | 46 | /buffer@6.0.3: 47 | resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} 48 | dependencies: 49 | base64-js: 1.5.1 50 | ieee754: 1.2.1 51 | dev: false 52 | 53 | /fetch-multipart-graphql@3.2.1: 54 | resolution: {integrity: sha512-uNbr6ysfn3GmR7s6LzkeACpYfxdLvCHSDn9DHSZNHpn6jkDH9mWa8rT4jfqXNzua5PG1z5DpDnZjJzIH462Kew==} 55 | dev: false 56 | 57 | /ieee754@1.2.1: 58 | resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 59 | dev: false 60 | 61 | /it-multipart@1.0.9: 62 | resolution: {integrity: sha512-EGavbE/ohpP3DESwmjRSz6U3iBtgj2yVgCvqF3EkFO93WxndDg0vDnA2zeSbgyglIINXE93Kvk5Vl8ub6es5Jw==} 63 | dependencies: 64 | buffer: 6.0.3 65 | buffer-indexof: 1.1.1 66 | parse-headers: 2.0.5 67 | dev: false 68 | 69 | /parse-headers@2.0.5: 70 | resolution: {integrity: sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA==} 71 | dev: false 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meros", 3 | "version": "1.3.2", 4 | "description": "A fast 642B utility that makes reading multipart responses simple", 5 | "keywords": [ 6 | "defer", 7 | "fetch", 8 | "graphql", 9 | "multipart mixed", 10 | "multipart", 11 | "reader", 12 | "stream", 13 | "utility" 14 | ], 15 | "repository": "maraisr/meros", 16 | "license": "MIT", 17 | "author": "Marais Rossouw (https://marais.io)", 18 | "sideEffects": false, 19 | "exports": { 20 | ".": { 21 | "browser": { 22 | "types": "./browser/index.d.ts", 23 | "import": "./browser/index.mjs", 24 | "require": "./browser/index.js" 25 | }, 26 | "node": { 27 | "types": "./node/index.d.ts", 28 | "import": "./node/index.mjs", 29 | "require": "./node/index.js" 30 | }, 31 | "default": { 32 | "types": "./node/index.d.ts", 33 | "import": "./node/index.mjs", 34 | "require": "./node/index.js" 35 | } 36 | }, 37 | "./browser": { 38 | "types": "./browser/index.d.ts", 39 | "import": "./browser/index.mjs", 40 | "require": "./browser/index.js" 41 | }, 42 | "./node": { 43 | "types": "./node/index.d.ts", 44 | "import": "./node/index.mjs", 45 | "require": "./node/index.js" 46 | }, 47 | "./package.json": "./package.json" 48 | }, 49 | "main": "node/index.js", 50 | "module": "node/index.mjs", 51 | "browser": "browser/index.mjs", 52 | "types": "index.d.ts", 53 | "files": [ 54 | "*.d.ts", 55 | "browser", 56 | "node" 57 | ], 58 | "scripts": { 59 | "bench": "tsm -r ./test/_polyfill.ts bench/index.ts", 60 | "build": "bundt --minify", 61 | "format": "prettier --write --list-different \"{*,{src,examples,test}/**/*,.github/**/*}.{ts,tsx,json,yml,md}\"", 62 | "prepublishOnly": "pnpm run build", 63 | "test": "uvu test \".test.ts$\" -r tsm -r test/_polyfill.ts -i suites", 64 | "typecheck": "tsc --noEmit" 65 | }, 66 | "prettier": "@marais/prettier", 67 | "devDependencies": { 68 | "@marais/prettier": "0.0.4", 69 | "@marais/tsconfig": "0.0.4", 70 | "@n1ru4l/push-pull-async-iterable-iterator": "3.2.0", 71 | "@types/node": "24.0.15", 72 | "bundt": "2.0.0-next.5", 73 | "prettier": "3.6.2", 74 | "tsm": "2.3.0", 75 | "typescript": "5.8.3", 76 | "uvu": "0.5.4" 77 | }, 78 | "peerDependencies": { 79 | "@types/node": ">=13" 80 | }, 81 | "peerDependenciesMeta": { 82 | "@types/node": { 83 | "optional": true 84 | } 85 | }, 86 | "engines": { 87 | "node": ">=13" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/suites/headers.ts: -------------------------------------------------------------------------------- 1 | import { makePushPullAsyncIterableIterator } from '@n1ru4l/push-pull-async-iterable-iterator'; 2 | import { suite } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | import { 5 | makePart, 6 | preamble, 7 | splitString, 8 | wrap, 9 | tail, 10 | type Meros, 11 | type Responder, 12 | } from '../mocks'; 13 | 14 | export default (meros: Meros, responder: Responder) => { 15 | const Headers = suite('headers'); 16 | 17 | Headers('smoke', async () => { 18 | const { asyncIterableIterator, pushValue } = 19 | makePushPullAsyncIterableIterator(); 20 | const response = await responder(asyncIterableIterator, '-'); 21 | const parts = await meros(response); 22 | 23 | const collection = []; 24 | 25 | pushValue([ 26 | preamble, 27 | wrap, 28 | makePart( 29 | { 30 | foo: 'bar', 31 | }, 32 | [ 33 | 'cache-control: public,max-age=30', 34 | 'etag: test', 35 | 'x-test: test:test', // tests the colon 36 | 'x-test-2: test: test', // tests the colon 37 | 'x-valid: _ :;.,/"\'?!(){}[]@<>=-+*#$&`|~^%', 38 | ], 39 | ), 40 | tail, 41 | ]); 42 | 43 | for await (let { headers } of parts) { 44 | collection.push(headers); 45 | } 46 | 47 | assert.equal(collection, [ 48 | { 49 | 'content-type': 'application/json; charset=utf-8', 50 | 'cache-control': 'public,max-age=30', 51 | etag: 'test', 52 | 'x-test': 'test:test', 53 | 'x-test-2': 'test: test', 54 | 'x-valid': '_ :;.,/"\'?!(){}[]@<>=-+*#$&`|~^%', 55 | }, 56 | ]); 57 | }); 58 | 59 | Headers('crossing chunks', async () => { 60 | const { asyncIterableIterator, pushValue } = 61 | makePushPullAsyncIterableIterator(); 62 | const response = await responder(asyncIterableIterator, '-'); 63 | const parts = await meros(response); 64 | 65 | const collection = []; 66 | 67 | pushValue([ 68 | wrap, 69 | ...splitString( 70 | makePart( 71 | { 72 | foo: 'bar', 73 | }, 74 | [ 75 | 'cache-control: public,max-age=30', 76 | 'etag: test', 77 | 'x-test: test:test', // tests the colon 78 | 'x-test-2: test: test', // tests the colon 79 | 'x-valid: _ :;.,/"\'?!(){}[]@<>=-+*#$&`|~^%', 80 | ], 81 | ), 82 | 11, 83 | ), 84 | tail, 85 | ]); 86 | 87 | for await (let { headers } of parts) { 88 | collection.push(headers); 89 | } 90 | 91 | assert.equal(collection, [ 92 | { 93 | 'content-type': 'application/json; charset=utf-8', 94 | 'cache-control': 'public,max-age=30', 95 | etag: 'test', 96 | 'x-test': 'test:test', 97 | 'x-test-2': 'test: test', 98 | 'x-valid': '_ :;.,/"\'?!(){}[]@<>=-+*#$&`|~^%', 99 | }, 100 | ]); 101 | }); 102 | 103 | Headers('no headers', async () => { 104 | const { asyncIterableIterator, pushValue } = 105 | makePushPullAsyncIterableIterator(); 106 | const response = await responder(asyncIterableIterator, '-'); 107 | const parts = await meros(response); 108 | 109 | const collection = []; 110 | 111 | pushValue([preamble, wrap, makePart('part', false), tail]); 112 | 113 | for await (let { headers } of parts) { 114 | collection.push(headers); 115 | } 116 | 117 | assert.equal(collection, [{}]); 118 | }); 119 | 120 | Headers.run(); 121 | }; 122 | -------------------------------------------------------------------------------- /test/suites/use-cases.ts: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import { 4 | type Meros, 5 | type Responder, 6 | bodies, 7 | makePart, 8 | preamble, 9 | splitString, 10 | tail, 11 | test_helper, 12 | wrap, 13 | } from '../mocks'; 14 | 15 | export default (meros: Meros, responder: Responder) => { 16 | const make_test = test_helper.bind(0, meros, responder); 17 | 18 | const UseCase = suite('use-cases'); 19 | 20 | UseCase('graphql defer query', async () => { 21 | const collection = await make_test((push) => { 22 | push([ 23 | preamble, 24 | wrap, 25 | makePart({ 26 | data: { 27 | user: { 28 | id: 'VXNlcgpkN2FhNzFjMjctN2I0Yy00MzczLTkwZGItMzhjMjZlNjA4MzNh', 29 | }, 30 | }, 31 | hasNext: true, 32 | }), 33 | wrap, 34 | ]); 35 | 36 | for (let chunk of splitString( 37 | makePart({ 38 | label: 'WelcomeQuery$defer$ProjectList_projects_1qwc77', 39 | path: ['user'], 40 | data: { 41 | id: 'VXNlcgpkN2FhNzFjMjctN2I0Yy00MzczLTkwZGItMzhjMjZlNjA4MzNh', 42 | projects: { 43 | edges: [ 44 | { 45 | node: { 46 | id: 'UHJvamVjdAppMQ==', 47 | name: 'New project', 48 | desc: '', 49 | lastUpdate: 50 | '2021-12-22T12:57:45.488\u002B03:00', 51 | __typename: 'Project', 52 | }, 53 | cursor: 'MA==', 54 | }, 55 | ], 56 | pageInfo: { endCursor: 'MA==', hasNextPage: false }, 57 | }, 58 | }, 59 | hasNext: false, 60 | }), 61 | 11, 62 | )) { 63 | push([chunk]); 64 | } 65 | 66 | push([tail]); 67 | }); 68 | 69 | assert.equal(bodies(collection), [ 70 | { 71 | data: { 72 | user: { 73 | id: 'VXNlcgpkN2FhNzFjMjctN2I0Yy00MzczLTkwZGItMzhjMjZlNjA4MzNh', 74 | }, 75 | }, 76 | hasNext: true, 77 | }, 78 | { 79 | label: 'WelcomeQuery$defer$ProjectList_projects_1qwc77', 80 | path: ['user'], 81 | data: { 82 | id: 'VXNlcgpkN2FhNzFjMjctN2I0Yy00MzczLTkwZGItMzhjMjZlNjA4MzNh', 83 | projects: { 84 | edges: [ 85 | { 86 | node: { 87 | id: 'UHJvamVjdAppMQ==', 88 | name: 'New project', 89 | desc: '', 90 | lastUpdate: 91 | '2021-12-22T12:57:45.488\u002B03:00', 92 | __typename: 'Project', 93 | }, 94 | cursor: 'MA==', 95 | }, 96 | ], 97 | pageInfo: { endCursor: 'MA==', hasNextPage: false }, 98 | }, 99 | }, 100 | hasNext: false, 101 | }, 102 | ]); 103 | }); 104 | 105 | UseCase('handles utf-8', async () => { 106 | const stream = (async function* () { 107 | const smiley = Buffer.from('🤔'); 108 | yield Buffer.from('\r\n---\r\n\r\n'); 109 | yield smiley.subarray(0, 2); 110 | yield smiley.subarray(2); 111 | yield Buffer.from('\r\n-----\r\n'); 112 | })(); 113 | 114 | const response = await responder(stream, '-'); 115 | 116 | const chunks = await meros(response); 117 | const collection = []; 118 | 119 | for await (const chunk of chunks) { 120 | // TODO: Node yields a buffer here 121 | collection.push(String(chunk.body)); 122 | } 123 | 124 | assert.equal(collection, ['🤔']); 125 | }); 126 | 127 | UseCase.run(); 128 | }; 129 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 5 | 13 | 14 | 23 | 34 | 45 | 56 | 67 | 78 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from 'node:http'; 2 | import type { Readable } from 'node:stream'; 3 | 4 | import type { Options, Part } from 'meros'; 5 | import type { Arrayable } from './shared'; 6 | 7 | async function* generate( 8 | stream: Readable, 9 | boundary: string, 10 | options?: Options, 11 | ): AsyncGenerator>> { 12 | let is_eager = !options || !options.multiple; 13 | 14 | let len_boundary = Buffer.byteLength(boundary); 15 | let buffer = Buffer.alloc(0); 16 | let payloads = []; 17 | let idx_boundary; 18 | let in_main; 19 | let tmp; 20 | 21 | outer: for await (let chunk of stream) { 22 | idx_boundary = buffer.byteLength; 23 | buffer = Buffer.concat([buffer, chunk]); 24 | 25 | let idx_chunk = (chunk as Buffer).indexOf(boundary); 26 | // if the chunk has a boundary, simply use it 27 | !!~idx_chunk 28 | ? (idx_boundary += idx_chunk) 29 | : // if not lets search for it in our current buffer 30 | (idx_boundary = buffer.indexOf(boundary)); 31 | 32 | payloads = []; 33 | while (!!~idx_boundary) { 34 | let current = buffer.subarray(0, idx_boundary); 35 | let next = buffer.subarray(idx_boundary + len_boundary); 36 | 37 | if (!in_main) { 38 | boundary = '\r\n' + boundary; 39 | in_main = len_boundary += 2; 40 | } else { 41 | let idx_headers = current.indexOf('\r\n\r\n') + 4; // 4 -> '\r\n\r\n'.length 42 | let last_idx = current.lastIndexOf('\r\n', idx_headers); 43 | 44 | let is_json = false; 45 | let body: T | Buffer = current.subarray( 46 | idx_headers, 47 | last_idx > -1 ? undefined : last_idx, 48 | ); 49 | 50 | let arr_headers = String(current.subarray(0, idx_headers)) 51 | .trim() 52 | .split('\r\n'); 53 | 54 | let headers: Record = {}; 55 | let len = arr_headers.length; 56 | for ( 57 | ; 58 | (tmp = arr_headers[--len]); 59 | tmp = tmp.split(': '), 60 | headers[tmp.shift()!.toLowerCase()] = tmp.join(': ') 61 | ); 62 | 63 | tmp = headers['content-type']; 64 | if (tmp && !!~tmp.indexOf('application/json')) { 65 | try { 66 | body = JSON.parse(String(body)) as T; 67 | is_json = true; 68 | } catch (_) {} 69 | } 70 | 71 | tmp = { headers, body, json: is_json } as Part; 72 | is_eager ? yield tmp : payloads.push(tmp); 73 | 74 | // hit a tail boundary, break 75 | if (next[0] === 45 && next[1] === 45) break outer; 76 | } 77 | 78 | buffer = next; 79 | idx_boundary = buffer.indexOf(boundary); 80 | } 81 | 82 | if (payloads.length) yield payloads; 83 | } 84 | 85 | if (payloads.length) yield payloads; 86 | } 87 | 88 | export async function meros( 89 | response: IncomingMessage, 90 | options?: Options, 91 | ) { 92 | let ctype = response.headers['content-type']; 93 | if (!ctype || !~ctype.indexOf('multipart/')) return response; 94 | 95 | let idx_boundary = ctype.indexOf('boundary='); 96 | let boundary = '-'; 97 | if (!!~idx_boundary) { 98 | let idx_boundary_len = idx_boundary + 9; // +9 for 'boundary='.length 99 | let eo_boundary = ctype.indexOf(';', idx_boundary_len); // strip any parameter 100 | 101 | boundary = ctype 102 | .slice(idx_boundary_len, eo_boundary > -1 ? eo_boundary : undefined) 103 | .trim() 104 | .replace(/"/g, ''); 105 | } 106 | 107 | return generate(response, `--${boundary}`, options); 108 | } 109 | -------------------------------------------------------------------------------- /test/suites/body.ts: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import { 4 | bodies, 5 | makePart, 6 | preamble, 7 | tail, 8 | wrap, 9 | test_helper, 10 | type Meros, 11 | type Responder, 12 | } from '../mocks'; 13 | 14 | export default (meros: Meros, responder: Responder) => { 15 | const make_test = test_helper.bind(0, meros, responder); 16 | 17 | const Body = suite('body'); 18 | 19 | Body('json', async () => { 20 | const collection = await make_test((push) => { 21 | push([preamble, wrap, makePart({ foo: 'bar' }), tail]); 22 | }); 23 | 24 | assert.equal(collection, [ 25 | { 26 | body: { foo: 'bar' }, 27 | json: true, 28 | headers: { 29 | 'content-type': 'application/json; charset=utf-8', 30 | }, 31 | }, 32 | ]); 33 | }); 34 | 35 | Body('plain-text', async () => { 36 | const collection = await make_test((push) => { 37 | push([preamble, wrap, makePart('test'), tail]); 38 | }); 39 | 40 | assert.equal(collection.length, 1); 41 | assert.equal(collection[0].json, false); 42 | assert.equal(String(collection[0].body), 'test'); 43 | }); 44 | 45 | Body('mixed', async () => { 46 | const collection = await make_test((push) => { 47 | push([ 48 | preamble, 49 | wrap, 50 | makePart({ foo: 'bar' }), 51 | wrap, 52 | makePart('bar: baz'), 53 | tail, 54 | ]); 55 | }); 56 | 57 | assert.equal(collection.length, 2); 58 | 59 | assert.equal(collection[0].json, true); 60 | assert.equal(collection[0].body, { foo: 'bar' }); 61 | assert.equal(collection[1].json, false); 62 | assert.equal(String(collection[1].body), 'bar: baz'); 63 | }); 64 | 65 | Body('unicode body', async () => { 66 | const collection = await make_test((push) => { 67 | push([preamble, wrap, makePart('🚀')]); 68 | 69 | push([wrap, makePart('😎'), tail]); 70 | }); 71 | 72 | assert.equal(bodies(collection), ['🚀', '😎']); 73 | }); 74 | 75 | Body('retain newlines', async () => { 76 | const collection = await make_test((push) => { 77 | push([ 78 | preamble, 79 | wrap, 80 | makePart(`foo 81 | 82 | bar 83 | 84 | `), 85 | ]); 86 | 87 | push([wrap, makePart('bar: baz\n'), tail]); 88 | }); 89 | 90 | assert.equal(bodies(collection), [ 91 | `foo 92 | 93 | bar 94 | 95 | `, 96 | `bar: baz 97 | `, 98 | ]); 99 | }); 100 | 101 | // Doesnt mean --- can exist nakedly, multipart rules still apply. 102 | Body('boundary in payload*', async () => { 103 | const collection = await make_test((push) => { 104 | push([preamble, wrap, makePart({ test: '---' }), tail]); 105 | }); 106 | 107 | assert.equal(bodies(collection), [{ test: '---' }]); 108 | }); 109 | 110 | Body('boundary exist in multi payload*', async () => { 111 | const collection = await make_test((push) => { 112 | push([ 113 | preamble, 114 | wrap, 115 | makePart({ one: '---' }), 116 | wrap, 117 | makePart({ two: '---' }), 118 | tail, 119 | ]); 120 | }); 121 | 122 | assert.equal(bodies(collection), [{ one: '---' }, { two: '---' }]); 123 | }); 124 | 125 | Body('boundary exist in multi payloads*', async () => { 126 | const collection = await make_test((push) => { 127 | push([preamble, wrap, makePart({ one: '---' })]); 128 | 129 | push([wrap, makePart({ two: '---' }), tail]); 130 | }); 131 | 132 | assert.equal(bodies(collection), [{ one: '---' }, { two: '---' }]); 133 | }); 134 | 135 | Body.run(); 136 | }; 137 | -------------------------------------------------------------------------------- /test/suites/chunking.ts: -------------------------------------------------------------------------------- 1 | import { makePushPullAsyncIterableIterator } from '@n1ru4l/push-pull-async-iterable-iterator'; 2 | 3 | import { randomBytes } from 'crypto'; 4 | import { suite } from 'uvu'; 5 | import * as assert from 'uvu/assert'; 6 | import { 7 | bodies, 8 | makePart, 9 | preamble, 10 | splitString, 11 | tail, 12 | wrap, 13 | test_helper, 14 | type Meros, 15 | type Responder, 16 | } from '../mocks'; 17 | 18 | export default (meros: Meros, responder: Responder) => { 19 | const make_test = test_helper.bind(0, meros, responder); 20 | 21 | const Chunk = suite('chunking'); 22 | 23 | Chunk('single yield single chunk', async () => { 24 | const collection = await make_test((push) => { 25 | push([preamble, wrap, makePart({ foo: 'bar' }), tail]); 26 | }); 27 | 28 | assert.equal(bodies(collection), [{ foo: 'bar' }]); 29 | }); 30 | 31 | Chunk('single yield multi chunk', async () => { 32 | const collection = await make_test((push) => { 33 | push([preamble, wrap]); 34 | 35 | push([makePart('one'), tail]); 36 | }); 37 | 38 | assert.equal(bodies(collection), ['one']); 39 | }); 40 | 41 | Chunk('multiple yields single chunk', async () => { 42 | const collection = await make_test((push) => { 43 | push([ 44 | preamble, 45 | wrap, 46 | makePart('one'), 47 | wrap, 48 | makePart('two'), 49 | tail, 50 | ]); 51 | }); 52 | 53 | assert.equal(bodies(collection), ['one', 'two']); 54 | }); 55 | 56 | Chunk('multiple yields multi chunk', async () => { 57 | const collection = await make_test((push) => { 58 | push([preamble, wrap, makePart('one')]); 59 | 60 | push([wrap, makePart('two'), tail]); 61 | }); 62 | 63 | assert.equal(bodies(collection), ['one', 'two']); 64 | }); 65 | 66 | Chunk('goes rambo', async () => { 67 | const result = [ 68 | randomBytes(2000).toString('base64'), 69 | randomBytes(500).toString('utf8'), 70 | randomBytes(30).toString('hex'), 71 | randomBytes(3000).toString('ascii'), 72 | ]; 73 | const boundary = randomBytes(50).toString('hex'); 74 | 75 | const collection = await make_test((push) => { 76 | push([wrap(boundary), makePart('one')]); 77 | 78 | for (let payload of result) { 79 | push([wrap(boundary)]); 80 | for (let chunk of splitString(makePart(payload), 12)) { 81 | push([chunk]); 82 | } 83 | } 84 | 85 | push([wrap(boundary), makePart('two'), tail(boundary)]); 86 | }, boundary); 87 | 88 | assert.equal(bodies(collection), ['one', ...result, 'two']); 89 | }); 90 | 91 | Chunk.run(); 92 | 93 | const Multi = suite('chunking :: multi'); 94 | 95 | Multi('when true :: basic', async () => { 96 | const { asyncIterableIterator, pushValue } = 97 | makePushPullAsyncIterableIterator(); 98 | const response = await responder(asyncIterableIterator, '-'); 99 | const parts = await meros(response, { multiple: true }); 100 | 101 | pushValue([preamble, wrap, makePart({ foo: 'bar' }), wrap]); 102 | 103 | let { value } = await parts.next(); 104 | assert.is(Array.isArray(value), true); 105 | assert.equal(value, [ 106 | { 107 | headers: { 108 | 'content-type': 'application/json; charset=utf-8', 109 | }, 110 | body: { foo: 'bar' }, 111 | json: true, 112 | }, 113 | ]); 114 | 115 | pushValue([ 116 | makePart({ bar: 'baz' }), 117 | wrap, 118 | makePart({ foo: 'blitz' }), 119 | tail, 120 | ]); 121 | 122 | value = (await parts.next()).value; 123 | assert.is(Array.isArray(value), true); 124 | assert.equal( 125 | value.map((i: any) => i.body), 126 | [{ bar: 'baz' }, { foo: 'blitz' }], 127 | ); 128 | }); 129 | 130 | Multi.run(); 131 | }; 132 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | import type { Options, Part } from 'meros'; 2 | 3 | import type { Arrayable } from './shared'; 4 | 5 | async function* generate( 6 | stream: ReadableStream, 7 | boundary: string, 8 | options?: Options, 9 | ): AsyncGenerator>> { 10 | let decoder = new TextDecoder('utf8'); 11 | let reader = stream.getReader(); 12 | let is_eager = !options || !options.multiple; 13 | 14 | let len_boundary = boundary.length; 15 | let buffer = ''; 16 | let payloads = []; 17 | let idx_boundary; 18 | let in_main; 19 | let tmp; 20 | 21 | try { 22 | let result: ReadableStreamReadResult; 23 | outer: while (!(result = await reader.read()).done) { 24 | let chunk = decoder.decode(result.value, { stream: true }); 25 | 26 | idx_boundary = buffer.length; 27 | buffer += chunk; 28 | 29 | let idx_chunk = chunk.indexOf(boundary); 30 | // if the chunk has a boundary, simply use it 31 | !!~idx_chunk 32 | ? (idx_boundary += idx_chunk) 33 | : // if not lets search for it in our current buffer 34 | (idx_boundary = buffer.indexOf(boundary)); 35 | 36 | payloads = []; 37 | while (!!~idx_boundary) { 38 | let current = buffer.slice(0, idx_boundary); 39 | let next = buffer.slice(idx_boundary + len_boundary); 40 | 41 | if (!in_main) { 42 | boundary = '\r\n' + boundary; 43 | in_main = len_boundary += 2; 44 | } else { 45 | let idx_headers = current.indexOf('\r\n\r\n') + 4; // 4 -> '\r\n\r\n'.length 46 | let last_idx = current.lastIndexOf('\r\n', idx_headers); 47 | 48 | let is_json = false; 49 | let body: T | string = current.slice( 50 | idx_headers, 51 | last_idx > -1 ? undefined : last_idx, 52 | ); 53 | 54 | let arr_headers = String(current.slice(0, idx_headers)) 55 | .trim() 56 | .split('\r\n'); 57 | 58 | let headers: Record = {}; 59 | let len = arr_headers.length; 60 | for ( 61 | ; 62 | (tmp = arr_headers[--len]); 63 | tmp = tmp.split(': '), 64 | headers[tmp.shift()!.toLowerCase()] = tmp.join(': ') 65 | ); 66 | 67 | tmp = headers['content-type']; 68 | if (tmp && !!~tmp.indexOf('application/json')) { 69 | try { 70 | body = JSON.parse(body) as T; 71 | is_json = true; 72 | } catch (_) {} 73 | } 74 | 75 | tmp = { headers, body, json: is_json } as Part; 76 | is_eager ? yield tmp : payloads.push(tmp); 77 | 78 | // hit a tail boundary, break 79 | if ('--' === next.slice(0, 2)) break outer; 80 | } 81 | 82 | buffer = next; 83 | idx_boundary = buffer.indexOf(boundary); 84 | } 85 | 86 | if (payloads.length) yield payloads; 87 | } 88 | } finally { 89 | if (payloads.length) yield payloads; 90 | await reader.cancel(); 91 | } 92 | } 93 | 94 | export async function meros(response: Response, options?: Options) { 95 | if (!response.ok || !response.body || response.bodyUsed) return response; 96 | 97 | let ctype = response.headers.get('content-type'); 98 | if (!ctype || !~ctype.indexOf('multipart/')) return response; 99 | 100 | let idx_boundary = ctype.indexOf('boundary='); 101 | let boundary = '-'; 102 | if (!!~idx_boundary) { 103 | let idx_boundary_len = idx_boundary + 9; // +9 for 'boundary='.length 104 | let eo_boundary = ctype.indexOf(';', idx_boundary_len); // strip any parameter 105 | 106 | boundary = ctype 107 | .slice(idx_boundary_len, eo_boundary > -1 ? eo_boundary : undefined) 108 | .trim() 109 | .replace(/"/g, ''); 110 | } 111 | 112 | return generate(response.body, `--${boundary}`, options); 113 | } 114 | -------------------------------------------------------------------------------- /test/suites/boundary.ts: -------------------------------------------------------------------------------- 1 | import { makePushPullAsyncIterableIterator } from '@n1ru4l/push-pull-async-iterable-iterator'; 2 | import { suite } from 'uvu'; 3 | import * as assert from 'uvu/assert'; 4 | import { 5 | type Meros, 6 | type Responder, 7 | splitString, 8 | test_helper, 9 | preamble, 10 | wrap, 11 | makePart, 12 | tail, 13 | bodies, 14 | } from '../mocks'; 15 | 16 | export default (meros: Meros, responder: Responder) => { 17 | const Boundary = suite('boundary'); 18 | 19 | const make_test = async ( 20 | boundary: string, 21 | with_preamble_boundary = true, 22 | ) => { 23 | const { asyncIterableIterator, pushValue } = 24 | makePushPullAsyncIterableIterator(); 25 | const response = await responder(asyncIterableIterator, boundary); 26 | 27 | const part = [ 28 | `${with_preamble_boundary ? '\r\n' : ''}--${boundary}\r\n`, 29 | '\n', 30 | 'one', 31 | `\r\n--${boundary}\r\n`, 32 | '\n', 33 | 'two', 34 | `\r\n--${boundary}\r\n`, 35 | 'content-type: application/json\r\n', 36 | '\r\n', 37 | '"three"', 38 | `\r\n--${boundary}--`, 39 | ]; 40 | 41 | const split_parts = splitString(part.join(''), 11); 42 | 43 | const parts = await meros(response); 44 | const collection = []; 45 | 46 | for (const chunk of split_parts) { 47 | pushValue([chunk]); 48 | } 49 | 50 | for await (let { body: part } of parts) { 51 | collection.push(String(part)); 52 | } 53 | 54 | assert.equal(collection, ['one', 'two', 'three']); 55 | }; 56 | 57 | for (let boundary of [ 58 | 'abc123', 59 | 'abcdefghijklmnopqrstuvwxyz_abcdefghijklmnopqrstuvwxyz', 60 | '✨', 61 | // '✨🤔', // TODO: This should work 🤪 62 | ':::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::', 63 | '----------------------------------------------------------', 64 | '---', 65 | '===', 66 | "'", 67 | '(', 68 | ')', 69 | '+', 70 | '_', 71 | ',', 72 | '-', 73 | '.', 74 | '/', 75 | ':', 76 | '=', 77 | '?', 78 | ]) { 79 | Boundary(boundary, make_test.bind(0, boundary, true)); 80 | Boundary(boundary, make_test.bind(0, boundary, false)); 81 | } 82 | 83 | Boundary('RFC 1521 page 69', async () => { 84 | const collection = await test_helper( 85 | meros, 86 | responder, 87 | (push) => { 88 | push([ 89 | preamble, 90 | wrap, 91 | makePart({ foo: 'bar' }), 92 | wrap, 93 | makePart({ bar: 'baz' }), 94 | tail, 95 | ]); 96 | }, 97 | '-', 98 | { 99 | 'content-type': 'multipart/mixed;boundary="-";charset=utf-8', 100 | 'Content-Type': 'multipart/mixed;boundary="-";charset=utf-8', 101 | }, 102 | ); 103 | 104 | assert.equal(bodies(collection), [{ foo: 'bar' }, { bar: 'baz' }]); 105 | }); 106 | 107 | [ 108 | 'multipart/mixed', 109 | 'multipart/x-mixed-replace', 110 | 'multipart/alternative', 111 | 'multipart/digest', 112 | 'multipart/parallel', 113 | 'multipart/form-data', 114 | 'multipart/encrypted', 115 | 'multipart/signed', 116 | 'multipart/related', 117 | 'multipart/report', 118 | ].forEach((type) => { 119 | Boundary('using x-mixed-replace', async () => { 120 | const collection = await test_helper( 121 | meros, 122 | responder, 123 | (push) => { 124 | push([ 125 | preamble, 126 | wrap, 127 | makePart({ foo: 'bar' }), 128 | wrap, 129 | makePart({ bar: 'baz' }), 130 | tail, 131 | ]); 132 | }, 133 | '-', 134 | { 135 | 'content-type': type + ';boundary="-";charset=utf-8', 136 | 'Content-Type': type + ';boundary="-";charset=utf-8', 137 | }, 138 | ); 139 | 140 | assert.equal(bodies(collection), [{ foo: 'bar' }, { bar: 'baz' }]); 141 | }); 142 | }); 143 | 144 | Boundary.run(); 145 | }; 146 | -------------------------------------------------------------------------------- /test/mocks.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { makePushPullAsyncIterableIterator } from '@n1ru4l/push-pull-async-iterable-iterator'; 4 | import type { IncomingMessage } from 'http'; 5 | 6 | type Part = string; 7 | 8 | export const wrap = (boundary: string) => 9 | `\r\n--${boundary.replace(/['"]/g, '')}\r\n`; 10 | export const tail = (boundary: string) => 11 | `\r\n--${boundary.replace(/['"]/g, '')}--\r\n`; 12 | export const preamble = () => 'preamble'; 13 | 14 | export const makePart = ( 15 | payload: any, 16 | headers: string[] | boolean = [], 17 | ): Part => { 18 | if (headers === false) { 19 | headers = []; 20 | } else { 21 | if (!headers.includes('content-type')) 22 | (headers as string[]).unshift( 23 | `content-type: ${ 24 | typeof payload === 'string' 25 | ? 'text/plain' 26 | : 'application/json; charset=utf-8' 27 | }`, 28 | ); 29 | } 30 | 31 | const returns = [ 32 | ...headers, 33 | '', 34 | Buffer.from( 35 | typeof payload === 'string' ? payload : JSON.stringify(payload), 36 | 'utf8', 37 | ), 38 | ]; 39 | 40 | return returns.join('\r\n'); 41 | }; 42 | 43 | export const splitString = (str: Part, count: number): string[] => { 44 | const length = str.length, 45 | chunks = new Array(count), 46 | chars = Math.floor(length / count); 47 | for (let f = 0, n = chars, i = 0; i < count; i++) { 48 | chunks[i] = str.slice(f, i === count - 1 ? undefined : n); 49 | f = n; 50 | n = f + chars; 51 | } 52 | return chunks; 53 | }; 54 | 55 | const processChunk = (chunk: string[] | Buffer, boundary: string) => { 56 | if (Array.isArray(chunk)) 57 | return Buffer.from( 58 | chunk 59 | .map((v) => { 60 | if (typeof v === 'function') v = v(boundary); 61 | return v; 62 | }) 63 | .join(''), 64 | ); 65 | return chunk; 66 | }; 67 | 68 | export async function mockResponseNode( 69 | chunks: AsyncIterableIterator, 70 | boundary: string, 71 | headers: Record = {}, 72 | ): Promise { 73 | return { 74 | headers: { 75 | 'content-type': `multipart/mixed; boundary=${boundary}`, 76 | 'Content-Type': `multipart/mixed; boundary=${boundary}`, 77 | ...headers, 78 | }, 79 | [Symbol.asyncIterator]: async function* () { 80 | for await (let chunk of chunks) { 81 | yield processChunk(chunk, boundary); 82 | } 83 | }, 84 | }; 85 | } 86 | 87 | export async function mockResponseBrowser( 88 | chunks: AsyncIterableIterator, 89 | boundary: string, 90 | headers: Record = {}, 91 | ): Promise { 92 | const headrs = new Map([ 93 | ['content-type', `multipart/mixed; boundary=${boundary}`], 94 | ['Content-Type', `multipart/mixed; boundary=${boundary}`], 95 | ]); 96 | Object.entries(headers).forEach((key, value) => { 97 | headrs.set(key, value); 98 | }); 99 | return { 100 | headers: headrs, 101 | status: 200, 102 | body: { 103 | getReader() { 104 | return { 105 | async read() { 106 | const { value: chunk, done } = await chunks.next(); 107 | return { 108 | value: chunk 109 | ? processChunk(chunk, boundary) 110 | : undefined, 111 | done, 112 | }; 113 | }, 114 | cancel() { 115 | // nothing 116 | }, 117 | }; 118 | }, 119 | }, 120 | ok: true, 121 | bodyUsed: false, 122 | }; 123 | } 124 | 125 | export type Meros = any; 126 | export type Responder = any; 127 | 128 | export const bodies = (parts: any[]) => 129 | parts.map(({ body, json }) => (json ? body : String(body))); 130 | 131 | export const test_helper = async ( 132 | meros: Meros, 133 | responder: Responder, 134 | process: (v: any) => void, 135 | boundary = '-', 136 | headers?: Record, 137 | ) => { 138 | const { asyncIterableIterator, pushValue } = 139 | makePushPullAsyncIterableIterator(); 140 | const response = await responder(asyncIterableIterator, boundary, headers); 141 | 142 | const parts = await meros(response); 143 | const collection = []; 144 | 145 | await process(pushValue); 146 | 147 | for await (let part of parts) { 148 | collection.push(part); 149 | } 150 | 151 | return collection; 152 | }; 153 | -------------------------------------------------------------------------------- /bench/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { suite } from '@marais/bench'; 4 | import { equal } from 'uvu/assert'; 5 | 6 | import { makePart, mockResponseBrowser, mockResponseNode, preamble, tail, wrap } from '../test/mocks'; 7 | 8 | import fetchMultiPartGraphql from 'fetch-multipart-graphql'; 9 | import ItMultipart from 'it-multipart'; 10 | import { meros as merosBrowser } from '../src/browser'; 11 | import { meros as merosNode } from '../src/node'; 12 | 13 | const fmg = fetchMultiPartGraphql.default; 14 | 15 | const parts = [ 16 | { hello: 'world' }, 17 | [{ other: 'world' }, { another: 'world' }], 18 | { 19 | massive: { 20 | nested: { 21 | world: 'okay', 22 | }, 23 | }, 24 | }, 25 | ]; 26 | 27 | const results = parts.reduce((result, item) => { 28 | if (Array.isArray(item)) { 29 | return [...result, ...item]; 30 | } 31 | return [...result, item]; 32 | }, [] as any[]); 33 | 34 | const chunks = [ 35 | [preamble, wrap], 36 | ...parts.map((v, i) => { 37 | if (Array.isArray(v)) { 38 | return v.map(v2 => [makePart(v2), wrap]).flat() 39 | } 40 | 41 | if (i === parts.length - 1) { 42 | return [makePart(v), tail]; 43 | } 44 | 45 | return [makePart(v), wrap]; 46 | }), 47 | ]; 48 | 49 | const chunk_gen = (async function*() { 50 | for (const value of chunks) { 51 | yield value; 52 | } 53 | }); 54 | 55 | const do_node_call = mockResponseNode.bind(null, chunk_gen(), '-'); 56 | const do_browser_call = mockResponseBrowser.bind(null, chunk_gen(), '-'); 57 | 58 | global['fetch'] = async function(url, options) { 59 | return do_browser_call(); 60 | }; 61 | 62 | const verify = (result) => { 63 | equal(result, results, 'should match reference patch set'); 64 | return true; 65 | }; 66 | 67 | console.log('Node'); 68 | await suite({ 69 | meros() { 70 | return async () => { 71 | const response = await do_node_call(); 72 | const parts = await merosNode(response); 73 | 74 | const collection = []; 75 | 76 | for await (let { body } of parts) { 77 | collection.push(body); 78 | } 79 | 80 | return collection; 81 | } 82 | }, 83 | 'it-multipart'() { 84 | return async () => { 85 | const response = await do_node_call(); 86 | const parts = await ItMultipart(response); 87 | 88 | const collection = []; 89 | 90 | for await (let part of parts) { 91 | let data = ''; 92 | for await (const chunk of part.body) { 93 | data += String(chunk); 94 | } 95 | collection.push( 96 | !!~part.headers['content-type'].indexOf('application/json') 97 | ? JSON.parse(data) 98 | : data, 99 | ); 100 | } 101 | 102 | return collection; 103 | } 104 | }, 105 | }, (run) => { 106 | run(undefined, undefined, verify); 107 | }); 108 | 109 | console.log('\nBrowser'); 110 | await suite({ 111 | meros() { 112 | return async () => { 113 | const response = await do_browser_call(); 114 | const parts = await merosBrowser(response); 115 | 116 | const collection = []; 117 | 118 | for await (let { body } of parts) { 119 | collection.push(body); 120 | } 121 | 122 | return collection; 123 | } 124 | }, 125 | 'fetch-multipart-graphql'() { 126 | return async () => { 127 | return new Promise((resolve, reject) => { 128 | let collection: any[] = []; 129 | 130 | fmg('test', { 131 | onNext: (parts: any) => 132 | (collection = [...collection, ...parts]), 133 | onError: (err: Error) => reject(err), 134 | onComplete: () => resolve(collection), 135 | }); 136 | }); 137 | } 138 | }, 139 | }, (run) => { 140 | run(undefined, undefined, verify); 141 | }); 142 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | ![meros](logo.svg) 6 | 7 | 8 | 9 | **A utility that makes reading multipart responses simple** 10 | 11 | 12 | js downloads 13 | 14 | 15 | licenses 16 | 17 | 18 | gzip size 19 | 20 | 21 | brotli size 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 | This is free to use software, but if you do like it, consider supporting me ❤️ 30 | 31 | [![sponsor me](https://badgen.net/badge/icon/sponsor?icon=github&label&color=gray)](https://github.com/sponsors/maraisr) 32 | [![buy me a coffee](https://badgen.net/badge/icon/buymeacoffee?icon=buymeacoffee&label&color=gray)](https://www.buymeacoffee.com/marais) 33 | 34 | 35 | 36 |
37 | 38 | ## ⚡ Features 39 | 40 | - No dependencies 41 | - Seamless api 42 | - Super [performant](#-benchmark) 43 | - Supports _any_[^1] `content-type` 44 | - _preamble_ and _epilogue_ don't yield 45 | - Browser/Node Compatible 46 | - Plugs into existing libraries like Relay and rxjs 47 | 48 | [^1]: By default, we'll look for JSON, and parse that for you. If not, we'll give you the body as what was streamed. 49 | 50 | ## 🚀 Usage 51 | 52 | > Visit [/examples](/examples) for more info! 53 | 54 | ```ts 55 | // Relies on bundler/environment detection 56 | import { meros } from 'meros'; 57 | 58 | const parts = await fetch('/api').then(meros); 59 | 60 | // As a simple Async Generator 61 | for await (const part of parts) { 62 | // Do something with this part 63 | } 64 | 65 | // Used with rxjs streams 66 | from(parts).subscribe((part) => { 67 | // Do something with it 68 | }); 69 | ``` 70 | 71 | ## _Specific Environment_ 72 | 73 | #### _Browser_ 74 | 75 | ```ts 76 | import { meros } from 'meros/browser'; 77 | // import { meros } from 'https://cdn.skypack.dev/meros'; 78 | 79 | const parts = await fetch('/api').then(meros); 80 | ``` 81 | 82 | #### _Node_ 83 | 84 | ```ts 85 | import http from 'http'; 86 | import { meros } from 'meros/node'; 87 | 88 | const response = await new Promise((resolve) => { 89 | const request = http.get(`http://example.com/api`, (response) => { 90 | resolve(response); 91 | }); 92 | request.end(); 93 | }); 94 | 95 | const parts = await meros(response); 96 | ``` 97 | 98 | ## 🔎 API 99 | 100 | Meros offers two flavours, both for the browser and for node; but their api's are fundamentally the same. 101 | 102 | > **Note**: The type `Response` is used loosely here and simply alludes to Node's `IncomingMessage` or the browser's 103 | > `Response` type. 104 | 105 | ### `meros(response: Response, options?: Options)` 106 | 107 | Returns: `Promise>` 108 | 109 | Meros returns a promise that will resolve to an `AsyncGenerator` if the response is of `multipart/mixed` mime, or simply 110 | returns the `Response` if something else; helpful for middlewares. The idea here being that you run meros as a chain off 111 | fetch. 112 | 113 | ```ts 114 | fetch('/api').then(meros); 115 | ``` 116 | 117 | > If the `content-type` is **NOT** a multipart, then meros will resolve with the response argument. 118 | > 119 | >
120 | > Example on how to handle this case 121 | > 122 | > ```ts 123 | > import { meros } from 'meros'; 124 | > 125 | > const response = await fetch('/api'); // Assume this isnt multipart 126 | > const parts = await meros(response); 127 | > 128 | > if (parts[Symbol.asyncIterator] < 'u') { 129 | > for await (const part of parts) { 130 | > // Do something with this part 131 | > } 132 | > } else { 133 | > const data = await parts.json(); 134 | > } 135 | > ``` 136 | > 137 | >
138 | 139 | each `Part` gives you access to: 140 | 141 | - `json: boolean` ~ Tells you the `body` would be a JavaScript object of your defined generic `T`. 142 | - `headers: object` ~ A key-value pair of all headers discovered from this part. 143 | - `body: T | Fallback` ~ Is the _body_ of the part, either as a JavaScript object (noted by `json`) _or_ the base type 144 | of the environment (`Buffer | string`, for Node and Browser respectively). 145 | 146 | #### `options.multiple: boolean` 147 | 148 | Default: `false` 149 | 150 | Setting this to `true` will yield once for all available parts of a chunk, rather than yielding once per part. This is 151 | an optimization technique for technologies like GraphQL where rather than commit the payload to the store, to be 152 | added-to in the next process-tick we can simply do that synchronously. 153 | 154 | > **Warning**: This will alter the behaviour and yield arrays—than yield payloads. 155 | 156 | ```ts 157 | const chunks = await fetch('/api').then((response) => meros(response, { multiple: true })); 158 | 159 | // As a simple Async Generator 160 | for await (const parts of chunks) { 161 | for (const part of parts) { 162 | // Do something with this part, maybe aggregate? 163 | } 164 | } 165 | ``` 166 | 167 | ## 💨 Benchmark 168 | 169 | > via the [`/bench`](/bench) directory with Node v18.0.0 170 | 171 | ``` 172 | Node 173 | ✔ meros ~ 1,271,218 ops/sec ± 0.84% 174 | ✘ it-multipart ~ 700,039 ops/sec ± 0.72% 175 | -- 176 | it-multipart (FAILED @ "should match reference patch set") 177 | 178 | Browser 179 | ✔ meros ~ 800,941 ops/sec ± 1.06% 180 | ✘ fetch-multipart-graphql ~ 502,175 ops/sec ± 0.75% 181 | -- 182 | fetch-multipart-graphql (FAILED @ "should match reference patch set") 183 | ``` 184 | 185 | ## 🎒 Notes 186 | 187 | Why the name? _meros_ comes from Ancient Greek μέρος méros, meaning "part". 188 | 189 | This library aims to implement [RFC1341] in its entirety, however we aren't there yet. That being said, you may very 190 | well use this library in other scenarios like streaming in file form uploads. 191 | 192 | Another goal here is to aide in being the defacto standard transport library to support 193 | [`@defer` and `@stream` GraphQL directives](https://foundation.graphql.org/news/2020/12/08/improving-latency-with-defer-and-stream-directives/) 194 | 195 | ### _Caveats_ 196 | 197 | - No support the `/alternative` , `/digest` _or_ `/parallel` subtype at this time. 198 | - No support for [nested multiparts](https://tools.ietf.org/html/rfc1341#appendix-C) 199 | 200 | ## ❤ Thanks 201 | 202 | Special thanks to [Luke Edwards](https://github.com/lukeed) for performance guidance and high level api design. 203 | 204 | ## 😇 Compassion 205 | 206 | This library is simple, a mere few hundred bytes. It's easy to copy, and easy to alter. If you do, that is fine ❤️ I'm 207 | all for the freedom of software. But please give credit where credit is due. 208 | 209 | ## License 210 | 211 | MIT © [Marais Rossouw](https://marais.io) 212 | 213 | [rfc1341]: https://tools.ietf.org/html/rfc1341 'The Multipart Content-Type' 214 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | '@marais/prettier': 12 | specifier: 0.0.4 13 | version: 0.0.4 14 | '@marais/tsconfig': 15 | specifier: 0.0.4 16 | version: 0.0.4 17 | '@n1ru4l/push-pull-async-iterable-iterator': 18 | specifier: 3.2.0 19 | version: 3.2.0 20 | '@types/node': 21 | specifier: 24.0.15 22 | version: 24.0.15 23 | bundt: 24 | specifier: 2.0.0-next.5 25 | version: 2.0.0-next.5 26 | prettier: 27 | specifier: 3.6.2 28 | version: 3.6.2 29 | tsm: 30 | specifier: 2.3.0 31 | version: 2.3.0 32 | typescript: 33 | specifier: 5.8.3 34 | version: 5.8.3 35 | uvu: 36 | specifier: 0.5.4 37 | version: 0.5.4 38 | 39 | packages: 40 | 41 | '@esbuild/android-arm@0.15.18': 42 | resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} 43 | engines: {node: '>=12'} 44 | cpu: [arm] 45 | os: [android] 46 | 47 | '@esbuild/linux-loong64@0.14.54': 48 | resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==} 49 | engines: {node: '>=12'} 50 | cpu: [loong64] 51 | os: [linux] 52 | 53 | '@esbuild/linux-loong64@0.15.18': 54 | resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} 55 | engines: {node: '>=12'} 56 | cpu: [loong64] 57 | os: [linux] 58 | 59 | '@jridgewell/gen-mapping@0.3.8': 60 | resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} 61 | engines: {node: '>=6.0.0'} 62 | 63 | '@jridgewell/resolve-uri@3.1.2': 64 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 65 | engines: {node: '>=6.0.0'} 66 | 67 | '@jridgewell/set-array@1.2.1': 68 | resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} 69 | engines: {node: '>=6.0.0'} 70 | 71 | '@jridgewell/source-map@0.3.6': 72 | resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} 73 | 74 | '@jridgewell/sourcemap-codec@1.5.0': 75 | resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} 76 | 77 | '@jridgewell/trace-mapping@0.3.25': 78 | resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} 79 | 80 | '@marais/prettier@0.0.4': 81 | resolution: {integrity: sha512-fcJgHALkAkmOyMEioqMaikXlUQLy9jj+SZjlI2AD9V0vEO1EjR3ZI5vz3y6A0Bz/PgskbyM9+F/A44850UWrhQ==} 82 | 83 | '@marais/tsconfig@0.0.4': 84 | resolution: {integrity: sha512-b6KCal22xP6E8wgl52rxdf8MXuffI4oJ9aTosucX4aVb97yl01wU0PzGF67oMA/i9KdzLa0rjQ0zVdZ+1pvVAg==} 85 | 86 | '@n1ru4l/push-pull-async-iterable-iterator@3.2.0': 87 | resolution: {integrity: sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q==} 88 | engines: {node: '>=12'} 89 | 90 | '@types/node@24.0.15': 91 | resolution: {integrity: sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==} 92 | 93 | acorn@8.15.0: 94 | resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 95 | engines: {node: '>=0.4.0'} 96 | hasBin: true 97 | 98 | buffer-from@1.1.2: 99 | resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} 100 | 101 | bundt@2.0.0-next.5: 102 | resolution: {integrity: sha512-uoMMvvZUGRVyVbd0tls6ZU3bASc0lZt3b0iD3AE2J9sKgnsKJoWAWe4uUcCkla+Dx+T006ZERBvq0PY3iNuXlw==} 103 | engines: {node: '>=12'} 104 | hasBin: true 105 | 106 | commander@2.20.3: 107 | resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} 108 | 109 | dequal@2.0.3: 110 | resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} 111 | engines: {node: '>=6'} 112 | 113 | diff@5.2.0: 114 | resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} 115 | engines: {node: '>=0.3.1'} 116 | 117 | esbuild-android-64@0.14.54: 118 | resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==} 119 | engines: {node: '>=12'} 120 | cpu: [x64] 121 | os: [android] 122 | 123 | esbuild-android-64@0.15.18: 124 | resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==} 125 | engines: {node: '>=12'} 126 | cpu: [x64] 127 | os: [android] 128 | 129 | esbuild-android-arm64@0.14.54: 130 | resolution: {integrity: sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==} 131 | engines: {node: '>=12'} 132 | cpu: [arm64] 133 | os: [android] 134 | 135 | esbuild-android-arm64@0.15.18: 136 | resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==} 137 | engines: {node: '>=12'} 138 | cpu: [arm64] 139 | os: [android] 140 | 141 | esbuild-darwin-64@0.14.54: 142 | resolution: {integrity: sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==} 143 | engines: {node: '>=12'} 144 | cpu: [x64] 145 | os: [darwin] 146 | 147 | esbuild-darwin-64@0.15.18: 148 | resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==} 149 | engines: {node: '>=12'} 150 | cpu: [x64] 151 | os: [darwin] 152 | 153 | esbuild-darwin-arm64@0.14.54: 154 | resolution: {integrity: sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==} 155 | engines: {node: '>=12'} 156 | cpu: [arm64] 157 | os: [darwin] 158 | 159 | esbuild-darwin-arm64@0.15.18: 160 | resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==} 161 | engines: {node: '>=12'} 162 | cpu: [arm64] 163 | os: [darwin] 164 | 165 | esbuild-freebsd-64@0.14.54: 166 | resolution: {integrity: sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==} 167 | engines: {node: '>=12'} 168 | cpu: [x64] 169 | os: [freebsd] 170 | 171 | esbuild-freebsd-64@0.15.18: 172 | resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==} 173 | engines: {node: '>=12'} 174 | cpu: [x64] 175 | os: [freebsd] 176 | 177 | esbuild-freebsd-arm64@0.14.54: 178 | resolution: {integrity: sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==} 179 | engines: {node: '>=12'} 180 | cpu: [arm64] 181 | os: [freebsd] 182 | 183 | esbuild-freebsd-arm64@0.15.18: 184 | resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==} 185 | engines: {node: '>=12'} 186 | cpu: [arm64] 187 | os: [freebsd] 188 | 189 | esbuild-linux-32@0.14.54: 190 | resolution: {integrity: sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==} 191 | engines: {node: '>=12'} 192 | cpu: [ia32] 193 | os: [linux] 194 | 195 | esbuild-linux-32@0.15.18: 196 | resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==} 197 | engines: {node: '>=12'} 198 | cpu: [ia32] 199 | os: [linux] 200 | 201 | esbuild-linux-64@0.14.54: 202 | resolution: {integrity: sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==} 203 | engines: {node: '>=12'} 204 | cpu: [x64] 205 | os: [linux] 206 | 207 | esbuild-linux-64@0.15.18: 208 | resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==} 209 | engines: {node: '>=12'} 210 | cpu: [x64] 211 | os: [linux] 212 | 213 | esbuild-linux-arm64@0.14.54: 214 | resolution: {integrity: sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==} 215 | engines: {node: '>=12'} 216 | cpu: [arm64] 217 | os: [linux] 218 | 219 | esbuild-linux-arm64@0.15.18: 220 | resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==} 221 | engines: {node: '>=12'} 222 | cpu: [arm64] 223 | os: [linux] 224 | 225 | esbuild-linux-arm@0.14.54: 226 | resolution: {integrity: sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==} 227 | engines: {node: '>=12'} 228 | cpu: [arm] 229 | os: [linux] 230 | 231 | esbuild-linux-arm@0.15.18: 232 | resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==} 233 | engines: {node: '>=12'} 234 | cpu: [arm] 235 | os: [linux] 236 | 237 | esbuild-linux-mips64le@0.14.54: 238 | resolution: {integrity: sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==} 239 | engines: {node: '>=12'} 240 | cpu: [mips64el] 241 | os: [linux] 242 | 243 | esbuild-linux-mips64le@0.15.18: 244 | resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==} 245 | engines: {node: '>=12'} 246 | cpu: [mips64el] 247 | os: [linux] 248 | 249 | esbuild-linux-ppc64le@0.14.54: 250 | resolution: {integrity: sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==} 251 | engines: {node: '>=12'} 252 | cpu: [ppc64] 253 | os: [linux] 254 | 255 | esbuild-linux-ppc64le@0.15.18: 256 | resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==} 257 | engines: {node: '>=12'} 258 | cpu: [ppc64] 259 | os: [linux] 260 | 261 | esbuild-linux-riscv64@0.14.54: 262 | resolution: {integrity: sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==} 263 | engines: {node: '>=12'} 264 | cpu: [riscv64] 265 | os: [linux] 266 | 267 | esbuild-linux-riscv64@0.15.18: 268 | resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==} 269 | engines: {node: '>=12'} 270 | cpu: [riscv64] 271 | os: [linux] 272 | 273 | esbuild-linux-s390x@0.14.54: 274 | resolution: {integrity: sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==} 275 | engines: {node: '>=12'} 276 | cpu: [s390x] 277 | os: [linux] 278 | 279 | esbuild-linux-s390x@0.15.18: 280 | resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==} 281 | engines: {node: '>=12'} 282 | cpu: [s390x] 283 | os: [linux] 284 | 285 | esbuild-netbsd-64@0.14.54: 286 | resolution: {integrity: sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==} 287 | engines: {node: '>=12'} 288 | cpu: [x64] 289 | os: [netbsd] 290 | 291 | esbuild-netbsd-64@0.15.18: 292 | resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==} 293 | engines: {node: '>=12'} 294 | cpu: [x64] 295 | os: [netbsd] 296 | 297 | esbuild-openbsd-64@0.14.54: 298 | resolution: {integrity: sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==} 299 | engines: {node: '>=12'} 300 | cpu: [x64] 301 | os: [openbsd] 302 | 303 | esbuild-openbsd-64@0.15.18: 304 | resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==} 305 | engines: {node: '>=12'} 306 | cpu: [x64] 307 | os: [openbsd] 308 | 309 | esbuild-sunos-64@0.14.54: 310 | resolution: {integrity: sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==} 311 | engines: {node: '>=12'} 312 | cpu: [x64] 313 | os: [sunos] 314 | 315 | esbuild-sunos-64@0.15.18: 316 | resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==} 317 | engines: {node: '>=12'} 318 | cpu: [x64] 319 | os: [sunos] 320 | 321 | esbuild-windows-32@0.14.54: 322 | resolution: {integrity: sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==} 323 | engines: {node: '>=12'} 324 | cpu: [ia32] 325 | os: [win32] 326 | 327 | esbuild-windows-32@0.15.18: 328 | resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==} 329 | engines: {node: '>=12'} 330 | cpu: [ia32] 331 | os: [win32] 332 | 333 | esbuild-windows-64@0.14.54: 334 | resolution: {integrity: sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==} 335 | engines: {node: '>=12'} 336 | cpu: [x64] 337 | os: [win32] 338 | 339 | esbuild-windows-64@0.15.18: 340 | resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==} 341 | engines: {node: '>=12'} 342 | cpu: [x64] 343 | os: [win32] 344 | 345 | esbuild-windows-arm64@0.14.54: 346 | resolution: {integrity: sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==} 347 | engines: {node: '>=12'} 348 | cpu: [arm64] 349 | os: [win32] 350 | 351 | esbuild-windows-arm64@0.15.18: 352 | resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==} 353 | engines: {node: '>=12'} 354 | cpu: [arm64] 355 | os: [win32] 356 | 357 | esbuild@0.14.54: 358 | resolution: {integrity: sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==} 359 | engines: {node: '>=12'} 360 | hasBin: true 361 | 362 | esbuild@0.15.18: 363 | resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==} 364 | engines: {node: '>=12'} 365 | hasBin: true 366 | 367 | kleur@4.1.5: 368 | resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 369 | engines: {node: '>=6'} 370 | 371 | mri@1.2.0: 372 | resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 373 | engines: {node: '>=4'} 374 | 375 | prettier@3.6.2: 376 | resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} 377 | engines: {node: '>=14'} 378 | hasBin: true 379 | 380 | rewrite-imports@2.0.3: 381 | resolution: {integrity: sha512-R7ICJEeP3y+d/q4C8YEJj9nRP0JyiSqG07uc0oQh8JvAe706dDFVL95GBZYCjADqmhArZWWjfM/5EcmVu4/B+g==} 382 | engines: {node: '>=6'} 383 | 384 | sade@1.8.1: 385 | resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 386 | engines: {node: '>=6'} 387 | 388 | source-map-support@0.5.21: 389 | resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} 390 | 391 | source-map@0.6.1: 392 | resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 393 | engines: {node: '>=0.10.0'} 394 | 395 | terser@5.42.0: 396 | resolution: {integrity: sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==} 397 | engines: {node: '>=10'} 398 | hasBin: true 399 | 400 | tsm@2.3.0: 401 | resolution: {integrity: sha512-++0HFnmmR+gMpDtKTnW3XJ4yv9kVGi20n+NfyQWB9qwJvTaIWY9kBmzek2YUQK5APTQ/1DTrXmm4QtFPmW9Rzw==} 402 | engines: {node: '>=12'} 403 | hasBin: true 404 | 405 | typescript@5.8.3: 406 | resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} 407 | engines: {node: '>=14.17'} 408 | hasBin: true 409 | 410 | undici-types@7.8.0: 411 | resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} 412 | 413 | uvu@0.5.4: 414 | resolution: {integrity: sha512-x1CyUjcP9VKaNPhjeB3FIc/jqgLsz2Q9LFhRzUTu/jnaaHILEGNuE0XckQonl8ISLcwyk9I2EZvWlYsQnwxqvQ==} 415 | engines: {node: '>=8'} 416 | hasBin: true 417 | 418 | snapshots: 419 | 420 | '@esbuild/android-arm@0.15.18': 421 | optional: true 422 | 423 | '@esbuild/linux-loong64@0.14.54': 424 | optional: true 425 | 426 | '@esbuild/linux-loong64@0.15.18': 427 | optional: true 428 | 429 | '@jridgewell/gen-mapping@0.3.8': 430 | dependencies: 431 | '@jridgewell/set-array': 1.2.1 432 | '@jridgewell/sourcemap-codec': 1.5.0 433 | '@jridgewell/trace-mapping': 0.3.25 434 | 435 | '@jridgewell/resolve-uri@3.1.2': {} 436 | 437 | '@jridgewell/set-array@1.2.1': {} 438 | 439 | '@jridgewell/source-map@0.3.6': 440 | dependencies: 441 | '@jridgewell/gen-mapping': 0.3.8 442 | '@jridgewell/trace-mapping': 0.3.25 443 | 444 | '@jridgewell/sourcemap-codec@1.5.0': {} 445 | 446 | '@jridgewell/trace-mapping@0.3.25': 447 | dependencies: 448 | '@jridgewell/resolve-uri': 3.1.2 449 | '@jridgewell/sourcemap-codec': 1.5.0 450 | 451 | '@marais/prettier@0.0.4': {} 452 | 453 | '@marais/tsconfig@0.0.4': {} 454 | 455 | '@n1ru4l/push-pull-async-iterable-iterator@3.2.0': {} 456 | 457 | '@types/node@24.0.15': 458 | dependencies: 459 | undici-types: 7.8.0 460 | 461 | acorn@8.15.0: {} 462 | 463 | buffer-from@1.1.2: {} 464 | 465 | bundt@2.0.0-next.5: 466 | dependencies: 467 | esbuild: 0.14.54 468 | rewrite-imports: 2.0.3 469 | terser: 5.42.0 470 | 471 | commander@2.20.3: {} 472 | 473 | dequal@2.0.3: {} 474 | 475 | diff@5.2.0: {} 476 | 477 | esbuild-android-64@0.14.54: 478 | optional: true 479 | 480 | esbuild-android-64@0.15.18: 481 | optional: true 482 | 483 | esbuild-android-arm64@0.14.54: 484 | optional: true 485 | 486 | esbuild-android-arm64@0.15.18: 487 | optional: true 488 | 489 | esbuild-darwin-64@0.14.54: 490 | optional: true 491 | 492 | esbuild-darwin-64@0.15.18: 493 | optional: true 494 | 495 | esbuild-darwin-arm64@0.14.54: 496 | optional: true 497 | 498 | esbuild-darwin-arm64@0.15.18: 499 | optional: true 500 | 501 | esbuild-freebsd-64@0.14.54: 502 | optional: true 503 | 504 | esbuild-freebsd-64@0.15.18: 505 | optional: true 506 | 507 | esbuild-freebsd-arm64@0.14.54: 508 | optional: true 509 | 510 | esbuild-freebsd-arm64@0.15.18: 511 | optional: true 512 | 513 | esbuild-linux-32@0.14.54: 514 | optional: true 515 | 516 | esbuild-linux-32@0.15.18: 517 | optional: true 518 | 519 | esbuild-linux-64@0.14.54: 520 | optional: true 521 | 522 | esbuild-linux-64@0.15.18: 523 | optional: true 524 | 525 | esbuild-linux-arm64@0.14.54: 526 | optional: true 527 | 528 | esbuild-linux-arm64@0.15.18: 529 | optional: true 530 | 531 | esbuild-linux-arm@0.14.54: 532 | optional: true 533 | 534 | esbuild-linux-arm@0.15.18: 535 | optional: true 536 | 537 | esbuild-linux-mips64le@0.14.54: 538 | optional: true 539 | 540 | esbuild-linux-mips64le@0.15.18: 541 | optional: true 542 | 543 | esbuild-linux-ppc64le@0.14.54: 544 | optional: true 545 | 546 | esbuild-linux-ppc64le@0.15.18: 547 | optional: true 548 | 549 | esbuild-linux-riscv64@0.14.54: 550 | optional: true 551 | 552 | esbuild-linux-riscv64@0.15.18: 553 | optional: true 554 | 555 | esbuild-linux-s390x@0.14.54: 556 | optional: true 557 | 558 | esbuild-linux-s390x@0.15.18: 559 | optional: true 560 | 561 | esbuild-netbsd-64@0.14.54: 562 | optional: true 563 | 564 | esbuild-netbsd-64@0.15.18: 565 | optional: true 566 | 567 | esbuild-openbsd-64@0.14.54: 568 | optional: true 569 | 570 | esbuild-openbsd-64@0.15.18: 571 | optional: true 572 | 573 | esbuild-sunos-64@0.14.54: 574 | optional: true 575 | 576 | esbuild-sunos-64@0.15.18: 577 | optional: true 578 | 579 | esbuild-windows-32@0.14.54: 580 | optional: true 581 | 582 | esbuild-windows-32@0.15.18: 583 | optional: true 584 | 585 | esbuild-windows-64@0.14.54: 586 | optional: true 587 | 588 | esbuild-windows-64@0.15.18: 589 | optional: true 590 | 591 | esbuild-windows-arm64@0.14.54: 592 | optional: true 593 | 594 | esbuild-windows-arm64@0.15.18: 595 | optional: true 596 | 597 | esbuild@0.14.54: 598 | optionalDependencies: 599 | '@esbuild/linux-loong64': 0.14.54 600 | esbuild-android-64: 0.14.54 601 | esbuild-android-arm64: 0.14.54 602 | esbuild-darwin-64: 0.14.54 603 | esbuild-darwin-arm64: 0.14.54 604 | esbuild-freebsd-64: 0.14.54 605 | esbuild-freebsd-arm64: 0.14.54 606 | esbuild-linux-32: 0.14.54 607 | esbuild-linux-64: 0.14.54 608 | esbuild-linux-arm: 0.14.54 609 | esbuild-linux-arm64: 0.14.54 610 | esbuild-linux-mips64le: 0.14.54 611 | esbuild-linux-ppc64le: 0.14.54 612 | esbuild-linux-riscv64: 0.14.54 613 | esbuild-linux-s390x: 0.14.54 614 | esbuild-netbsd-64: 0.14.54 615 | esbuild-openbsd-64: 0.14.54 616 | esbuild-sunos-64: 0.14.54 617 | esbuild-windows-32: 0.14.54 618 | esbuild-windows-64: 0.14.54 619 | esbuild-windows-arm64: 0.14.54 620 | 621 | esbuild@0.15.18: 622 | optionalDependencies: 623 | '@esbuild/android-arm': 0.15.18 624 | '@esbuild/linux-loong64': 0.15.18 625 | esbuild-android-64: 0.15.18 626 | esbuild-android-arm64: 0.15.18 627 | esbuild-darwin-64: 0.15.18 628 | esbuild-darwin-arm64: 0.15.18 629 | esbuild-freebsd-64: 0.15.18 630 | esbuild-freebsd-arm64: 0.15.18 631 | esbuild-linux-32: 0.15.18 632 | esbuild-linux-64: 0.15.18 633 | esbuild-linux-arm: 0.15.18 634 | esbuild-linux-arm64: 0.15.18 635 | esbuild-linux-mips64le: 0.15.18 636 | esbuild-linux-ppc64le: 0.15.18 637 | esbuild-linux-riscv64: 0.15.18 638 | esbuild-linux-s390x: 0.15.18 639 | esbuild-netbsd-64: 0.15.18 640 | esbuild-openbsd-64: 0.15.18 641 | esbuild-sunos-64: 0.15.18 642 | esbuild-windows-32: 0.15.18 643 | esbuild-windows-64: 0.15.18 644 | esbuild-windows-arm64: 0.15.18 645 | 646 | kleur@4.1.5: {} 647 | 648 | mri@1.2.0: {} 649 | 650 | prettier@3.6.2: {} 651 | 652 | rewrite-imports@2.0.3: {} 653 | 654 | sade@1.8.1: 655 | dependencies: 656 | mri: 1.2.0 657 | 658 | source-map-support@0.5.21: 659 | dependencies: 660 | buffer-from: 1.1.2 661 | source-map: 0.6.1 662 | 663 | source-map@0.6.1: {} 664 | 665 | terser@5.42.0: 666 | dependencies: 667 | '@jridgewell/source-map': 0.3.6 668 | acorn: 8.15.0 669 | commander: 2.20.3 670 | source-map-support: 0.5.21 671 | 672 | tsm@2.3.0: 673 | dependencies: 674 | esbuild: 0.15.18 675 | 676 | typescript@5.8.3: {} 677 | 678 | undici-types@7.8.0: {} 679 | 680 | uvu@0.5.4: 681 | dependencies: 682 | dequal: 2.0.3 683 | diff: 5.2.0 684 | kleur: 4.1.5 685 | sade: 1.8.1 686 | --------------------------------------------------------------------------------