├── dist └── esm │ └── package.json ├── tests ├── php │ ├── post.php │ └── hello.php ├── record.test.ts ├── params.test.ts ├── php-fpm.test.ts ├── utils.test.ts ├── writer.test.ts ├── reader.test.ts └── client.test.ts ├── .prettierrc ├── .gitignore ├── jest.config.cjs ├── README.md ├── .editorconfig ├── src ├── index.ts ├── middleware.ts ├── reader.ts ├── params.ts ├── writer.ts ├── options.ts ├── utils.ts ├── record.ts └── client.ts ├── .eslintrc.cjs ├── rollup.config.js ├── package.json └── tsconfig.json /dist/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /tests/php/post.php: -------------------------------------------------------------------------------- 1 | ({ 5 | ...config, 6 | input: 'src/index.ts', 7 | external: id => !/^[./]/.test(id), 8 | }) 9 | 10 | module.exports = [ 11 | bundle({ 12 | plugins: [typescript.default(),], 13 | output: [ 14 | { 15 | file: `dist/index.js`, 16 | format: 'cjs', 17 | sourcemap: true, 18 | }, 19 | { 20 | file: `dist/esm/index.mjs`, 21 | format: 'es', 22 | sourcemap: true, 23 | }, 24 | ], 25 | }), 26 | ] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastcgi-kit", 3 | "version": "0.16.0", 4 | "description": "Modules to implement FastCGI", 5 | "main": "dist/index.js", 6 | "module": "dist/esm/index.mjs", 7 | "type": "commonjs", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "/dist" 11 | ], 12 | "scripts": { 13 | "test": "jest", 14 | "build": "rollup -c", 15 | "watch": "rollup -c -w", 16 | "lint": "eslint --fix 'src/**/*.ts'", 17 | "prepublishOnly": "npm run build" 18 | }, 19 | "keywords": [ 20 | "FastCGI", 21 | "PHP" 22 | ], 23 | "devDependencies": { 24 | "@rollup/plugin-typescript": "^11.1.6", 25 | "@types/express": "^4.17.21", 26 | "@types/jest": "^29.5.12", 27 | "@typescript-eslint/eslint-plugin": "^5.62.0", 28 | "@typescript-eslint/parser": "^5.62.0", 29 | "esbuild": "^0.17.19", 30 | "eslint": "^8.57.0", 31 | "jest": "^29.7.0", 32 | "prettier": "^2.8.8", 33 | "rollup": "^3.29.4", 34 | "ts-jest": "^29.1.2", 35 | "typescript": "^4.9.5" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/basuke/node-fastcgi-kit.git" 40 | }, 41 | "author": "Basuke Suzuki", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/basuke/node-fastcgi-kit/issues" 45 | }, 46 | "homepage": "https://github.com/basuke/node-fastcgi-kit#readme", 47 | "dependencies": { 48 | "remove": "^0.1.5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler, Request, Response, NextFunction } from 'express'; 2 | import { createClient } from './client'; 3 | import { requestToParams } from './options'; 4 | 5 | export type FastCGIOptions = { 6 | address: string; 7 | documentRoot: string; 8 | debug?: boolean; 9 | }; 10 | 11 | export function fastcgi(options: FastCGIOptions): RequestHandler { 12 | const params = {}; 13 | 14 | const client = createClient({ ...options, params }); 15 | const documentRoot = options.documentRoot; 16 | 17 | const handler = async ( 18 | req: Request, 19 | res: Response, 20 | next: NextFunction 21 | ): Promise => { 22 | const params = requestToParams(req, documentRoot); 23 | const request = await client.begin(); 24 | 25 | return new Promise((resolve, reject) => { 26 | let error = ''; 27 | 28 | request.on('stdout', (buffer: Buffer) => res.write(buffer)); 29 | request.on('stderr', (line: string) => (error += line)); 30 | 31 | request.on('end', (appStatus) => { 32 | if (appStatus) { 33 | reject(new Error(error)); 34 | } else { 35 | res.end(); 36 | resolve(); 37 | } 38 | }); 39 | 40 | request.sendParams(params); 41 | 42 | if (req.headers['content-length']) { 43 | request.send(req); 44 | } else { 45 | request.done(); 46 | } 47 | }); 48 | }; 49 | 50 | return handler; 51 | } 52 | -------------------------------------------------------------------------------- /tests/record.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Type, 3 | makeRecord, 4 | setBody, 5 | encode, 6 | decodableSize, 7 | decode, 8 | } from '../src/record'; 9 | import { bytestr as B } from '../src/utils'; 10 | 11 | function b(...bytes: number[]): Buffer { 12 | return Buffer.from(bytes); 13 | } 14 | 15 | describe('encoding record', () => { 16 | test('simple request', () => { 17 | const record = makeRecord(Type.FCGI_UNKNOWN_TYPE); 18 | expect(encode(record)).toEqual(B`01 0b 0000 0000 00 00`); 19 | }); 20 | 21 | test('request with body', () => { 22 | const record = makeRecord(Type.FCGI_UNKNOWN_TYPE); 23 | record.requestId = 258; 24 | expect(encode(setBody(record, b(0, 1, 2)))).toEqual( 25 | B`01 0B 0102 0003 05 00 00010200 00000000` 26 | ); 27 | }); 28 | 29 | test('request with string body', () => { 30 | const record = makeRecord(Type.FCGI_UNKNOWN_TYPE); 31 | record.requestId = 259; 32 | expect(encode(setBody(record, 'Hello'))).toEqual( 33 | B`01 0b 0103 0005 03 00 ${'Hello'} 000000` 34 | ); 35 | }); 36 | }); 37 | 38 | describe('decoding record', () => { 39 | test('detect enogh data', () => { 40 | expect(decodableSize(B`01`)).toBeFalsy(); 41 | expect(decodableSize(B`01 0b 0000 0000 00`)).toBeFalsy(); 42 | 43 | expect(decodableSize(B`01 0b 0000 0000 00 00`)).toBe(8); 44 | expect(decodableSize(B`01 0b 0103 0005 03 00 ${'Hello'} 000000`)).toBe( 45 | 16 46 | ); 47 | }); 48 | 49 | test('decode data', () => { 50 | expect(() => decode(B`01`)).toThrow(); 51 | 52 | let record = decode(B`01 0b 0000 0000 00 00`); 53 | expect(record).not.toBeNull(); 54 | expect(record.body).toBeNull(); 55 | expect(record.type).toBe(Type.FCGI_UNKNOWN_TYPE); 56 | expect(record.requestId).toBe(0); 57 | 58 | record = decode(B`01 0b 0001 0003 05 00 0001 0203 04ff ffff`); 59 | expect(record).not.toBeNull(); 60 | expect(record.body).toEqual(B`00 01 02`); 61 | expect(record.type).toBe(Type.FCGI_UNKNOWN_TYPE); 62 | expect(record.requestId).toBe(1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/params.test.ts: -------------------------------------------------------------------------------- 1 | import { decode, encode, Params } from '../src/params'; 2 | import { bytestr as B } from '../src/utils'; 3 | 4 | describe('Key-Value paire encoding', () => { 5 | test('simple', () => { 6 | expect(encode({})).toEqual(Buffer.alloc(0)); 7 | expect(encode({ hello: '' })).toEqual(B`0500${'hello'}`); 8 | expect(encode({ hello: 'world' })).toEqual(B`0505${'hello'}${'world'}`); 9 | expect(encode({ hello: 'world', foo: 'bar' })).toEqual( 10 | B`0505${'hello'}${'world'} 0303${'foo'}${'bar'}` 11 | ); 12 | }); 13 | 14 | test('name.length == 130', () => { 15 | const name = '0123456789'.repeat(13); 16 | const values: Params = {}; 17 | values[name] = 'hello'; 18 | expect(encode(values)).toEqual( 19 | B`${[128, 0, 0, 130]}05${name}${'hello'}` 20 | ); 21 | }); 22 | 23 | test('name.length == 260', () => { 24 | const name = '0123456789'.repeat(26); 25 | const values: Params = {}; 26 | values[name] = 'hello'; 27 | expect(encode(values)).toEqual(B`${[128, 0, 1, 4]}05${name}${'hello'}`); 28 | }); 29 | 30 | test('name.length == 260', () => { 31 | const name = '0123456789'.repeat(26); 32 | const values: Params = {}; 33 | values[name] = 'hello'; 34 | expect(encode(values)).toEqual(B`${[128, 0, 1, 4]}05${name}${'hello'}`); 35 | }); 36 | }); 37 | 38 | describe('Decoding key-value params', () => { 39 | test('simple pair', () => { 40 | const params = {}; 41 | const remainings = decode(B`${[5, 5]} ${'hello'} ${'world'}`, params); 42 | expect(params).toEqual({ hello: 'world' }); 43 | expect(remainings).toBeNull(); 44 | }); 45 | 46 | test('multiple params', () => { 47 | const params = {}; 48 | decode(B`${[5, 5]} ${'helloworld'} ${[3, 3]} ${'foobar'}`, params); 49 | expect(params).toEqual({ hello: 'world', foo: 'bar' }); 50 | }); 51 | 52 | test('extra data in buffer', () => { 53 | const params = {}; 54 | const remainings = decode( 55 | B`${[5, 5]} ${'helloworld'} ${[3, 3]}`, 56 | params 57 | ); 58 | expect(params).toEqual({ hello: 'world' }); 59 | expect(remainings).toEqual(Buffer.from([3, 3])); 60 | }); 61 | 62 | test('not enough data', () => { 63 | const params = {}; 64 | decode(B``, params); 65 | expect(params).toEqual({}); 66 | 67 | decode(B`${[5, 5]}`, params); 68 | expect(params).toEqual({}); 69 | 70 | decode(B`${[5, 5]} ${'Hello'}`, params); 71 | expect(params).toEqual({}); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/reader.ts: -------------------------------------------------------------------------------- 1 | import { decodableSize, decode, FCGIRecord, Type } from './record'; 2 | import { Writable } from 'node:stream'; 3 | import { StreamDecoder as ParamsDecoder, StreamDecoder } from './params'; 4 | 5 | export class Reader extends Writable { 6 | remaining: Buffer | null = null; 7 | 8 | // Param stream 9 | paramsDecoders: Map = new Map(); 10 | 11 | _write( 12 | chunk: Buffer, 13 | _: BufferEncoding, 14 | callback: (error?: Error | null | undefined) => void 15 | ): void { 16 | if (this.remaining) { 17 | chunk = Buffer.concat([this.remaining, chunk]); 18 | this.remaining = null; 19 | } 20 | 21 | while (chunk.byteLength > 0) { 22 | const length = decodableSize(chunk); 23 | if (!length || length > chunk.byteLength) { 24 | this.remaining = chunk; 25 | break; 26 | } 27 | const record = this.decodeRecord(chunk.subarray(0, length)); 28 | chunk = chunk.subarray(length); 29 | if (record) { 30 | this.emit('record', record); 31 | } 32 | } 33 | callback(); 34 | } 35 | 36 | decodeRecord(chunk: Buffer): FCGIRecord | null { 37 | const record = decode(chunk); 38 | if ( 39 | this.paramsDecoders.has(record.requestId) && 40 | record.type !== Type.FCGI_PARAMS 41 | ) { 42 | this.emit( 43 | 'error', 44 | new Error( 45 | 'Reader::decodeRecord: Cannot receive other record while processing FCGI_PARAMS stream.' 46 | ) 47 | ); 48 | return null; 49 | } 50 | 51 | switch (record.type) { 52 | case Type.FCGI_GET_VALUES: 53 | case Type.FCGI_GET_VALUES_RESULT: 54 | case Type.FCGI_PARAMS: 55 | return this.decodeParams(record); 56 | 57 | default: 58 | return record; 59 | } 60 | } 61 | 62 | paramsDecoderForRecord(record: FCGIRecord): ParamsDecoder { 63 | if (this.paramsDecoders.has(record.requestId)) { 64 | return this.paramsDecoders.get(record.requestId) as StreamDecoder; 65 | } else { 66 | const decoder = new ParamsDecoder(record.type === Type.FCGI_PARAMS); 67 | this.paramsDecoders.set(record.requestId, decoder); 68 | return decoder; 69 | } 70 | } 71 | 72 | decodeParams(record: FCGIRecord): FCGIRecord | null { 73 | const decoder = this.paramsDecoderForRecord(record); 74 | 75 | if (record.body instanceof Buffer) { 76 | decoder.decode(record.body); 77 | if (decoder.isStream) return null; 78 | } 79 | 80 | if (decoder.canClose) { 81 | record.body = decoder.params; 82 | this.paramsDecoders.delete(record.requestId); 83 | return record; 84 | } else { 85 | const message = 'decodeParams: Incomplete params.'; 86 | this.emit('error', new Error(message)); 87 | return null; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/params.ts: -------------------------------------------------------------------------------- 1 | export type Params = { [name: string]: string }; 2 | 3 | function encodeLength(buffer: Buffer): Buffer { 4 | const len = buffer.byteLength; 5 | return Buffer.from( 6 | len >= 128 7 | ? [ 8 | 0x80 | (len >> 24), 9 | (len >> 16) & 0xff, 10 | (len >> 8) & 0xff, 11 | len & 0xff, 12 | ] 13 | : [len] 14 | ); 15 | } 16 | 17 | function decodePair( 18 | buffer: Buffer, 19 | offset: number 20 | ): [string, string, number] | undefined { 21 | function decodeLength(): number | undefined { 22 | if (buffer.byteLength <= offset) return undefined; 23 | 24 | const length = buffer[offset]; 25 | if (length < 0x80) { 26 | offset += 1; 27 | return length; 28 | } 29 | if (buffer.byteLength <= offset + 3) return undefined; 30 | const longLength = 31 | ((length & 0x7f) << 24) + 32 | (buffer[offset + 1] << 16) + 33 | (buffer[offset + 2] << 8) + 34 | buffer[offset + 3]; 35 | offset += 4; 36 | return longLength; 37 | } 38 | 39 | const nameLength = decodeLength(); 40 | if (nameLength === undefined) return undefined; 41 | const valueLength = decodeLength(); 42 | if (valueLength === undefined) return undefined; 43 | if (buffer.byteLength < offset + nameLength + valueLength) return undefined; 44 | 45 | const nameStart = offset; 46 | const valueStart = nameStart + nameLength; 47 | const valueEnd = valueStart + valueLength; 48 | return [ 49 | buffer.subarray(nameStart, valueStart).toString(), 50 | buffer.subarray(valueStart, valueEnd).toString(), 51 | valueEnd, 52 | ]; 53 | } 54 | 55 | export function encode(params: Params): Buffer { 56 | const buffers: Buffer[] = []; 57 | 58 | for (const name in params) { 59 | const value = params[name]; 60 | 61 | const nameBytes = Buffer.from(name); 62 | const valueBytes = Buffer.from(value); 63 | 64 | buffers.push(encodeLength(nameBytes)); 65 | buffers.push(encodeLength(valueBytes)); 66 | buffers.push(nameBytes); 67 | buffers.push(valueBytes); 68 | } 69 | return Buffer.concat(buffers); 70 | } 71 | 72 | export function decode(buffer: Buffer, params: Params): Buffer | null { 73 | let offset = 0; 74 | while (offset < buffer.byteLength) { 75 | const result = decodePair(buffer, offset); 76 | if (result === undefined) { 77 | return offset > 0 ? buffer.subarray(offset) : buffer; 78 | } 79 | 80 | const [name, value, newOffset] = result; 81 | params[name] = value; 82 | offset = newOffset; 83 | } 84 | return null; 85 | } 86 | 87 | export class StreamDecoder { 88 | params: Params = {}; 89 | remaining: Buffer | null = null; 90 | readonly isStream: boolean; 91 | 92 | constructor(isStream: boolean) { 93 | this.isStream = isStream; 94 | } 95 | 96 | decode(buffer: Buffer) { 97 | buffer = this.remaining 98 | ? Buffer.concat([this.remaining, buffer]) 99 | : buffer; 100 | this.remaining = decode(buffer, this.params); 101 | } 102 | 103 | get canClose(): boolean { 104 | return this.remaining === null; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/php-fpm.test.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '../src/client'; 2 | import { findExec, tick } from '../src/utils'; 3 | import { join } from 'node:path'; 4 | 5 | const describeIf = (condition: boolean) => 6 | condition ? describe : describe.skip; 7 | 8 | const phpFpm = 'php-fpm'; 9 | const phpFpmExists = findExec(phpFpm); 10 | 11 | function sendParams(script_file: string = '/hello.php') { 12 | const script_dir = join(__dirname, 'php'); 13 | const script_path = join(script_dir, script_file); 14 | 15 | return { 16 | QUERY_STRING: '', 17 | REQUEST_METHOD: 'GET', 18 | REQUEST_URI: 'http://localhost/hello/world', 19 | 20 | SCRIPT_FILENAME: script_path, 21 | SCRIPT_NAME: script_file, 22 | PATH_INFO: script_file, 23 | DOCUMENT_URI: script_file, 24 | PHP_SELF: script_file, 25 | }; 26 | } 27 | 28 | describeIf(phpFpmExists)('Test with php-fpm', () => { 29 | test('connect to php-fpm', (done) => { 30 | const client = createClient({ 31 | host: 'localhost', 32 | port: 9000, 33 | debug: false, 34 | params: { 35 | DOCUMENT_ROOT: __dirname, 36 | }, 37 | }); 38 | 39 | client.on('ready', async () => { 40 | const response = await client.get( 41 | new URL('http://localhost/php/hello.php'), 42 | {} 43 | ); 44 | 45 | expect(response.statusCode).toBe(200); 46 | expect(response.text).toContain('Hello world from PHP'); 47 | done(); 48 | }); 49 | }); 50 | 51 | test('php-fpm, POST', (done) => { 52 | const client = createClient({ 53 | address: 'localhost:9000', 54 | debug: false, 55 | params: { 56 | DOCUMENT_ROOT: __dirname, 57 | }, 58 | }); 59 | 60 | client.on('ready', async () => { 61 | const response = await client.post( 62 | new URL('http://localhost/php/post.php'), 63 | 'name=Basuke', 64 | {} 65 | ); 66 | 67 | expect(response.statusCode).toBe(200); 68 | expect(response.text).toContain('Basuke'); 69 | done(); 70 | }); 71 | }); 72 | 73 | test('connect to php-fpm: low-level', (done) => { 74 | const client = createClient({ 75 | host: 'localhost', 76 | port: 9000, 77 | debug: false, 78 | params: { 79 | REMOTE_ADDR: '127.0.0.1', 80 | GATEWAY_PROTOCOL: 'CGI/1.1', 81 | SERVER_SOFTWARE: 'fastcgi-kit; node/' + process.version, 82 | DOCUMENT_ROOT: __dirname, 83 | }, 84 | }); 85 | 86 | client.on('ready', async () => { 87 | const request = await client.begin(false); 88 | 89 | let body: string = ''; 90 | let stderr: string = ''; 91 | 92 | request.sendParams(sendParams()); 93 | 94 | request.on('stdout', (buffer: Buffer) => { 95 | body += buffer.toString(); 96 | }); 97 | request.on('stderr', (err: string) => { 98 | stderr += err; 99 | }); 100 | request.on('end', (appStatus: number) => { 101 | expect(appStatus).toBe(0); 102 | expect(body).toContain('Hello world from PHP'); 103 | expect(stderr.length).toBe(0); 104 | done(); 105 | }); 106 | 107 | request.done(); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/writer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | encode, 3 | FCGIRecord, 4 | defaultAlignment, 5 | setBody, 6 | paddingSize, 7 | maxContentLength, 8 | Type, 9 | } from './record'; 10 | import { Readable, Writable } from 'node:stream'; 11 | import { Params } from './params'; 12 | 13 | export interface Writer { 14 | readonly alignment: number; 15 | write: (record: FCGIRecord, stream?: Readable, length?: number) => void; 16 | } 17 | 18 | class WriterImpl implements Writer { 19 | stream: Writable; 20 | alignment: number; 21 | 22 | constructor(stream: Writable, alignment: number) { 23 | this.stream = stream; 24 | this.alignment = alignment; 25 | } 26 | 27 | write(record: FCGIRecord, stream?: Readable, length?: number) { 28 | if (stream) { 29 | const originalSize = length ?? 0; 30 | let readSize = 0; 31 | 32 | const processChunk = (chunk: Buffer) => { 33 | const limit = this.safeMaxContentSize(); 34 | 35 | let offset = 0; 36 | while (offset < chunk.byteLength) { 37 | const body = chunk.subarray(offset, offset + limit); 38 | const record2 = setBody(record, body); 39 | 40 | this.stream.write(encode(record2, this.alignment, true)); 41 | this.stream.write(record2.body); 42 | 43 | const padding = paddingSize( 44 | body.byteLength, 45 | this.alignment 46 | ); 47 | if (padding > 0) { 48 | this.stream.write(Buffer.allocUnsafe(padding)); 49 | } 50 | offset += body.byteLength; 51 | } 52 | 53 | readSize += chunk.byteLength; 54 | }; 55 | 56 | stream.on('data', (chunk: string | Buffer) => { 57 | if (chunk instanceof Buffer) { 58 | processChunk(chunk); 59 | } else if (typeof chunk === 'string') { 60 | processChunk(Buffer.from(chunk)); 61 | } else { 62 | stream.emit( 63 | 'error', 64 | new TypeError( 65 | 'WriterImpl::write: Only Buffer or string in Readable' 66 | ) 67 | ); 68 | } 69 | }); 70 | 71 | stream.on('end', () => { 72 | if (readSize !== originalSize) { 73 | stream.emit( 74 | 'error', 75 | new Error( 76 | `WriterImpl::write: Invalid size of data is sent. content-length: ${originalSize} readSize: ${readSize}` 77 | ) 78 | ); 79 | } 80 | }); 81 | } else if (record.type === Type.FCGI_PARAMS) { 82 | const body = record.body as Params; 83 | if (typeof body === 'object' && Object.keys(body).length > 0) { 84 | this.stream.write(encode(record, this.alignment)); 85 | } 86 | this.stream.write(encode(setBody(record, null), this.alignment)); 87 | } else { 88 | const header = encode(record, this.alignment); 89 | this.stream.write(header); 90 | } 91 | } 92 | 93 | safeMaxContentSize() { 94 | const padding = paddingSize(maxContentLength, this.alignment); 95 | if (padding === 0) return maxContentLength; 96 | return maxContentLength + padding - this.alignment; 97 | } 98 | } 99 | 100 | export function createWriter( 101 | stream: Writable, 102 | alignment: number = defaultAlignment 103 | ): Writer { 104 | return new WriterImpl(stream, alignment); 105 | } 106 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alignedSize, 3 | bytestr as B, 4 | hiByte, 5 | loByte, 6 | StreamPair, 7 | word, 8 | tick, 9 | MinBag, 10 | } from '../src/utils'; 11 | 12 | describe('alignedSize', () => { 13 | test('works with same size', () => { 14 | expect(alignedSize(8, 8)).toBe(8); 15 | expect(alignedSize(16, 16)).toBe(16); 16 | expect(alignedSize(32, 32)).toBe(32); 17 | }); 18 | 19 | test('works with dividable size', () => { 20 | expect(alignedSize(8, 8)).toBe(8); 21 | expect(alignedSize(16, 8)).toBe(16); 22 | expect(alignedSize(32, 8)).toBe(32); 23 | }); 24 | 25 | test('works with smaller size than alignment', () => { 26 | expect(alignedSize(7, 8)).toBe(8); 27 | expect(alignedSize(13, 16)).toBe(16); 28 | }); 29 | 30 | test('works with larger size than alignment', () => { 31 | expect(alignedSize(9, 8)).toBe(16); 32 | expect(alignedSize(17, 16)).toBe(32); 33 | }); 34 | }); 35 | 36 | describe('hiByte', () => { 37 | test('small number', () => { 38 | expect(hiByte(10)).toBe(0); 39 | expect(hiByte(0)).toBe(0); 40 | expect(hiByte(255)).toBe(0); 41 | }); 42 | 43 | test('big number', () => { 44 | expect(hiByte(256)).toBe(1); 45 | expect(hiByte(513)).toBe(2); 46 | expect(hiByte(1026)).toBe(4); 47 | }); 48 | }); 49 | 50 | describe('loByte', () => { 51 | test('small number', () => { 52 | expect(loByte(10)).toBe(10); 53 | expect(loByte(0)).toBe(0); 54 | expect(loByte(255)).toBe(255); 55 | }); 56 | 57 | test('big number', () => { 58 | expect(loByte(256)).toBe(0); 59 | expect(loByte(513)).toBe(1); 60 | expect(loByte(1026)).toBe(2); 61 | }); 62 | }); 63 | 64 | describe('word', () => { 65 | test('basic', () => { 66 | expect(word(1, 1)).toBe(257); 67 | }); 68 | }); 69 | 70 | describe('bytestr', () => { 71 | test('basic', () => { 72 | expect(B`00`).toEqual(Buffer.alloc(1)); 73 | expect(B`01`).toEqual(Buffer.from([1])); 74 | expect(B`000103`).toEqual(Buffer.from([0, 1, 3])); 75 | }); 76 | 77 | test('allow space', () => { 78 | expect(B`00 01 03`).toEqual(Buffer.from([0, 1, 3])); 79 | }); 80 | 81 | test('does not allow weird space', () => { 82 | expect(() => B`000 103`).toThrow(); 83 | }); 84 | 85 | test('does not allow odd digits', () => { 86 | expect(() => B`000`).toThrow(); 87 | }); 88 | 89 | test('allow expression', () => { 90 | expect(B`00${'Hello'}00`).toEqual( 91 | Buffer.from([0, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0]) 92 | ); 93 | }); 94 | 95 | test('spaces between expression', () => { 96 | expect(B`00 ${[5]} ${'Hello'}`).toEqual( 97 | Buffer.from([0, 5, 0x48, 0x65, 0x6c, 0x6c, 0x6f]) 98 | ); 99 | }); 100 | }); 101 | 102 | describe('stream pair', () => { 103 | test('creation', async () => { 104 | const [a, b] = StreamPair.create(); 105 | const receivedA: any[] = []; 106 | const receivedB: any[] = []; 107 | 108 | a.on('data', (chunk) => receivedA.push(chunk)); 109 | b.on('data', (chunk) => receivedB.push(chunk)); 110 | 111 | a.write(B`010203`); 112 | b.write(B`ABCDEF`); 113 | 114 | await tick(); 115 | 116 | expect(receivedB).toEqual([B`010203`]); 117 | expect(receivedA).toEqual([B`ABCDEF`]); 118 | }); 119 | }); 120 | 121 | describe('MinBag', () => { 122 | test('basic', () => { 123 | const bag = new MinBag(true); 124 | 125 | expect(bag.issue()).toBe(1); 126 | expect(bag.issue()).toBe(2); 127 | bag.putBack(2); 128 | expect(bag.issue()).toBe(2); 129 | bag.putBack(1); 130 | expect(bag.issue()).toBe(1); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'node:stream'; 2 | import { Params } from './params'; 3 | import dns from 'node:dns'; 4 | import path from 'node:path'; 5 | 6 | export type SocketConnectOptions = { 7 | host: string; 8 | port: number; 9 | hosts?: string[]; 10 | }; 11 | 12 | export type IPCConnectOptions = { 13 | path: string; 14 | }; 15 | 16 | export type UniversalConnectOptions = { 17 | address: string; 18 | }; 19 | 20 | export type ConnectOptions = ( 21 | | SocketConnectOptions 22 | | IPCConnectOptions 23 | | UniversalConnectOptions 24 | ) & { 25 | connector?: Connector; 26 | debug?: boolean; 27 | }; 28 | 29 | export type Connector = (options: ConnectOptions) => Promise; 30 | 31 | export type ServerOptions = { 32 | skipServerValues?: boolean; 33 | params?: Params; 34 | }; 35 | 36 | export type ClientOptions = ConnectOptions & ServerOptions; 37 | 38 | export type ParseAddressOptions = { 39 | ipv6?: boolean; 40 | }; 41 | 42 | async function resolveHost( 43 | host: string, 44 | ipv6: boolean = false 45 | ): Promise { 46 | return new Promise((resolve, reject) => { 47 | const type = ipv6 ? 'AAAA' : 'A'; 48 | dns.resolve(host, type, (err, addresses) => { 49 | if (err) reject(err); 50 | 51 | if (Array.isArray(addresses)) { 52 | resolve(addresses.map((host) => host as string)); 53 | } else { 54 | reject(new Error('Invalid dns resole')); 55 | } 56 | }); 57 | }); 58 | } 59 | 60 | export async function parseConnectOptions( 61 | options: ConnectOptions, 62 | parseOptions: ParseAddressOptions = {} 63 | ): Promise { 64 | if ('path' in options) return options; 65 | if ('host' in options) { 66 | const hosts = await resolveHost(options.host, parseOptions.ipv6); 67 | return { 68 | host: hosts[0], 69 | port: options.port, 70 | hosts, 71 | }; 72 | } 73 | return parseAddress( 74 | (options as UniversalConnectOptions).address, 75 | parseOptions 76 | ); 77 | } 78 | 79 | export async function parseAddress( 80 | address: string, 81 | options: ParseAddressOptions = {} 82 | ): Promise { 83 | const [host, port] = address.split(':', 2); 84 | 85 | if (host === 'unit') { 86 | return { 87 | path: port, 88 | }; 89 | } else { 90 | const hosts = await resolveHost(host, options.ipv6); 91 | return { 92 | host: hosts[0], 93 | port: parseInt(port), 94 | hosts, 95 | }; 96 | } 97 | } 98 | 99 | export function scriptPathToParams( 100 | script: string, 101 | method: string, 102 | documentRoot: string 103 | ): Params { 104 | const scriptFile = path.isAbsolute(script) 105 | ? script 106 | : path.join(documentRoot, script); 107 | 108 | return { 109 | DOCUMENT_ROOT: documentRoot, 110 | REQUEST_METHOD: method, 111 | REQUEST_URI: scriptFile, 112 | QUERY_STRING: '', 113 | 114 | SCRIPT_NAME: scriptFile, 115 | SCRIPT_FILENAME: scriptFile, 116 | }; 117 | } 118 | 119 | export function urlToParams( 120 | url: URL, 121 | method: string, 122 | documentRoot: string 123 | ): Params { 124 | const scriptFile = url.pathname; 125 | 126 | return { 127 | DOCUMENT_ROOT: documentRoot, 128 | REQUEST_METHOD: method, 129 | REQUEST_URI: url.toString(), 130 | QUERY_STRING: url.search.substring(1), 131 | 132 | SCRIPT_NAME: scriptFile, 133 | SCRIPT_FILENAME: path.join(documentRoot, scriptFile), 134 | }; 135 | } 136 | 137 | interface ExpressRequest { 138 | url: string; 139 | headers: Record; 140 | method: string; 141 | } 142 | 143 | export function requestToParams( 144 | req: ExpressRequest, 145 | documentRoot: string 146 | ): Params { 147 | const params = urlToParams(new URL(req.url), req.method, documentRoot); 148 | for (const header in req.headers) { 149 | const value = req.headers[header]; 150 | if (value === undefined) continue; 151 | 152 | const name = header.toLowerCase().replaceAll('-', '_'); 153 | params[name] = Array.isArray(value) ? value.join('\n') : value; 154 | } 155 | return params; 156 | } 157 | -------------------------------------------------------------------------------- /tests/writer.test.ts: -------------------------------------------------------------------------------- 1 | import { createWriter } from '../src/writer'; 2 | import { Writable, Readable } from 'node:stream'; 3 | import { headerSize, makeRecord, maxContentLength, Type } from '../src/record'; 4 | import { bytestr as B, hiByte, loByte, tick } from '../src/utils'; 5 | 6 | class TestWritable extends Writable { 7 | received: Buffer = Buffer.alloc(0); 8 | 9 | sub(start?: number, end?: number) { 10 | return this.received.subarray(start, end); 11 | } 12 | 13 | _write( 14 | chunk: Buffer, 15 | _: BufferEncoding, 16 | callback: (error?: Error | null) => void 17 | ) { 18 | this.received = Buffer.concat([this.received, chunk]); 19 | callback(); 20 | } 21 | } 22 | 23 | const prepare = () => { 24 | const writable = new TestWritable(); 25 | const writer = createWriter(writable); 26 | return { writable, writer }; 27 | }; 28 | 29 | describe('Writer and simple record', () => { 30 | test('write simple record', () => { 31 | const { writable, writer } = prepare(); 32 | 33 | writer.write( 34 | makeRecord(Type.FCGI_UNKNOWN_TYPE, 0, B`0000 0000 0000 0000`) 35 | ); 36 | expect(writable.received).toEqual( 37 | B`01 0b 0000 0008 00 00 0000 0000 0000 0000` 38 | ); 39 | }); 40 | 41 | test('write record with padding', () => { 42 | const { writable, writer } = prepare(); 43 | 44 | writer.write(makeRecord(Type.FCGI_UNKNOWN_TYPE, 0, B`0000 0000`)); 45 | expect(writable.received.length).toBe(16); 46 | expect(writable.sub(0, 12)).toEqual(B`01 0b 0000 0004 04 00 0000 0000`); 47 | }); 48 | 49 | test('write two records', () => { 50 | const { writable, writer } = prepare(); 51 | const record = makeRecord( 52 | Type.FCGI_UNKNOWN_TYPE, 53 | 0, 54 | B`0000 0000 0000 0000` 55 | ); 56 | 57 | writer.write(record); 58 | writer.write(record); 59 | 60 | expect(writable.received).toEqual( 61 | B` 62 | 01 0b 0000 0008 00 00 0000 0000 0000 0000 63 | 01 0b 0000 0008 00 00 0000 0000 0000 0000 64 | ` 65 | ); 66 | }); 67 | }); 68 | 69 | describe('Writer and streamed record', () => { 70 | test('write record with stream', async () => { 71 | const { writable, writer } = prepare(); 72 | const stream = Readable.from([B`0000 0000 0000 0001`]); 73 | 74 | writer.write(makeRecord(Type.FCGI_UNKNOWN_TYPE, 0), stream, 8); 75 | 76 | await tick(); 77 | 78 | expect(writable.received).toEqual( 79 | B`01 0b 0000 0008 00 00 0000 0000 0000 0001` 80 | ); 81 | }); 82 | 83 | test('write record with stream and padding', async () => { 84 | const { writable, writer } = prepare(); 85 | const stream = Readable.from([B`01 02 03 04 05`]); 86 | 87 | writer.write(makeRecord(Type.FCGI_UNKNOWN_TYPE, 1), stream, 5); 88 | 89 | await tick(); 90 | 91 | expect(writable.received.length).toBe(16); 92 | expect(writable.sub(0, 13)).toEqual( 93 | B`01 0b 0001 0005 03 00 0102 0304 05` 94 | ); 95 | }); 96 | 97 | test('can write another record while writing streamed record', async () => { 98 | const { writable, writer } = prepare(); 99 | const stream = Readable.from([B`0000 0000 0000 0123`]); 100 | 101 | writer.write(makeRecord(Type.FCGI_UNKNOWN_TYPE, 0), stream, 8); 102 | writer.write( 103 | makeRecord(Type.FCGI_UNKNOWN_TYPE, 1, B`0123 4567 89AB CDEF`) 104 | ); 105 | 106 | await tick(); 107 | 108 | // The order of bytes is not guaranteed. 109 | // In this case, because stream one is async, 110 | // the second write wins so that it comes first. 111 | expect(writable.received).toEqual( 112 | B`01 0b 0001 0008 00 00 0123 4567 89AB CDEF 113 | 01 0b 0000 0008 00 00 0000 0000 0000 0123` 114 | ); 115 | }); 116 | 117 | test('stream with the size bigger than record limit', async () => { 118 | const seed = '0123456789ABCDEFabcdefghijklmnop'; 119 | const data = seed.repeat(16 * 16 * 8); 120 | expect(data.length).toBeGreaterThan(maxContentLength); 121 | 122 | const { writable, writer } = prepare(); 123 | const stream = Readable.from(data); 124 | writer.write( 125 | makeRecord(Type.FCGI_UNKNOWN_TYPE, 0), 126 | stream, 127 | data.length 128 | ); 129 | await tick(); 130 | 131 | const contentLength1 = 132 | Math.floor(maxContentLength / writer.alignment) * writer.alignment; 133 | expect(writable.received.length).toBe( 134 | headerSize + contentLength1 + headerSize + 8 135 | ); 136 | expect(writable.sub(0, headerSize)).toEqual( 137 | B`01 0b 0000 ${[ 138 | hiByte(contentLength1), 139 | loByte(contentLength1), 140 | ]} 00 00` 141 | ); 142 | expect( 143 | writable.sub( 144 | headerSize + contentLength1, 145 | headerSize + contentLength1 + headerSize 146 | ) 147 | ).toEqual(B`01 0b 0000 ${[0, data.length - contentLength1]} 00 00`); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Duplex, PassThrough } from 'node:stream'; 2 | import { EventEmitter } from 'node:events'; 3 | import { execSync } from 'node:child_process'; 4 | 5 | export function alignedSize(size: number, alignment: number): number { 6 | return Math.floor((size + alignment - 1) / alignment) * alignment; 7 | } 8 | 9 | export function hiByte(val: number): number { 10 | return loByte(val >> 8); 11 | } 12 | 13 | export function loByte(val: number): number { 14 | return val & 0xff; 15 | } 16 | 17 | export function word(hi: number, lo: number): number { 18 | return (hi << 8) + lo; 19 | } 20 | 21 | export function hiWord(val: number): number { 22 | return loWord(val >> 16); 23 | } 24 | 25 | export function loWord(val: number): number { 26 | return val & 0xffff; 27 | } 28 | 29 | export function dword(hi: number, lo: number): number { 30 | return (hi << 16) + lo; 31 | } 32 | 33 | export function bytestr( 34 | strs: TemplateStringsArray, 35 | ...exprs: (string | number | number[])[] 36 | ): Buffer { 37 | const bytes = []; 38 | for (let str of strs) { 39 | while (str.length > 0) { 40 | str = str.trim(); 41 | if (str) { 42 | const twoDigits = str.substring(0, 2); 43 | if (!twoDigits.match(/[0-9A-Fa-f]{2}/)) { 44 | throw new SyntaxError('invalid hex digits'); 45 | } 46 | str = str.substring(2); 47 | bytes.push(parseInt(twoDigits, 16)); 48 | } 49 | } 50 | 51 | if (exprs.length > 0) { 52 | let expr = exprs[0]; 53 | exprs.shift(); 54 | 55 | if (typeof expr === 'number') { 56 | expr = [expr]; 57 | } 58 | const buffer = Buffer.from(expr); 59 | for (const value of buffer) { 60 | bytes.push(value); 61 | } 62 | } 63 | } 64 | return Buffer.from(bytes); 65 | } 66 | 67 | export function tick() { 68 | return new Promise((resolve) => { 69 | setTimeout(resolve, 17); 70 | }); 71 | } 72 | 73 | export class StreamPair extends Duplex { 74 | static create() { 75 | const a = new StreamPair(); 76 | const b = new StreamPair(); 77 | 78 | a.other = b; 79 | b.other = a; 80 | 81 | return [a, b]; 82 | } 83 | 84 | buffer: PassThrough = new PassThrough(); 85 | other: StreamPair | null = null; 86 | 87 | constructor() { 88 | super(); 89 | 90 | this.once('finish', () => { 91 | if (this.other) { 92 | this.other.buffer.end(); 93 | } 94 | }); 95 | 96 | this.buffer.once('end', () => this.push(null)); 97 | } 98 | 99 | _read() { 100 | const chunk = this.buffer.read(); 101 | if (chunk) return this.push(chunk); 102 | 103 | this.buffer.once('readable', () => this._read()); 104 | } 105 | 106 | _write(data: any, enc: BufferEncoding, cb: any) { 107 | if (this.other) { 108 | this.other.buffer.write(data, enc, cb); 109 | } 110 | } 111 | } 112 | 113 | export function once( 114 | target: EventEmitter, 115 | event: string, 116 | timeout: number | undefined 117 | ): Promise { 118 | return new Promise((resolve, reject) => { 119 | const listener = (values: T) => { 120 | resolve(values); 121 | clearTimeout(ticket); 122 | }; 123 | 124 | target.once(event, listener); 125 | 126 | const ticket = setTimeout(() => { 127 | target.removeListener(event, listener); 128 | reject( 129 | new Error( 130 | `Timeout: cannot receive value record in ${timeout} ms` 131 | ) 132 | ); 133 | }, timeout); 134 | }); 135 | } 136 | 137 | export class MinBag { 138 | maxIssued = 0; 139 | available: number[] = []; 140 | readonly needCheck: boolean; 141 | 142 | constructor(needCheck = false) { 143 | this.needCheck = needCheck; 144 | } 145 | 146 | issue(): number { 147 | if (this.available.length > 0) { 148 | return this.available.shift() as number; 149 | } 150 | 151 | const id = ++this.maxIssued; 152 | if (this.needCheck) this.check(); 153 | return id; 154 | } 155 | 156 | putBack(id: number) { 157 | if (id <= 0 || id > this.maxIssued) { 158 | throw new Error('Invalid id was returned'); 159 | } 160 | 161 | if (id === this.maxIssued) { 162 | this.maxIssued--; 163 | } else { 164 | this.available.push(id); 165 | } 166 | 167 | if (this.needCheck) this.check(); 168 | } 169 | 170 | check(): void { 171 | for (const id of this.available) { 172 | if (id >= this.maxIssued) { 173 | throw new Error( 174 | `invalid id is in 'available': ${id} > maxIssued(${this.maxIssued})` 175 | ); 176 | } 177 | } 178 | } 179 | } 180 | 181 | export function findExec(command: string): boolean { 182 | try { 183 | execSync(`which ${command}`, { stdio: 'ignore' }); 184 | return true; 185 | } catch (_e) { 186 | return false; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /tests/reader.test.ts: -------------------------------------------------------------------------------- 1 | import { Reader } from '../src/reader'; 2 | import { Readable } from 'node:stream'; 3 | import { bytestr as B, hiByte, loByte, tick } from '../src/utils'; 4 | import { FCGIRecord, makeRecord, Type } from '../src/record'; 5 | 6 | interface TestController { 7 | reader: Reader; 8 | decoded: FCGIRecord[]; 9 | error?: Error; 10 | } 11 | 12 | function createReader(chunks: Buffer[]): TestController { 13 | const reader = new Reader(); 14 | 15 | const decoded: FCGIRecord[] = []; 16 | const controller: TestController = { reader, decoded }; 17 | 18 | reader.on('record', (record: FCGIRecord) => { 19 | decoded.push(record); 20 | }); 21 | reader.on('error', (err) => { 22 | controller.error = err; 23 | }); 24 | 25 | const source = Readable.from(chunks); 26 | source.pipe(reader); 27 | return controller; 28 | } 29 | 30 | async function readChunks(chunks: Buffer[]) { 31 | const test = createReader(chunks); 32 | await tick(); 33 | return test.decoded; 34 | } 35 | 36 | // 01 02 0000 0000 00 00 37 | const abortRecord = makeRecord(Type.FCGI_ABORT_REQUEST); 38 | 39 | // 01 0b 0000 0008 00 00 0000 0000 0000 0000 40 | const unknownRecord = makeRecord(Type.FCGI_UNKNOWN_TYPE, 0, Buffer.alloc(8)); 41 | const eor = B`01 04 0001 0000 00 00`; 42 | const eorWithRequestId = (id: number) => { 43 | const buffer = Buffer.from(eor); 44 | buffer[2] = hiByte(id); 45 | buffer[3] = loByte(id); 46 | return buffer; 47 | }; 48 | 49 | describe('Reader', () => { 50 | test('simple read', async () => { 51 | expect(await readChunks([B``])).toEqual([]); 52 | expect(await readChunks([B`01`])).toEqual([]); 53 | 54 | expect(await readChunks([B`01 02 0000 0000 00 00`])).toEqual([ 55 | abortRecord, 56 | ]); 57 | 58 | expect( 59 | await readChunks([B`01 02 0000 0000 00 00 01 02 0000 0000 00 00`]) 60 | ).toEqual([abortRecord, abortRecord]); 61 | 62 | expect( 63 | await readChunks([ 64 | B`01 02 0000 0000 00 00`, 65 | B`01 02 0000 0000 00 00`, 66 | ]) 67 | ).toEqual([abortRecord, abortRecord]); 68 | }); 69 | 70 | test('record with content', async () => { 71 | expect( 72 | await readChunks([B`01 0b 0000 0008 00 00 0000000000000000`]) 73 | ).toEqual([unknownRecord]); 74 | }); 75 | 76 | test('separated chunk', async () => { 77 | expect(await readChunks([B`01 02 0000 0000`, B`00 00`])).toEqual([ 78 | abortRecord, 79 | ]); 80 | }); 81 | }); 82 | 83 | describe('Reader for key-value pairs', () => { 84 | test('no EOR', async () => { 85 | const record = B`01 04 0001 000c 00 00 05 05 ${'helloworld'}`; 86 | 87 | // without EOR, it won't return record 88 | expect(await readChunks([record])).toEqual([]); 89 | }); 90 | 91 | test('empty key-value pairs', async () => { 92 | expect(await readChunks([eor])).toEqual([ 93 | makeRecord(Type.FCGI_PARAMS, 1, {}), 94 | ]); 95 | }); 96 | 97 | test('simple key-value pairs', async () => { 98 | const record = B`01 04 0001 000c 00 00 05 05 ${'helloworld'}`; 99 | expect(await readChunks([record, eor])).toEqual([ 100 | makeRecord(Type.FCGI_PARAMS, 1, { hello: 'world' }), 101 | ]); 102 | }); 103 | 104 | test('key-value pairs with 1 chunk', async () => { 105 | const record = B`01 04 0001 000c 00 00 05 05 ${'helloworld'}`; 106 | // 1 chunk 107 | expect(await readChunks([Buffer.concat([record, eor])])).toEqual([ 108 | makeRecord(Type.FCGI_PARAMS, 1, { hello: 'world' }), 109 | ]); 110 | }); 111 | 112 | test('two pairs of key-value', async () => { 113 | const record = B`01 04 0001 000c 00 00 05 05 ${'helloworld'}`; 114 | expect( 115 | await readChunks([ 116 | record, 117 | B`01 04 0001 0008 00 00 03 03 ${'foobar'}`, 118 | eor, 119 | ]) 120 | ).toEqual([ 121 | makeRecord(Type.FCGI_PARAMS, 1, { hello: 'world', foo: 'bar' }), 122 | ]); 123 | }); 124 | 125 | test('key-value pairs stream', async () => { 126 | expect( 127 | await readChunks([ 128 | Buffer.concat([ 129 | B`01 04 0001 0007 00 00 05 05 ${'hello'}`, 130 | B`01 04 0001 0005 00 00 ${'world'}`, 131 | eor, 132 | ]), 133 | ]) 134 | ).toEqual([makeRecord(Type.FCGI_PARAMS, 1, { hello: 'world' })]); 135 | }); 136 | 137 | test('incomplete pairs', async () => { 138 | const test = createReader([ 139 | B`01 04 0001 0007 00 00 05 05 ${'hello'}`, // value is missing 140 | eor, 141 | ]); 142 | 143 | await tick(); 144 | 145 | expect(test.error).toBeInstanceOf(Error); 146 | expect(test.decoded).toEqual([]); 147 | }); 148 | 149 | test('cannot receive other record while handling params steam', async () => { 150 | const test = createReader([ 151 | B`01 04 0001 000c 00 00 05 05 ${'helloworld'}`, 152 | B`01 0B 0001 0008 00 00 03 03 ${'foobar'}`, 153 | eor, 154 | ]); 155 | 156 | await tick(); 157 | 158 | expect(test.error).toBeInstanceOf(Error); 159 | expect(test.decoded).toEqual([]); 160 | }); 161 | 162 | test('param streams with multiple request id', async () => { 163 | const test = createReader([ 164 | B`01 04 0001 000c 00 00 05 05 ${'helloworld'}`, 165 | B`01 04 0002 0008 00 00 03 03 ${'foobar'}`, 166 | eor, 167 | eorWithRequestId(2), 168 | ]); 169 | 170 | await tick(); 171 | 172 | expect(test.decoded).toEqual([ 173 | makeRecord(Type.FCGI_PARAMS, 1, { hello: 'world' }), 174 | makeRecord(Type.FCGI_PARAMS, 2, { foo: 'bar' }), 175 | ]); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/record.ts: -------------------------------------------------------------------------------- 1 | import { Params } from './params'; 2 | import { 3 | alignedSize, 4 | dword, 5 | hiByte, 6 | hiWord, 7 | loByte, 8 | loWord, 9 | word, 10 | } from './utils'; 11 | import { encode as encodeParams } from './params'; 12 | 13 | export enum Type { 14 | FCGI_BEGIN_REQUEST = 1, 15 | FCGI_ABORT_REQUEST, 16 | FCGI_END_REQUEST, 17 | FCGI_PARAMS, 18 | FCGI_STDIN, 19 | FCGI_STDOUT, 20 | FCGI_STDERR, 21 | FCGI_DATA, 22 | FCGI_GET_VALUES, 23 | FCGI_GET_VALUES_RESULT, 24 | FCGI_UNKNOWN_TYPE, 25 | } 26 | 27 | export const defaultAlignment = 8; 28 | export const maxContentLength = 0xffff; 29 | export const maxAlignment = 256; 30 | export const headerSize = 8; 31 | 32 | export enum Role { 33 | Responder = 1, 34 | Authorizer = 2, 35 | Filter = 3, 36 | } 37 | 38 | export class BeginRequestBody { 39 | readonly role: Role; 40 | readonly keepConnection: boolean; 41 | 42 | static bufferSize = 8; 43 | 44 | constructor(role: Role, keepConnection: boolean) { 45 | this.role = role; 46 | this.keepConnection = keepConnection; 47 | } 48 | 49 | get flags(): number { 50 | let value = 0; 51 | if (this.keepConnection) value |= 0x1; 52 | return value; 53 | } 54 | 55 | encode(): Buffer { 56 | const buffer = Buffer.allocUnsafe(BeginRequestBody.bufferSize); 57 | buffer[0] = hiByte(this.role); 58 | buffer[1] = loByte(this.role); 59 | buffer[2] = this.flags; 60 | return buffer; 61 | } 62 | 63 | static decode(buffer: Buffer): BeginRequestBody | null { 64 | if (buffer.byteLength !== BeginRequestBody.bufferSize) return null; 65 | const role = word(buffer[0], buffer[1]); 66 | const keepConnection = !!(buffer[2] & 0x1); 67 | return new BeginRequestBody(role, keepConnection); 68 | } 69 | } 70 | 71 | export class EndRequestBody { 72 | readonly appStatus: number; 73 | readonly protocolStatus: number; 74 | 75 | static bufferSize = 8; 76 | 77 | constructor(appStatus: number, protocolStatus: number) { 78 | this.appStatus = appStatus; 79 | this.protocolStatus = protocolStatus; 80 | } 81 | 82 | encode(): Buffer { 83 | const buffer = Buffer.allocUnsafe(BeginRequestBody.bufferSize); 84 | const val1 = hiWord(this.appStatus); 85 | const val2 = loWord(this.appStatus); 86 | buffer[0] = hiByte(val1); 87 | buffer[1] = loByte(val1); 88 | buffer[2] = hiByte(val2); 89 | buffer[3] = loByte(val2); 90 | buffer[4] = loByte(this.protocolStatus); 91 | return buffer; 92 | } 93 | 94 | static decode(buffer: Buffer): EndRequestBody | null { 95 | if (buffer.byteLength !== EndRequestBody.bufferSize) return null; 96 | const appStatus = dword( 97 | word(buffer[0], buffer[1]), 98 | word(buffer[2], buffer[3]) 99 | ); 100 | const protocolStatus = buffer[4]; 101 | return new EndRequestBody(appStatus, protocolStatus); 102 | } 103 | } 104 | 105 | type EncodableBody = Buffer | BeginRequestBody | EndRequestBody | null; 106 | export type RecordBody = EncodableBody | Params; 107 | 108 | interface EncodableRecord { 109 | type: Type; 110 | requestId: number; 111 | body: EncodableBody; 112 | } 113 | 114 | export interface FCGIRecord { 115 | type: Type; 116 | requestId: number; 117 | body: RecordBody; 118 | } 119 | 120 | // 3. Records 121 | 122 | export interface Header { 123 | version: number; 124 | type: Type; 125 | requestId: number; 126 | contentLength: number; 127 | paddingLength: number; 128 | } 129 | 130 | export function makeRecord( 131 | type: Type, 132 | requestId = 0, 133 | body: RecordBody = null 134 | ): FCGIRecord { 135 | return { 136 | type, 137 | requestId, 138 | body, 139 | }; 140 | } 141 | 142 | function encodeBody(body: RecordBody): EncodableBody { 143 | if (!body || body instanceof Buffer) return body; 144 | 145 | if (body instanceof BeginRequestBody) { 146 | return body.encode(); 147 | } 148 | 149 | if (body instanceof EndRequestBody) { 150 | return body.encode(); 151 | } 152 | 153 | if (typeof body === 'object') { 154 | return encodeParams(body); 155 | } 156 | 157 | throw new Error(`Cannot encode body value: ${body}`); 158 | } 159 | 160 | function encodableRecord(record: FCGIRecord): EncodableRecord { 161 | if (!record.body || record.body instanceof Buffer) { 162 | return record as EncodableRecord; 163 | } 164 | return { 165 | type: record.type, 166 | requestId: record.requestId, 167 | body: encodeBody(record.body), 168 | }; 169 | } 170 | 171 | export function setBody( 172 | { type, requestId, body: _ }: FCGIRecord, 173 | newBody: string | EncodableBody 174 | ): EncodableRecord { 175 | if (typeof newBody === 'string') { 176 | newBody = Buffer.from(newBody); 177 | } 178 | return { type, requestId, body: newBody }; 179 | } 180 | 181 | function contentSize(record: EncodableRecord): number { 182 | if (record.body instanceof Buffer) { 183 | return record.body.byteLength; 184 | } 185 | return 0; 186 | } 187 | 188 | export function paddingSize(contentLength: number, alignment: number): number { 189 | const totalSize = headerSize + contentLength; 190 | return alignedSize(totalSize, alignment) - totalSize; 191 | } 192 | 193 | export function encode( 194 | record: FCGIRecord, 195 | alignment: number = defaultAlignment, 196 | headerOnly = false 197 | ): Buffer { 198 | if (alignment > maxAlignment) { 199 | throw new RangeError(`alignment must be <= ${maxAlignment}`); 200 | } 201 | 202 | const record_ = encodableRecord(record); 203 | 204 | const length = contentSize(record_); 205 | if (length >= 0x10000) { 206 | throw new RangeError('body must be < 0x10000'); 207 | } 208 | 209 | const withBody = !headerOnly && record_.body instanceof Buffer; 210 | const padding = paddingSize(length, alignment); 211 | 212 | const bufferSize = headerSize + (withBody ? length + padding : 0); 213 | const buffer = Buffer.alloc(bufferSize); 214 | 215 | buffer[0] = 1; // version 216 | buffer[1] = record_.type; // type 217 | buffer[2] = hiByte(record_.requestId); // requestId (Hi) 218 | buffer[3] = loByte(record_.requestId); // requestId (Lo) 219 | buffer[4] = hiByte(length); // contentLength (Hi) 220 | buffer[5] = loByte(length); // contentLength (Lo) 221 | buffer[6] = padding; // paddingLength 222 | buffer[7] = 0; // reserved 223 | 224 | if ( 225 | !headerOnly && 226 | record_.body instanceof Buffer && 227 | record_.body.byteLength > 0 228 | ) { 229 | record_.body.copy(buffer, headerSize); 230 | } 231 | 232 | return buffer; 233 | } 234 | 235 | export function decodeHeader(buffer: Buffer): Header | undefined { 236 | if (buffer.byteLength < headerSize) { 237 | return undefined; 238 | } 239 | const version = buffer[0]; 240 | const type = buffer[1]; 241 | const requestId = word(buffer[2], buffer[3]); 242 | const contentLength = word(buffer[4], buffer[5]); 243 | const paddingLength = buffer[6]; 244 | const reserved = buffer[7]; 245 | return { 246 | version, 247 | type, 248 | requestId, 249 | contentLength, 250 | paddingLength, 251 | }; 252 | } 253 | 254 | function recordSize(header: Header): number { 255 | return headerSize + header.contentLength + header.paddingLength; 256 | } 257 | 258 | export function decodableSize(buffer: Buffer): number | undefined { 259 | const header = decodeHeader(buffer); 260 | if (!header) { 261 | return undefined; 262 | } 263 | const expectedSize = recordSize(header); 264 | return buffer.byteLength >= expectedSize ? expectedSize : undefined; 265 | } 266 | 267 | function decodeBody(type: Type, buffer: Buffer): RecordBody { 268 | switch (type) { 269 | case Type.FCGI_BEGIN_REQUEST: 270 | return BeginRequestBody.decode(buffer); 271 | case Type.FCGI_END_REQUEST: 272 | return EndRequestBody.decode(buffer); 273 | case Type.FCGI_PARAMS: 274 | case Type.FCGI_GET_VALUES: 275 | case Type.FCGI_GET_VALUES_RESULT: 276 | case Type.FCGI_ABORT_REQUEST: 277 | case Type.FCGI_UNKNOWN_TYPE: 278 | case Type.FCGI_STDIN: 279 | case Type.FCGI_STDOUT: 280 | case Type.FCGI_STDERR: 281 | case Type.FCGI_DATA: 282 | return buffer; 283 | } 284 | } 285 | 286 | export function decode(buffer: Buffer): FCGIRecord { 287 | const header = decodeHeader(buffer); 288 | if (!header) { 289 | throw new RangeError('buffer too short'); 290 | } 291 | const expectedSize = recordSize(header); 292 | if (buffer.byteLength < expectedSize) { 293 | throw new RangeError('buffer too short'); 294 | } 295 | 296 | if (header.contentLength > 0) { 297 | const body = buffer.subarray( 298 | headerSize, 299 | headerSize + header.contentLength 300 | ); 301 | return makeRecord( 302 | header.type, 303 | header.requestId, 304 | decodeBody(header.type, body) 305 | ); 306 | } else { 307 | return makeRecord(header.type, header.requestId); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /tests/client.test.ts: -------------------------------------------------------------------------------- 1 | import type { ClientOptions, ConnectOptions } from '../src/options'; 2 | import { createClient, Request } from '../src/client'; 3 | import { Reader } from '../src/reader'; 4 | import { 5 | BeginRequestBody, 6 | FCGIRecord, 7 | makeRecord, 8 | RecordBody, 9 | Role, 10 | Type, 11 | } from '../src/record'; 12 | import { bytestr as B, once, StreamPair, tick } from '../src/utils'; 13 | import { createWriter } from '../src/writer'; 14 | import { Readable } from 'node:stream'; 15 | import { Params } from '../src/params'; 16 | 17 | function clientForTest({ 18 | skipServerValues = true, 19 | onRecord = (record: FCGIRecord) => {}, 20 | } = {}) { 21 | const [stream, other] = StreamPair.create(); 22 | 23 | const sentChunks: Buffer[] = []; 24 | other.on('data', (chunk: Buffer) => { 25 | sentChunks.push(chunk); 26 | }); 27 | 28 | const sentRecords: FCGIRecord[] = []; 29 | const reader = new Reader(); 30 | 31 | reader.on('record', (record: FCGIRecord) => { 32 | sentRecords.push(record); 33 | onRecord(record); 34 | }); 35 | 36 | other.pipe(reader); 37 | 38 | const writer = createWriter(other); 39 | 40 | const options: ClientOptions = { 41 | address: 'localhost:9000', 42 | connector: async (options: ConnectOptions): Promise => 43 | stream, 44 | skipServerValues, 45 | debug: false, 46 | }; 47 | 48 | const client = createClient(options); 49 | 50 | function sendToClient(type: Type, id: number, body: RecordBody) { 51 | writer.write(makeRecord(type, id, body)); 52 | } 53 | 54 | return { 55 | client, // client 56 | stream, // our endpoint 57 | other, // their endpoint (Buffer) 58 | reader, // their endpoint via Reader (Record) 59 | writer, // their write endpoint using Record 60 | sentChunks, // chunks arrived to their end 61 | sentRecords, // records arrived to their end 62 | sendToClient, 63 | }; 64 | } 65 | 66 | async function requestForTest({ 67 | count = 1, 68 | skipServerValues = true, 69 | onRecord = (record: FCGIRecord) => {}, 70 | } = {}) { 71 | const result = clientForTest({ skipServerValues, onRecord }); 72 | const { client, sendToClient } = result; 73 | 74 | let requests: Request[] = []; 75 | let error: any = undefined; 76 | 77 | try { 78 | await once(client, 'ready', 5000); 79 | for (let i = 0; i < count; i++) { 80 | requests.push(await client.begin()); 81 | } 82 | } catch (e) { 83 | console.error(e); 84 | error = e; 85 | } 86 | 87 | const request = requests[0]; 88 | function sendToRequest(type: Type, body: RecordBody) { 89 | sendToClient(type, request.id, body); 90 | } 91 | 92 | return { ...result, requests, request, error, sendToRequest }; 93 | } 94 | 95 | function serverValuesResult({ 96 | maxConns = 100, 97 | maxReqs = 100, 98 | mpxsConns = true, 99 | } = {}) { 100 | return makeRecord(Type.FCGI_GET_VALUES_RESULT, 0, { 101 | FCGI_MAX_CONNS: maxConns.toString(), 102 | FCGI_MAX_REQS: maxReqs.toString(), 103 | FCGI_MPXS_CONNS: mpxsConns ? '1' : '0', 104 | }); 105 | } 106 | 107 | describe('Client', () => { 108 | test('request and receive server value', async () => { 109 | async function doIt() { 110 | const { other, client, writer } = clientForTest(); 111 | 112 | other.on('data', (_: any) => writer.write(serverValuesResult())); 113 | 114 | await tick(); 115 | return client.getServerValues(); 116 | } 117 | const values = await doIt(); 118 | 119 | expect(values).toEqual({ 120 | maxConns: 100, 121 | maxReqs: 100, 122 | mpxsConns: true, 123 | }); 124 | }); 125 | 126 | test('request must have id', async () => { 127 | async function diIt() { 128 | const { request } = await requestForTest(); 129 | return request; 130 | } 131 | 132 | const request = await diIt(); 133 | expect(request).not.toBeFalsy(); 134 | expect(request.id).not.toBe(0); 135 | }); 136 | 137 | test('request id must be unique', async () => { 138 | async function diIt() { 139 | const { requests } = await requestForTest({ count: 2 }); 140 | return requests; 141 | } 142 | 143 | const [request1, request2] = await diIt(); 144 | expect(request1.id).not.toBe(0); 145 | expect(request1.id).not.toBe(request2.id); 146 | }); 147 | 148 | test('beginRequest record must be sent', async () => { 149 | async function doIt() { 150 | const { request, sentRecords } = await requestForTest(); 151 | await tick(); 152 | return { request, record: sentRecords[0] }; 153 | } 154 | 155 | const { request, record } = await doIt(); 156 | 157 | expect(record.type).toBe(Type.FCGI_BEGIN_REQUEST); 158 | expect(record.requestId).toBe(request.id); 159 | expect(record.body).toBeInstanceOf(BeginRequestBody); 160 | 161 | const body = record.body as BeginRequestBody; 162 | expect(body.role).toBe(Role.Responder); 163 | expect(body.keepConnection).toBeFalsy(); 164 | }); 165 | 166 | test('can send params over request', async () => { 167 | const headers = { 168 | Hello: 'world', 169 | Foo: 'bar', 170 | }; 171 | 172 | async function doIt() { 173 | const { sentRecords, request } = await requestForTest(); 174 | 175 | request.sendParams(headers); 176 | await tick(); 177 | 178 | return { records: sentRecords.slice(1) }; 179 | } 180 | 181 | const { records } = await doIt(); 182 | expect(records.length).toBe(1); 183 | const record = records[0]; 184 | const body = record.body as Params; 185 | expect(body.Hello).toEqual('world'); 186 | expect(body.Foo).toEqual('bar'); 187 | }); 188 | 189 | test('can send empty params and receive one record', async () => { 190 | async function doIt() { 191 | const { sentRecords, request } = await requestForTest(); 192 | 193 | request.sendParams({}); 194 | await tick(); 195 | 196 | return { records: sentRecords.slice(1) }; 197 | } 198 | 199 | const { records } = await doIt(); 200 | expect(records.length).toBe(1); 201 | expect(records[0].body).not.toBeFalsy(); 202 | expect(records[0].body).not.toEqual({}); 203 | }); 204 | 205 | test('can send LARGE params over request', async () => {}); 206 | 207 | test('can send stdin as string', async () => { 208 | async function doIt() { 209 | const { request, sentRecords } = await requestForTest(); 210 | request.sendParams({}).send('Hello world'); 211 | await tick(); 212 | return sentRecords.slice(2); 213 | } 214 | 215 | const records = await doIt(); 216 | expect(records.length).toBe(2); 217 | const body = records[0].body as Buffer; 218 | expect(body).toEqual(B`${'Hello world'}`); 219 | }); 220 | 221 | test('can send stdin from stream', async () => { 222 | async function doIt() { 223 | const { request, sentRecords } = await requestForTest(); 224 | const source = Readable.from(['Hello world\n', B`01 23 45 67 89`]); 225 | request.sendParams({}).send(source); 226 | 227 | await tick(); 228 | return sentRecords.slice(2); 229 | } 230 | 231 | const records = await doIt(); 232 | expect(records.length).toBe(3); 233 | const body1 = records[0].body as Buffer; 234 | expect(body1).toEqual(B`${'Hello world\n'}`); 235 | 236 | const body2 = records[1].body as Buffer; 237 | expect(body2).toEqual(B`0123456789`); 238 | }); 239 | 240 | test('must receive stdout after sending params', async () => { 241 | const content = B`${'Hello back!'}`; 242 | async function doIt() { 243 | const { request, sendToRequest } = await requestForTest({ 244 | onRecord: (record) => { 245 | if (record.type !== Type.FCGI_PARAMS) return; 246 | 247 | sendToRequest(Type.FCGI_STDOUT, content); 248 | }, 249 | }); 250 | 251 | const received: Buffer[] = []; 252 | request.on('stdout', (chunk: Buffer) => { 253 | received.push(chunk); 254 | }); 255 | 256 | request.sendParams({}); 257 | request.done(); 258 | 259 | await tick(); 260 | return received; 261 | } 262 | 263 | const received = await doIt(); 264 | expect(received.length).toBe(1); 265 | 266 | const body = received[0]; 267 | expect(body).toEqual(content); 268 | expect(body).not.toBe(content); 269 | }); 270 | 271 | test('might receive stderr', async () => {}); 272 | test('error when getting stdout before sending params', async () => {}); 273 | 274 | test('after receiving EndRequest, request must be closed and the id is available', async () => { 275 | const content = B`${'Hello'}`; 276 | async function doIt() { 277 | const { client, request, sendToRequest, other } = 278 | await requestForTest({ 279 | onRecord: (record) => { 280 | if (record.type !== Type.FCGI_PARAMS) return; 281 | 282 | sendToRequest(Type.FCGI_STDOUT, content); 283 | sendToRequest(Type.FCGI_STDOUT, null); 284 | sendToRequest( 285 | Type.FCGI_END_REQUEST, 286 | B`00000000 00 000000` 287 | ); 288 | other.end(); 289 | }, 290 | }); 291 | 292 | let closed = false; 293 | request.on('end', () => { 294 | closed = true; 295 | }); 296 | request.sendParams({ foo: 'bar' }).done(); 297 | 298 | await tick(); 299 | return { client, request, closed }; 300 | } 301 | 302 | const { client, request, closed } = await doIt(); 303 | expect(closed).toBeTruthy(); 304 | 305 | expect(request.closed).toBeTruthy(); 306 | expect(client.getRequest(request.id)).toBeUndefined(); 307 | }); 308 | 309 | test('when request is finished, the id is available to use', async () => {}); 310 | 311 | test('error ending request before closing stdin', async () => {}); 312 | test('error ending request before closing stdout', async () => {}); 313 | test('error ending request before closing stderr', async () => {}); 314 | 315 | test('aborting request', async () => {}); 316 | }); 317 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ESNext" /* Specify what module code is generated. */, 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 77 | 78 | /* Type Checking */ 79 | "strict": true /* Enable all strict type-checking options. */, 80 | "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["src/**/*"], 104 | "exclude": ["node_modules"] 105 | } 106 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { Duplex, Readable } from 'node:stream'; 2 | import { EventEmitter } from 'node:events'; 3 | import { createConnection } from 'node:net'; 4 | import { Reader } from './reader'; 5 | import { 6 | BeginRequestBody, 7 | EndRequestBody, 8 | FCGIRecord, 9 | makeRecord, 10 | Role, 11 | Type, 12 | } from './record'; 13 | import { createWriter, Writer } from './writer'; 14 | import { Params } from './params'; 15 | import { MinBag, once } from './utils'; 16 | import { 17 | ClientOptions, 18 | ConnectOptions, 19 | parseConnectOptions, 20 | urlToParams, 21 | } from './options'; 22 | 23 | export interface ServerValues { 24 | maxConns: number; 25 | maxReqs: number; 26 | mpxsConns: boolean; 27 | } 28 | 29 | export function createClient(options: ClientOptions): Client { 30 | const client = new ClientImpl(options); 31 | return client; 32 | } 33 | 34 | export type Body = string | Buffer | { stream: Readable; length: number }; 35 | 36 | export interface Client extends EventEmitter { 37 | // no payload requests 38 | get(url: URL, params: Params): Promise; 39 | head(url: URL, params: Params): Promise; 40 | options(url: URL, params: Params): Promise; 41 | delete(url: URL, params: Params): Promise; 42 | 43 | // requests with payload 44 | post(url: URL, body: Body, params: Params): Promise; 45 | put(url: URL, body: Body, params: Params): Promise; 46 | delete(url: URL, body: Body, params: Params): Promise; 47 | patch(url: URL, body: Body, params: Params): Promise; 48 | 49 | on(event: 'ready', listener: () => void): this; 50 | on(event: 'end', listener: () => void): this; 51 | on(event: 'error', listener: (err: Error) => void): this; 52 | 53 | // low level interface 54 | 55 | request(args: { 56 | url: URL; 57 | method: string; 58 | params: Params; 59 | body?: Body; 60 | }): Promise; 61 | 62 | getServerValues(): Promise; 63 | 64 | begin(): Promise; 65 | begin(role: Role): Promise; 66 | begin(keepConn: boolean): Promise; 67 | begin(role: Role, keepConn: boolean): Promise; 68 | 69 | getRequest(id: number): Request | undefined; 70 | } 71 | 72 | export interface Request extends EventEmitter { 73 | readonly id: number; 74 | readonly closed: boolean; 75 | 76 | sendParams(params: Params): this; 77 | 78 | send(body: string): this; 79 | send(body: Buffer): this; 80 | send(stream: Readable): this; 81 | done(): this; 82 | 83 | on(event: 'stdout', listener: (buffer: Buffer) => void): this; 84 | on(event: 'stderr', listener: (error: string) => void): this; 85 | on(event: 'end', listener: (appStatus: number) => void): this; 86 | } 87 | 88 | export interface Response { 89 | statusCode: number; 90 | headers: Params; 91 | text: string; 92 | body: Buffer; 93 | json(): any; 94 | } 95 | 96 | export interface Connection extends EventEmitter { 97 | send(record: FCGIRecord): void; 98 | end(): void; 99 | 100 | on(event: 'connect', listener: () => void): this; 101 | on(event: 'record', listener: (record: FCGIRecord) => void): this; 102 | on(event: 'close', listener: (status: number) => void): this; 103 | } 104 | 105 | const valuesToGet = ['FCGI_MAX_CONNS', 'FCGI_MAX_REQS', 'FCGI_MPXS_CONNS']; 106 | 107 | const defaultParams: Params = { 108 | REMOTE_ADDR: '127.0.0.1', 109 | GATEWAY_PROTOCOL: 'CGI/1.1', 110 | SERVER_SOFTWARE: 'fastcgi-kit; node/' + process.version, 111 | }; 112 | 113 | class ConnectionImpl extends EventEmitter implements Connection { 114 | stream: Duplex; 115 | reader: Reader; 116 | writer: Writer; 117 | debug: boolean; 118 | 119 | constructor(stream: Duplex, debug: boolean) { 120 | super(); 121 | 122 | this.stream = stream; 123 | this.debug = debug; 124 | 125 | this.reader = new Reader(); 126 | this.stream.pipe(this.reader); 127 | this.reader.on('record', (record: FCGIRecord) => { 128 | if (this.debug) console.log('received:', record); 129 | this.emit('record', record); 130 | }); 131 | 132 | stream.on('end', () => { 133 | if (this.debug) console.log('Stream ended'); 134 | this.emit('end'); 135 | }); 136 | this.writer = createWriter(this.stream); 137 | } 138 | 139 | send(record: FCGIRecord): void { 140 | this.writer.write(record); 141 | if (this.debug) console.log('sent:', record); 142 | } 143 | 144 | end(): void { 145 | this.stream.end(); 146 | } 147 | } 148 | 149 | async function connectToHost(options: ConnectOptions): Promise { 150 | const opts = await parseConnectOptions(options); 151 | return new Promise(async (resolve, reject) => { 152 | const conn = createConnection(opts); 153 | conn.once('connect', () => { 154 | conn.removeAllListeners(); 155 | resolve(conn); 156 | }); 157 | conn.once('error', reject); 158 | }); 159 | } 160 | 161 | class ClientImpl extends EventEmitter implements Client { 162 | readonly clientOptions: ClientOptions; 163 | keptConnection: Connection | undefined; 164 | requests: Map = new Map(); 165 | idBag: MinBag = new MinBag(); 166 | maxConns = 1; 167 | maxReqs = 1; 168 | mpxsConns = false; 169 | 170 | constructor(clientOptions: ClientOptions) { 171 | super(); 172 | 173 | this.clientOptions = clientOptions; 174 | 175 | if (this.clientOptions.skipServerValues) { 176 | setImmediate(() => this.emit('ready')); 177 | } else { 178 | this.getServerValues() 179 | .then((values: ServerValues) => { 180 | this.maxConns = values.maxConns; 181 | this.maxReqs = values.maxReqs; 182 | this.mpxsConns = values.mpxsConns; 183 | 184 | this.emit('ready'); 185 | }) 186 | .catch((err: Error) => { 187 | this.emitError(err); 188 | }); 189 | } 190 | } 191 | 192 | async connect(keepConn: boolean): Promise { 193 | if (keepConn && this.keptConnection) return this.keptConnection; 194 | 195 | const { connector = connectToHost, debug = false } = this.clientOptions; 196 | const connection = new ConnectionImpl( 197 | await connector(this.clientOptions), 198 | debug 199 | ); 200 | 201 | connection.on('record', (record: FCGIRecord) => 202 | this.handleRecord(record) 203 | ); 204 | 205 | if (keepConn) { 206 | this.keptConnection = connection; 207 | } 208 | 209 | return connection; 210 | } 211 | 212 | async get(url: URL, params: Params): Promise { 213 | return this.request({ url, method: 'GET', params }); 214 | } 215 | 216 | async head(url: URL, params: Params): Promise { 217 | return this.request({ url, method: 'HEAD', params }); 218 | } 219 | 220 | async options(url: URL, params: Params): Promise { 221 | return this.request({ url, method: 'OPTIONS', params }); 222 | } 223 | 224 | async delete( 225 | url: URL, 226 | bodyOrParams: Params | Body, 227 | params: Params | undefined = undefined 228 | ): Promise { 229 | let body: Body | undefined = undefined; 230 | 231 | if (params === undefined) { 232 | params = bodyOrParams as Params; 233 | return this.request({ url, method: 'DELETE', params }); 234 | } else { 235 | body = bodyOrParams as Body; 236 | return this.request({ url, method: 'DELETE', body, params }); 237 | } 238 | } 239 | 240 | async post(url: URL, body: Body, params: Params): Promise { 241 | return this.request({ url, method: 'POST', body, params }); 242 | } 243 | 244 | async put(url: URL, body: Body, params: Params): Promise { 245 | return this.request({ url, method: 'PUT', body, params }); 246 | } 247 | 248 | async patch(url: URL, body: Body, params: Params): Promise { 249 | return this.request({ url, method: 'PATCH', body, params }); 250 | } 251 | 252 | async request({ 253 | url, 254 | method, 255 | body = undefined, 256 | params, 257 | }: { 258 | url: URL; 259 | method: string; 260 | body?: Body; 261 | params: Params; 262 | }): Promise { 263 | const documentRoot = 264 | this.clientOptions?.params?.DOCUMENT_ROOT ?? process.cwd(); 265 | 266 | params = { 267 | ...(this.clientOptions.params ?? {}), 268 | ...urlToParams(url, method, documentRoot), 269 | ...params, 270 | }; 271 | 272 | if (body !== undefined) { 273 | let contentLength = 0; 274 | 275 | if (body instanceof Buffer) { 276 | contentLength = body.byteLength; 277 | } else if (typeof body === 'string') { 278 | contentLength = Buffer.from(body).byteLength; 279 | } else { 280 | contentLength = body.length; 281 | } 282 | 283 | if (!('CONTENT_TYPE' in params)) { 284 | params.CONTENT_TYPE = 'application/x-www-form-urlencoded'; 285 | } 286 | 287 | if ('CONTENT_LENGTH' in params) { 288 | if (params.CONTENT_LENGTH !== `${contentLength}`) { 289 | return Promise.reject( 290 | new Error( 291 | 'Content length is already set and does not match body length' 292 | ) 293 | ); 294 | } 295 | } else { 296 | params.CONTENT_LENGTH = `${contentLength}`; 297 | } 298 | } 299 | 300 | return new Promise(async (resolve, reject) => { 301 | const request = await this.begin(); 302 | const result: Buffer[] = []; 303 | let error: string = ''; 304 | 305 | request.on('stdout', (buffer: Buffer) => result.push(buffer)); 306 | request.on('stderr', (line: string) => (error += line)); 307 | 308 | request.on('end', (appStatus) => { 309 | if (appStatus) { 310 | reject(new Error(error)); 311 | } else { 312 | resolve(new ResponseImpl(result)); 313 | } 314 | }); 315 | 316 | request.sendParams(params); 317 | 318 | if (body !== undefined) { 319 | if (typeof body === 'string') { 320 | request.send(body); 321 | } else if (body instanceof Buffer) { 322 | request.send(body); 323 | } else { 324 | request.send(body.stream); 325 | } 326 | } else { 327 | request.done(); 328 | } 329 | }); 330 | } 331 | 332 | async getServerValues(): Promise { 333 | const connection = await this.connect( 334 | this.keptConnection !== undefined 335 | ); 336 | 337 | const valuesToAsk = valuesToGet.reduce((result: Params, name) => { 338 | result[name] = ''; 339 | return result; 340 | }, {}); 341 | const record = makeRecord(Type.FCGI_GET_VALUES, 0, valuesToAsk); 342 | connection.send(record); 343 | 344 | const values = await once(this, 'values', 3000); 345 | if (values.mpxsConns && !this.keptConnection) { 346 | this.keptConnection = connection; 347 | } 348 | 349 | return values; 350 | } 351 | 352 | async begin(arg1?: Role | boolean, arg2?: boolean): Promise { 353 | const detectRole = (): Role => { 354 | if (arguments.length === 2) { 355 | if (typeof arg1 === 'boolean') 356 | throw new TypeError('invalid role arguments'); 357 | return arg1 as Role; 358 | } 359 | if (arguments.length === 1) { 360 | if (typeof arg1 !== 'boolean') return arg1 as Role; 361 | } 362 | return Role.Responder; 363 | }; 364 | 365 | const detectKeepConn = (): boolean => { 366 | if (arguments.length === 2) { 367 | if (typeof arg2 !== 'boolean') 368 | throw new TypeError('invalid keepConn arguments'); 369 | return arg2; 370 | } 371 | if (arguments.length === 1) { 372 | if (typeof arg1 === 'boolean') return arg1; 373 | } 374 | return this.mpxsConns; 375 | }; 376 | 377 | const role = detectRole(); 378 | const keepConn = detectKeepConn(); 379 | 380 | const request = new RequestImpl( 381 | this, 382 | await this.connect(this.mpxsConns), 383 | this.issueRequestId(), 384 | role, 385 | keepConn 386 | ); 387 | 388 | request.sendBegin(role, keepConn); 389 | if (this.clientOptions.debug) { 390 | request.on('stdout', (buffer: Buffer) => { 391 | console.log(buffer); 392 | console.log(buffer.toString()); 393 | }); 394 | request.on('stderr', (err: string) => { 395 | console.error(err); 396 | }); 397 | } 398 | 399 | this.requests.set(request.id, request); 400 | return request; 401 | } 402 | 403 | getRequest(id: number): RequestImpl | undefined { 404 | return this.requests.get(id); 405 | } 406 | 407 | endRequest(id: number): void { 408 | const request = this.getRequest(id); 409 | if (request) { 410 | this.requests.delete(id); 411 | this.idBag.putBack(id); 412 | } 413 | } 414 | 415 | handleRecord(record: FCGIRecord): void { 416 | if (record.requestId) { 417 | const request = this.getRequest(record.requestId); 418 | if (!request) { 419 | this.emitError(`Invalid request id: ${record.requestId}`); 420 | return; 421 | } 422 | request.handleRecord(record); 423 | return; 424 | } 425 | 426 | switch (record.type) { 427 | case Type.FCGI_GET_VALUES_RESULT: 428 | this.handleGetValuesResult(record.body as Params); 429 | break; 430 | 431 | default: 432 | console.error('Client: unhandled record', record); 433 | this.emitError(`Client: unhandled record: ${record.type}`); 434 | break; 435 | } 436 | } 437 | 438 | issueRequestId(): number { 439 | return this.idBag.issue(); 440 | } 441 | 442 | handleGetValuesResult(body: Params | null) { 443 | if (body) { 444 | const values = { 445 | maxConns: parseInt(body.FCGI_MAX_CONNS ?? '1'), 446 | maxReqs: parseInt(body.FCGI_MAX_REQS ?? '1'), 447 | mpxsConns: !!parseInt(body.FCGI_MPXS_CONNS ?? '0'), 448 | }; 449 | 450 | this.emit('values', values); 451 | } 452 | } 453 | 454 | emitError(error: string | Error) { 455 | if (typeof error === 'string') { 456 | error = new Error(error); 457 | } 458 | this.emit('error', error); 459 | } 460 | } 461 | 462 | class RequestImpl extends EventEmitter implements Request { 463 | client: ClientImpl; 464 | connection: Connection; 465 | id: number; 466 | closed = false; 467 | role: Role; 468 | keepConnection: boolean; 469 | 470 | constructor( 471 | client: ClientImpl, 472 | connection: Connection, 473 | id: number, 474 | role: Role, 475 | keepConnection: boolean 476 | ) { 477 | super(); 478 | 479 | this.client = client; 480 | this.connection = connection; 481 | this.id = id; 482 | this.role = role; 483 | this.keepConnection = keepConnection; 484 | } 485 | 486 | write( 487 | type: Type, 488 | body: Buffer | Params | BeginRequestBody | null = null 489 | ): this { 490 | const record = this.makeRecord(type, body); 491 | this.connection.send(record); 492 | return this; 493 | } 494 | 495 | makeRecord( 496 | type: Type, 497 | body: Buffer | Params | BeginRequestBody | null = null 498 | ): FCGIRecord { 499 | return makeRecord(type, this.id, body); 500 | } 501 | 502 | sendBegin(role: Role, keepConn: boolean): this { 503 | return this.write( 504 | Type.FCGI_BEGIN_REQUEST, 505 | new BeginRequestBody(role, keepConn) 506 | ); 507 | } 508 | 509 | sendParams(params: Params): this { 510 | return this.write(Type.FCGI_PARAMS, { 511 | ...defaultParams, 512 | ...params, 513 | }); 514 | } 515 | 516 | send(arg: string | Buffer | Readable): this { 517 | if (arg instanceof Readable) { 518 | return this.sendFromStream(arg); 519 | } else { 520 | if (typeof arg === 'string') { 521 | arg = Buffer.from(arg); 522 | } 523 | return this.sendBuffer(arg).done(); 524 | } 525 | } 526 | 527 | handleRecord(record: FCGIRecord): void { 528 | switch (record.type) { 529 | case Type.FCGI_STDOUT: 530 | case Type.FCGI_STDERR: 531 | if (record.body) { 532 | if (record.body instanceof Buffer) { 533 | this.handleOutput( 534 | record.body, 535 | record.type === Type.FCGI_STDERR 536 | ); 537 | } else { 538 | this.emitError( 539 | 'Invalid body for FCGI_STDOUT|FCGI_STDERR' 540 | ); 541 | } 542 | } 543 | break; 544 | 545 | case Type.FCGI_END_REQUEST: 546 | if (record.body instanceof EndRequestBody) { 547 | this.handleEndRequest(record.body); 548 | } else { 549 | this.emitError('Invalid body for FCGI_END_REQUEST'); 550 | } 551 | break; 552 | } 553 | } 554 | 555 | sendBuffer(buffer: Buffer): this { 556 | return this.write(Type.FCGI_STDIN, buffer); 557 | } 558 | 559 | sendFromStream(stream: Readable): this { 560 | stream.on('data', (chunk: Buffer) => { 561 | if (typeof chunk === 'string') { 562 | chunk = Buffer.from(chunk); 563 | } 564 | this.sendBuffer(chunk); 565 | }); 566 | stream.on('end', () => this.done()); 567 | return this; 568 | } 569 | 570 | done(): this { 571 | return this.write(Type.FCGI_STDIN); 572 | } 573 | 574 | handleOutput(buffer: Buffer, stderr: boolean) { 575 | this.emit(stderr ? 'stderr' : 'stdout', buffer); 576 | } 577 | 578 | handleEndRequest(body: EndRequestBody): void { 579 | this.client.endRequest(this.id); 580 | if (this.keepConnection) { 581 | this.emit('end', body.appStatus); 582 | } else { 583 | this.connection.once('end', () => this.emit('end', body.appStatus)); 584 | } 585 | this.close(); 586 | } 587 | 588 | close(): void { 589 | if (!this.keepConnection) { 590 | this.connection.end(); 591 | } 592 | this.closed = true; 593 | } 594 | 595 | emitError(error: string | Error) { 596 | this.close(); 597 | this.client.emit('error', error); 598 | } 599 | } 600 | 601 | class ResponseImpl implements Response { 602 | statusCode: number = 200; 603 | headers: Params; 604 | text: string; 605 | 606 | constructor(stdout: Buffer[]) { 607 | const [headers, body] = Buffer.concat(stdout) 608 | .toString() 609 | .split('\r\n\r\n', 2); 610 | this.headers = headers.split('\r\n').reduce((params, line) => { 611 | const [name, value] = line.split(':', 2); 612 | params[name.trim().toLowerCase()] = value.trim(); 613 | if (name.trim().toLowerCase() === 'status') { 614 | this.statusCode = parseInt(value); 615 | } 616 | return params; 617 | }, {} as Params); 618 | this.text = body; 619 | } 620 | 621 | get body(): Buffer { 622 | return Buffer.from(this.text); 623 | } 624 | 625 | json(): any { 626 | try { 627 | return JSON.parse(this.text); 628 | } catch (e) { 629 | console.error('Invalid JSON', this.text); 630 | throw e; 631 | } 632 | } 633 | } 634 | --------------------------------------------------------------------------------