diff --git a/packages/csv-stringify/dist/esm/browser.d.ts b/packages/csv-stringify/dist/esm/browser.d.ts new file mode 100644 index 00000000..e8cada3d --- /dev/null +++ b/packages/csv-stringify/dist/esm/browser.d.ts @@ -0,0 +1,117 @@ +// Stream signatures follow rollup-plugin-node-builtins 2.1.2's bundled +// readable-stream implementation; Buffer input follows buffer-es6 4.9.3. + +export interface Buffer extends Uint8Array { + toString(encoding?: string, start?: number, end?: number): string; + readUInt8(offset?: number, noAssert?: boolean): number; +} + +// Object-mode chunks and custom events carry user-defined values. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Chunk = any; +type EventName = string | symbol; +type Listener = (this: Context, ...args: Chunk[]) => void; +type WriteCallback = (error?: Error | null) => void; +type TransformCallback = (error?: Error | null, data?: Chunk) => void; +type WriteEntry = { chunk: Chunk; encoding: string }; + +declare class EventEmitter { + setMaxListeners(count: number): this; + getMaxListeners(): number; + emit(event: EventName, ...args: Chunk[]): boolean; + addListener(event: EventName, listener: Listener): this; + on(event: EventName, listener: Listener): this; + once(event: EventName, listener: Listener): this; + prependListener(event: EventName, listener: Listener): this; + prependOnceListener(event: EventName, listener: Listener): this; + removeListener(event: EventName, listener: Listener): this; + removeAllListeners(event?: EventName): this; + listeners(event: EventName): Listener[]; + listenerCount(event: EventName): number; + eventNames(): EventName[]; +} + +interface PipeDestination { + write(chunk: Chunk): boolean; + end(): unknown; + on(event: EventName, listener: Listener): unknown; + once(event: EventName, listener: Listener): unknown; + removeListener(event: EventName, listener: Listener): unknown; + listeners(event: EventName): Listener[]; + emit(event: EventName, ...args: Chunk[]): boolean; + prependListener?(event: EventName, listener: Listener): unknown; +} + +interface WrappedStream { + on(event: EventName, listener: Listener): unknown; + pause(): unknown; + resume(): unknown; +} + +export interface TransformOptions { + highWaterMark?: number; + objectMode?: boolean; + readableObjectMode?: boolean; + writableObjectMode?: boolean; + encoding?: string; + defaultEncoding?: string; + decodeStrings?: boolean; + readable?: boolean; + writable?: boolean; + allowHalfOpen?: boolean; + read?(this: Transform, size: number): void; + write?( + this: Transform, + chunk: Chunk, + encoding: string, + callback: WriteCallback, + ): void; + writev?(this: Transform, chunks: WriteEntry[], callback: WriteCallback): void; + transform?( + this: Transform, + chunk: Chunk, + encoding: string, + callback: TransformCallback, + ): void; + flush?(this: Transform, callback: WriteCallback): void; +} + +export class Transform extends EventEmitter { + constructor(options?: TransformOptions); + readable: boolean; + writable: boolean; + allowHalfOpen: boolean; + + read(size?: number): Chunk; + push(chunk: Chunk, encoding?: string): boolean; + unshift(chunk: Chunk): boolean; + isPaused(): boolean; + setEncoding(encoding: string): this; + pipe( + destination: T, + options?: { end?: boolean }, + ): T; + unpipe(destination?: PipeDestination): this; + resume(): this; + pause(): this; + wrap(stream: WrappedStream): this; + + write(chunk: Chunk, callback?: WriteCallback): boolean; + write( + chunk: Chunk, + encoding: string | undefined, + callback?: WriteCallback, + ): boolean; + cork(): void; + uncork(): void; + setDefaultEncoding(encoding: string): this; + end(callback?: () => void): void; + end(chunk: Chunk, callback?: () => void): void; + end(chunk: Chunk, encoding: string | undefined, callback?: () => void): void; + + _read(size: number): void; + _write(chunk: Chunk, encoding: string, callback: WriteCallback): void; + _writev: ((chunks: WriteEntry[], callback: WriteCallback) => void) | null; + _transform(chunk: Chunk, encoding: string, callback: TransformCallback): void; + _flush?(callback: WriteCallback): void; +} diff --git a/packages/csv-stringify/dist/esm/index.d.ts b/packages/csv-stringify/dist/esm/index.d.ts index c91b5369..20231607 100644 --- a/packages/csv-stringify/dist/esm/index.d.ts +++ b/packages/csv-stringify/dist/esm/index.d.ts @@ -1,6 +1,5 @@ -/// - -import * as stream from "stream"; +import type * as stream from "./browser.js"; +import type { Buffer } from "./browser.js"; export type Callback = (err: Error | undefined, output: string) => void; export type RecordDelimiter = diff --git a/packages/csv-stringify/lib/browser.d.ts b/packages/csv-stringify/lib/browser.d.ts new file mode 100644 index 00000000..e8cada3d --- /dev/null +++ b/packages/csv-stringify/lib/browser.d.ts @@ -0,0 +1,117 @@ +// Stream signatures follow rollup-plugin-node-builtins 2.1.2's bundled +// readable-stream implementation; Buffer input follows buffer-es6 4.9.3. + +export interface Buffer extends Uint8Array { + toString(encoding?: string, start?: number, end?: number): string; + readUInt8(offset?: number, noAssert?: boolean): number; +} + +// Object-mode chunks and custom events carry user-defined values. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Chunk = any; +type EventName = string | symbol; +type Listener = (this: Context, ...args: Chunk[]) => void; +type WriteCallback = (error?: Error | null) => void; +type TransformCallback = (error?: Error | null, data?: Chunk) => void; +type WriteEntry = { chunk: Chunk; encoding: string }; + +declare class EventEmitter { + setMaxListeners(count: number): this; + getMaxListeners(): number; + emit(event: EventName, ...args: Chunk[]): boolean; + addListener(event: EventName, listener: Listener): this; + on(event: EventName, listener: Listener): this; + once(event: EventName, listener: Listener): this; + prependListener(event: EventName, listener: Listener): this; + prependOnceListener(event: EventName, listener: Listener): this; + removeListener(event: EventName, listener: Listener): this; + removeAllListeners(event?: EventName): this; + listeners(event: EventName): Listener[]; + listenerCount(event: EventName): number; + eventNames(): EventName[]; +} + +interface PipeDestination { + write(chunk: Chunk): boolean; + end(): unknown; + on(event: EventName, listener: Listener): unknown; + once(event: EventName, listener: Listener): unknown; + removeListener(event: EventName, listener: Listener): unknown; + listeners(event: EventName): Listener[]; + emit(event: EventName, ...args: Chunk[]): boolean; + prependListener?(event: EventName, listener: Listener): unknown; +} + +interface WrappedStream { + on(event: EventName, listener: Listener): unknown; + pause(): unknown; + resume(): unknown; +} + +export interface TransformOptions { + highWaterMark?: number; + objectMode?: boolean; + readableObjectMode?: boolean; + writableObjectMode?: boolean; + encoding?: string; + defaultEncoding?: string; + decodeStrings?: boolean; + readable?: boolean; + writable?: boolean; + allowHalfOpen?: boolean; + read?(this: Transform, size: number): void; + write?( + this: Transform, + chunk: Chunk, + encoding: string, + callback: WriteCallback, + ): void; + writev?(this: Transform, chunks: WriteEntry[], callback: WriteCallback): void; + transform?( + this: Transform, + chunk: Chunk, + encoding: string, + callback: TransformCallback, + ): void; + flush?(this: Transform, callback: WriteCallback): void; +} + +export class Transform extends EventEmitter { + constructor(options?: TransformOptions); + readable: boolean; + writable: boolean; + allowHalfOpen: boolean; + + read(size?: number): Chunk; + push(chunk: Chunk, encoding?: string): boolean; + unshift(chunk: Chunk): boolean; + isPaused(): boolean; + setEncoding(encoding: string): this; + pipe( + destination: T, + options?: { end?: boolean }, + ): T; + unpipe(destination?: PipeDestination): this; + resume(): this; + pause(): this; + wrap(stream: WrappedStream): this; + + write(chunk: Chunk, callback?: WriteCallback): boolean; + write( + chunk: Chunk, + encoding: string | undefined, + callback?: WriteCallback, + ): boolean; + cork(): void; + uncork(): void; + setDefaultEncoding(encoding: string): this; + end(callback?: () => void): void; + end(chunk: Chunk, callback?: () => void): void; + end(chunk: Chunk, encoding: string | undefined, callback?: () => void): void; + + _read(size: number): void; + _write(chunk: Chunk, encoding: string, callback: WriteCallback): void; + _writev: ((chunks: WriteEntry[], callback: WriteCallback) => void) | null; + _transform(chunk: Chunk, encoding: string, callback: TransformCallback): void; + _flush?(callback: WriteCallback): void; +} diff --git a/packages/csv-stringify/package.json b/packages/csv-stringify/package.json index 2fa2b138..e89c0da8 100644 --- a/packages/csv-stringify/package.json +++ b/packages/csv-stringify/package.json @@ -78,25 +78,24 @@ "scripts": { "build": "npm run build:rollup && npm run build:ts", "build:rollup": "rollup -c", - "build:ts": "cp lib/index.d.ts dist/cjs/index.d.cts && cp lib/sync.d.ts dist/cjs/sync.d.cts && cp lib/*.ts dist/esm", - "postbuild:ts": "find dist/cjs -name '*.d.cts' -exec sh -c \"sed -i \"s/\\.js'/\\.cjs'/g\" {} || sed -i '' \"s/\\.js'/\\.cjs'/g\" {}\" \\;", + "build:ts": "node scripts/build-types.js", "lint:check": "eslint", "lint:fix": "eslint --fix", "lint:ts": "tsc --noEmit true", "preversion": "npm run build && git add dist", - "test": "tsc --noEmit && mocha 'test/**/*.{js,ts}'", + "test": "tsc --noEmit && tsc -p test/types/tsconfig.json && tsc -p test/types/tsconfig.node.json && tsc -p test/types/tsconfig.legacy.json && mocha 'test/**/*.{js,ts}'", "test:16": "mocha 'test/**/*.js'", "test:legacy": "mocha --ignore test/api.callback.js --ignore test/api.web_stream.js 'test/**/*.{js,ts}'" }, "type": "module", - "types": "dist/esm/index.d.ts", + "types": "lib/index.d.ts", "typesVersions": { "*": { ".": [ - "dist/esm/index.d.ts" + "lib/index.d.ts" ], "sync": [ - "dist/esm/sync.d.ts" + "lib/sync.d.ts" ], "browser/esm": [ "dist/esm/index.d.ts" diff --git a/packages/csv-stringify/scripts/build-types.js b/packages/csv-stringify/scripts/build-types.js new file mode 100644 index 00000000..996a9668 --- /dev/null +++ b/packages/csv-stringify/scripts/build-types.js @@ -0,0 +1,36 @@ +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; + +const lib = new URL("../lib/", import.meta.url); +const cjs = new URL("../dist/cjs/", import.meta.url); +const esm = new URL("../dist/esm/", import.meta.url); +const index = (await readFile(new URL("index.d.ts", lib), "utf8")).replaceAll( + "\r\n", + "\n", +); +const sync = (await readFile(new URL("sync.d.ts", lib), "utf8")).replaceAll( + "\r\n", + "\n", +); +const nodeHeader = + '/// \n\nimport * as stream from "stream";'; + +if (!index.startsWith(nodeHeader)) { + throw new Error("Update the browser declaration header for lib/index.d.ts"); +} + +const browserHeader = + 'import type * as stream from "./browser.js";\nimport type { Buffer } from "./browser.js";'; + +await mkdir(cjs, { recursive: true }); +await mkdir(esm, { recursive: true }); +await writeFile(new URL("index.d.cts", cjs), index); +await writeFile( + new URL("sync.d.cts", cjs), + sync.replaceAll("./index.js", "./index.cjs"), +); +await writeFile( + new URL("index.d.ts", esm), + browserHeader + index.slice(nodeHeader.length), +); +await writeFile(new URL("sync.d.ts", esm), sync); +await copyFile(new URL("browser.d.ts", lib), new URL("browser.d.ts", esm)); diff --git a/packages/csv-stringify/test/api.browser.js b/packages/csv-stringify/test/api.browser.js new file mode 100644 index 00000000..265b1f87 --- /dev/null +++ b/packages/csv-stringify/test/api.browser.js @@ -0,0 +1,40 @@ +import "should"; +import { Stringifier, stringify } from "csv-stringify/browser/esm"; +import { stringify as stringifySync } from "csv-stringify/browser/esm/sync"; + +describe("Browser ESM API", function () { + it("stringifies records with a callback", function (next) { + stringify( + [{ name: "first", value: 1 }], + { header: true }, + function (err, output) { + if (err) return next(err); + output.should.eql("name,value\nfirst,1\n"); + next(); + }, + ); + }); + + it("exposes the readable and writable stream API", function (next) { + const stream = new Stringifier({ delimiter: ";" }); + stream.setEncoding("utf8").pause().resume(); + let output = ""; + stream.on("data", function (chunk) { + output += chunk; + }); + stream.on("error", next); + stream.on("end", function () { + output.should.eql("first;1\nsecond;2\n"); + next(); + }); + stream.cork(); + stream.write(["first", 1]).should.be.a.Boolean(); + stream.write(["second", 2]).should.be.a.Boolean(); + stream.uncork(); + stream.end(); + }); + + it("stringifies records synchronously", function () { + stringifySync([["first", 1]], { delimiter: ";" }).should.eql("first;1\n"); + }); +}); diff --git a/packages/csv-stringify/test/types/browser.mts b/packages/csv-stringify/test/types/browser.mts new file mode 100644 index 00000000..5b78d705 --- /dev/null +++ b/packages/csv-stringify/test/types/browser.mts @@ -0,0 +1,50 @@ +import { + stringify, + Stringifier, + CsvError, + type Options, + type OptionsNormalized, +} from "csv-stringify/browser/esm"; +import { stringify as stringifySync } from "csv-stringify/browser/esm/sync"; + +const timeout: number = setTimeout(() => {}, 1000); +clearTimeout(timeout); + +const options: Options = { + header: true, + columns: ["name", "value"] as const, + quoted_match: [/\s/], + cast: { number: (value, context) => `${value + context.index}` }, + highWaterMark: 32, +}; +const stream: Stringifier = stringify( + [["first", 1]], + options, + (err, output) => { + if (err) throw err; + const csv: string = output; + console.log(csv); + }, +); +const normalized: OptionsNormalized = stream.options; +const csv: string = stringifySync([["first", 1]], options); +const ready: boolean = stream.write(["second", 2]); +stream.on("readable", function (this: Stringifier) { + this.read(); +}); +stream.pause(); +stream.resume(); +stream.cork(); +stream.uncork(); +stream.end(); +new Stringifier(options); +new CsvError("CSV_INVALID_ARGUMENT", "invalid", normalized); +console.log(csv, ready); + +// @ts-expect-error Browser imports must not introduce the Node process global. +console.log(process.env); +// @ts-expect-error Browser imports must not introduce the Node Buffer global. +Buffer.from("value"); +// @ts-expect-error CSV options accept Buffer values, not arbitrary typed arrays. +const invalid: Options = { delimiter: new Uint8Array([44]) }; +console.log(invalid); diff --git a/packages/csv-stringify/test/types/node.cts b/packages/csv-stringify/test/types/node.cts new file mode 100644 index 00000000..2950aa8e --- /dev/null +++ b/packages/csv-stringify/test/types/node.cts @@ -0,0 +1,8 @@ +import { Transform } from "node:stream"; +import { stringify, type Options } from "csv-stringify"; +import { stringify as stringifySync } from "csv-stringify/sync"; + +const options: Options = { delimiter: Buffer.from(";") }; +const stringifier: Transform = stringify(options); +const output: string = stringifySync([["first", 1]], options); +console.log(stringifier, output); diff --git a/packages/csv-stringify/test/types/node.mts b/packages/csv-stringify/test/types/node.mts new file mode 100644 index 00000000..a7e200d8 --- /dev/null +++ b/packages/csv-stringify/test/types/node.mts @@ -0,0 +1,16 @@ +import { Transform, Writable } from "node:stream"; +import { stringify, type Options } from "csv-stringify"; +import { stringify as stringifySync } from "csv-stringify/sync"; +import { + stringify as stringifyBrowser, + type Options as BrowserOptions, +} from "csv-stringify/browser/esm"; + +const options: Options = { delimiter: Buffer.from(";") }; +const nodeStream: Transform = stringify(options); +const csv: string = stringifySync([["first", 1]], options); +const browserOptions: BrowserOptions = { delimiter: Buffer.from(";") }; +const browserStream = stringifyBrowser(browserOptions); +const destination = new Writable(); +const piped: Writable = browserStream.pipe(destination); +console.log(nodeStream, csv, piped); diff --git a/packages/csv-stringify/test/types/tsconfig.json b/packages/csv-stringify/test/types/tsconfig.json new file mode 100644 index 00000000..1c57a4ad --- /dev/null +++ b/packages/csv-stringify/test/types/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": [], + "strict": true, + "skipLibCheck": false, + "noEmit": true + }, + "files": ["browser.mts"] +} diff --git a/packages/csv-stringify/test/types/tsconfig.legacy.json b/packages/csv-stringify/test/types/tsconfig.legacy.json new file mode 100644 index 00000000..435d2659 --- /dev/null +++ b/packages/csv-stringify/test/types/tsconfig.legacy.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node10", + "ignoreDeprecations": "6.0" + }, + "files": ["node.cts"] +} diff --git a/packages/csv-stringify/test/types/tsconfig.node.json b/packages/csv-stringify/test/types/tsconfig.node.json new file mode 100644 index 00000000..9df521b9 --- /dev/null +++ b/packages/csv-stringify/test/types/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"] + }, + "files": ["node.mts", "node.cts"] +} diff --git a/packages/csv-stringify/tsconfig.json b/packages/csv-stringify/tsconfig.json index 5867967a..d898a2b8 100644 --- a/packages/csv-stringify/tsconfig.json +++ b/packages/csv-stringify/tsconfig.json @@ -7,6 +7,7 @@ "strict": true, "target": "esnext", // Prevent error "Cannot find name 'it'" - "types": ["mocha"], + "types": ["mocha"] }, + "exclude": ["test/types"] }