UNPKG

ts-postgres

Version:
897 lines 36.1 kB
import { Buffer } from 'node:buffer'; import { randomBytes } from 'node:crypto'; import { constants } from 'node:os'; import { env, nextTick } from 'node:process'; import { Socket } from 'node:net'; import { TLSSocket, connect as tls, createSecureContext, } from 'node:tls'; import { EventEmitter } from 'node:events'; import { Defaults } from './defaults.js'; import * as logger from './logging.js'; import { Queue } from './queue.js'; import { makeResult, } from './result.js'; import { DatabaseError, Message, Reader, SSLResponseCode, Writer, } from './protocol.js'; import { DataFormat, } from './types.js'; import { md5 } from './utils.js'; export var SSLMode; (function (SSLMode) { SSLMode["Disable"] = "disable"; SSLMode["Prefer"] = "prefer"; SSLMode["Require"] = "require"; })(SSLMode || (SSLMode = {})); var Cleanup; (function (Cleanup) { Cleanup[Cleanup["Bind"] = 0] = "Bind"; Cleanup[Cleanup["Close"] = 1] = "Close"; Cleanup[Cleanup["ErrorHandler"] = 2] = "ErrorHandler"; Cleanup[Cleanup["ParameterDescription"] = 3] = "ParameterDescription"; Cleanup[Cleanup["PreFlight"] = 4] = "PreFlight"; Cleanup[Cleanup["RowDescription"] = 5] = "RowDescription"; })(Cleanup || (Cleanup = {})); const DEFAULTS = new Defaults(env); export class ClientImpl { /** * @param config - An optional configuration object, comprised of connection details * and client configuration. Most of the connection details can also be specified * using environment variables, see {@link Environment}. */ constructor(config = {}) { this.config = config; this.events = new EventEmitter(); this.connected = false; this.error = false; this.clientNonce = randomBytes(18).toString('base64'); this.serverSignature = null; this.expect = 5; this.stream = new Socket(); this.mustDrain = false; this.activeRow = null; this.bindQueue = new Queue(); this.closeHandlerQueue = new Queue(); this.cleanupQueue = new Queue(); this.errorHandlerQueue = new Queue(); this.preFlightQueue = new Queue(); this.rowDescriptionQueue = new Queue(); this.parameterDescriptionQueue = new Queue(); this.nextPreparedStatementId = 0; this.activeDataHandlerInfo = null; this.parameters = new Map(); this.closed = true; this.processId = null; this.secretKey = null; this.transactionStatus = null; this.encoding = config.clientEncoding || DEFAULTS.clientEncoding || 'utf-8'; this.writer = new Writer(this.encoding); this.stream.on('close', () => { this.closed = true; this.events.emit('end', null); this.ending?.(); }); this.stream.on('connect', () => { const keepAlive = typeof this.config.keepAlive === 'undefined' ? this.config.keepAlive : true; if (keepAlive) { this.stream.setKeepAlive(true); } this.closed = false; this.startup(); }); /* istanbul ignore next */ this.stream.on('error', (error) => { if (this.connecting) { this.connecting(error); } else { // Don't raise ECONNRESET errors - they can & should be // ignored during disconnect. if (error.errno === constants.errno.ECONNRESET) return; if (this.ending) { this.ending(error); } this.events.emit('end', error); } }); this.stream.on('finish', () => { this.connected = false; }); } startup() { const writer = new Writer(this.encoding); if (DEFAULTS.sslMode && Object.values(SSLMode).indexOf(DEFAULTS.sslMode) < 0) { throw new Error('Invalid SSL mode: ' + DEFAULTS.sslMode); } const ssl = this.config.ssl ?? (((DEFAULTS.sslMode || SSLMode.Disable) === SSLMode.Disable) ? SSLMode.Disable : { mode: SSLMode.Prefer, options: undefined }); const settings = { user: this.config.user || DEFAULTS.user, database: this.config.database || DEFAULTS.database, clientMinMessages: this.config.clientMinMessages, defaultTableAccessMethod: this.config.defaultTableAccessMethod, defaultTablespace: this.config.defaultTablespace, defaultTransactionIsolation: this.config.defaultTransactionIsolation, extraFloatDigits: this.config.extraFloatDigits, idleInTransactionSessionTimeout: this.config.idleInTransactionSessionTimeout, idleSessionTimeout: this.config.idleSessionTimeout, lockTimeout: this.config.lockTimeout, searchPath: this.config.searchPath, statementTimeout: this.config.statementTimeout, }; if (ssl !== SSLMode.Disable) { writer.startupSSL(); const abort = (error) => { this.handleError(error); if (!this.connecting) throw error; this.connecting(error); }; const startup = (stream) => { if (stream) this.stream = stream; writer.startup(settings); this.receive(); this.sendUsing(writer); }; this.stream.once('data', (buffer) => { const code = buffer.readInt8(0); switch (code) { // Server supports SSL connections, continue. case SSLResponseCode.Supported: break; // Server does not support SSL connections. case SSLResponseCode.NotSupported: if (ssl.mode === SSLMode.Require) { abort(new Error('Server does not support SSL connections')); } else { startup(); } return; // Any other response byte, including 'E' // (ErrorResponse) indicating a server error. default: abort(new Error('Error establishing an SSL connection')); return; } const context = ssl.options ? createSecureContext(ssl.options) : undefined; const options = { socket: this.stream, secureContext: context, ...(ssl.options ?? {}), }; const stream = tls(options, () => startup(stream)); stream.on('error', (error) => { abort(error); }); }); } else { writer.startup(settings); this.receive(); } this.sendUsing(writer); } receive() { let buffer = null; let offset = 0; let remaining = 0; this.stream.on('data', (newBuffer) => { const length = newBuffer.length; const size = length + remaining; if (buffer && remaining) { const free = buffer.length - offset - remaining; let tail = offset + remaining; if (free < length) { const tempBuf = Buffer.allocUnsafe(size); buffer.copy(tempBuf, 0, offset, tail); offset = 0; buffer = tempBuf; tail = remaining; } newBuffer.copy(buffer, tail, 0, length); } else { buffer = newBuffer; offset = 0; } try { const read = this.handle(buffer, offset, size); offset += read; remaining = size - read; } catch (error) { logger.warn(error); if (this.connecting) { this.connecting(error); } else { try { // In normal operation (including regular handling of errors), // there's nothing further to clean up at this point. while (this.handleError(error)) { logger.info('Cancelled query due to an internal error'); } } catch (error) { logger.error('Internal error occurred while cleaning up query stack'); } } this.stream.destroy(); } }); this.stream.on('drain', () => { this.mustDrain = false; this.writer.flush(); this.send(); }); } connect() { if (this.connecting) { throw new Error('Already connecting'); } if (this.error) { throw new Error("Can't connect in error state"); } const timeout = this.config.connectionTimeout ?? DEFAULTS.connectionTimeout; let p = new Promise((resolve, reject) => { this.connecting = (error) => { this.connecting = undefined; if (error) { this.stream.destroy(); reject(error); } else { resolve({ encrypted: this.stream instanceof TLSSocket, parameters: this.parameters, }); } }; }); const host = this.config.host ?? DEFAULTS.host; const port = this.config.port ?? DEFAULTS.port; if (host.indexOf('/') === 0) { this.stream.connect(host + '/.s.PGSQL.' + port); } else { this.stream.connect(port, host); } if (typeof timeout !== 'undefined') { p = Promise.race([ p, new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout} ms`)), timeout)), ]); } return p; } /** End the database connection. * */ end() { if (this.ending) { throw new Error('Already ending'); } if (this.closed) { throw new Error('Connection already closed'); } if (this.stream.destroyed) { throw new Error('Connection unexpectedly destroyed'); } if (this.connected) { this.writer.end(); this.send(); this.stream.end(); this.mustDrain = false; } else { this.stream.destroy(); } return new Promise((resolve, reject) => { this.ending = (error) => { this.ending = undefined; if (!error) resolve(); reject(error); }; }); } on(event, listener) { this.events.on(event, listener); } off(event, listener) { this.events.off(event, listener); } /** Prepare a statement for later execution. * * @returns A prepared statement object. */ prepare(text) { const query = typeof text === 'string' ? { text } : text; const providedNameOrGenerated = query.name || (this.config.preparedStatementPrefix || DEFAULTS.preparedStatementPrefix) + this.nextPreparedStatementId++; return new Promise((resolve, reject) => { const errorHandler = (error) => reject(error); this.errorHandlerQueue.push(errorHandler); this.writer.parse(providedNameOrGenerated, query.text, query.types || []); this.writer.describe(providedNameOrGenerated, 'S'); this.preFlightQueue.push({ descriptionHandler: (description) => { const types = this.parameterDescriptionQueue.shift(); this.cleanupQueue.expect(Cleanup.ParameterDescription); const close = () => { return new Promise((resolve) => { this.writer.close(providedNameOrGenerated, 'S'); this.closeHandlerQueue.push(resolve); this.cleanupQueue.push(Cleanup.Close); this.writer.flush(); this.send(); }); }; resolve({ [Symbol.asyncDispose]: close, close, execute: (values, portal, format, streams) => { const result = makeResult(query?.transform); result.nameHandler(description.names); const info = { handler: { callback: result.dataHandler, streams: streams || {}, bigints: query.bigints ?? this.config.bigints ?? true, }, description: description, }; this.bindAndExecute(info, { name: providedNameOrGenerated, portal: portal || query.portal || '', format: format || query.format || DataFormat.Binary, values: values || [], close: false, }, types || query.types); return result.iterator; }, }); }, dataHandler: null, bind: null, }); this.writer.sync(); this.cleanupQueue.push(Cleanup.PreFlight); this.cleanupQueue.push(Cleanup.ErrorHandler); this.send(); }); } /** * Send a query to the database. * * The query string is given as the first argument, or pass a {@link Query} * object which provides more control. * * @param text - The query string, or pass a {@link Query} * object which provides more control (including streaming values into a socket). * @param values - The query parameters, corresponding to $1, $2, etc. * @returns A promise for the query results. */ query(text, values) { const query = typeof text === 'string' ? { text } : text; if (this.closed && !this.connecting) { throw new Error('Connection is closed.'); } const format = query?.format; const types = query?.types; const streams = query?.streams; const portal = query?.portal || ''; const result = makeResult(query?.transform); const descriptionHandler = (description) => { result.nameHandler(description.names); }; const dataHandler = { callback: result.dataHandler, streams: streams || {}, bigints: query.bigints ?? this.config.bigints ?? true, }; if (values && values.length) { const name = query?.name || (this.config.preparedStatementPrefix || DEFAULTS.preparedStatementPrefix) + this.nextPreparedStatementId++; this.writer.parse(name, query.text, types || []); this.writer.describe(name, 'S'); this.preFlightQueue.push({ descriptionHandler: descriptionHandler, dataHandler: dataHandler, bind: { name: name, portal: portal, format: format || DataFormat.Binary, values: values, close: true, }, }); this.cleanupQueue.push(Cleanup.PreFlight); } else { const name = query.name || ''; this.writer.parse(name, query.text); this.writer.bind(name, portal); this.bindQueue.push(null); this.writer.describe(portal, 'P'); this.preFlightQueue.push({ descriptionHandler: descriptionHandler, dataHandler: dataHandler, bind: null, }); this.writer.execute(portal); this.writer.close(name, 'S'); this.cleanupQueue.push(Cleanup.Bind); this.cleanupQueue.push(Cleanup.PreFlight); this.closeHandlerQueue.push(null); this.cleanupQueue.push(Cleanup.Close); } const stack = new Error().stack; this.errorHandlerQueue.push((error) => { if (stack !== undefined) error.stack = stack.replace(/(?<=^Error: )\n/, error.toString() + '\n'); result.dataHandler(error); }); this.cleanupQueue.push(Cleanup.ErrorHandler); this.writer.sync(); this.send(); return result.iterator; } bindAndExecute(info, bind, types) { try { this.writer.bind(bind.name, bind.portal, bind.format, bind.values, types); } catch (error) { info.handler.callback(error); return; } this.bindQueue.push(info); this.writer.execute(bind.portal); this.cleanupQueue.push(Cleanup.Bind); if (bind.close) { this.writer.close(bind.name, 'S'); this.closeHandlerQueue.push(null); this.cleanupQueue.push(Cleanup.Close); } this.writer.sync(); this.errorHandlerQueue.push((error) => { info.handler.callback(error); }); this.cleanupQueue.push(Cleanup.ErrorHandler); this.send(); } handleError(error) { while (true) { switch (this.cleanupQueue.shiftMaybe()) { case undefined: return false; case Cleanup.Bind: { this.bindQueue.shift(); break; } case Cleanup.Close: { this.closeHandlerQueue.shift(); break; } case Cleanup.ErrorHandler: { const handler = this.errorHandlerQueue.shift(); handler(error); this.error = true; return true; } case Cleanup.ParameterDescription: { // This does not seem to ever happen! this.parameterDescriptionQueue.shift(); break; } case Cleanup.PreFlight: { this.preFlightQueue.shift(); break; } case Cleanup.RowDescription: { this.rowDescriptionQueue.shift(); break; } } } } [Symbol.asyncDispose]() { return this.end(); } send() { if (this.mustDrain || !this.connected) return; this.sendUsing(this.writer); } sendUsing(writer) { if (this.ending) return; if (!this.stream.writable) throw new Error('Stream not writable'); const full = writer.send(this.stream); if (full !== undefined) { this.mustDrain = !full; } } parseError(buffer) { const params = []; const length = buffer.length; let offset = 0; while (offset < length) { const next = buffer.indexOf(0, offset); if (next < 0) break; const value = buffer.subarray(offset + 1, next).toString(); // See https://www.postgresql.org/docs/current/protocol-error-fields.html const token = buffer[offset]; switch (token) { // S: case 0x53: { params[0] = value; break; } // V: // This is present only in messages generated by PostgreSQL // versions 9.6 and later, taking priority over the previous // case. case 0x56: { params[0] = value; break; } // C: case 0x43: { params[1] = value; break; } // M: case 0x4d: { params[2] = value; break; } // D: case 0x44: { params[3] = value; break; } // F: case 0x46: { params[4] = value; break; } // H: case 0x48: { params[5] = value; break; } // L: case 0x4c: { params[6] = parseInt(value); break; } // R: case 0x52: { params[7] = value; break; } // P: case 0x50: { params[8] = parseInt(value); break; } } offset = next + 1; } const [level, code, message, ...optional] = params; if (level && code && message) { return new DatabaseError(level, code, message, ...optional); } throw new Error('Malformed error response'); } handle(buffer, offset, size) { let read = 0; while (size >= this.expect + read) { let frame = offset + read; let mtype = buffer.readInt8(frame); // Fast path: retrieve data rows. if (mtype === Message.RowData) { const info = this.activeDataHandlerInfo; if (!info) { throw new Error('No active data handler'); } if (!info.description) { throw new Error('No result type information'); } const { handler: { callback, streams, bigints }, description: { columns, names }, } = info; let row = this.activeRow; const types = this.config.types; const encoding = this.encoding; const hasStreams = Object.keys(streams).length > 0; const mappedStreams = hasStreams ? names.map((name) => streams[name]) : undefined; while (true) { mtype = buffer.readInt8(frame); if (mtype !== Message.RowData) break; const bytes = buffer.readInt32BE(frame + 1) + 1; const start = frame + 5; if (size < 11 + read) { this.expect = 7; this.activeRow = row; return read; } if (row === null) { const count = buffer.readInt16BE(start); row = new Array(count); } const startRowData = start + 2; const reader = new Reader(buffer, startRowData, bytes + read); const end = reader.readRowData(row, columns, encoding, bigints, types, mappedStreams); const remaining = bytes + read - size; if (remaining > 0) { const offset = startRowData + end; buffer.writeInt8(mtype, offset - 7); buffer.writeInt32BE(bytes - end - 1, offset - 6); buffer.writeInt16BE(row.length, offset - 2); this.expect = 12; this.activeRow = row; return read + end; } callback(row); row = null; // Keep track of how much data we've consumed. frame += bytes; read += bytes; // If the next message header doesn't fit, we // break out and wait for more data to arrive. if (size < frame + 5) { this.activeRow = row; this.expect = 5; return read; } } this.activeRow = null; } const bytes = buffer.readInt32BE(frame + 1) + 1; const length = bytes - 5; if (size < bytes + read) { this.expect = bytes; break; } this.expect = 5; read += bytes; // This is the start offset of the message data. const start = frame + 5; switch (mtype) { case Message.Authentication: { const writer = new Writer(this.encoding); const code = buffer.readInt32BE(start); /* istanbul ignore next */ outer: switch (code) { case 0: { nextTick(() => { this.connecting?.(); }); break; } case 3: { const s = this.config.password || DEFAULTS.password || ''; writer.password(s); break; } case 5: { const { user = '', password = '' } = this.config; const salt = buffer.subarray(start + 4, start + 8); const shadow = md5(`${password || DEFAULTS.password}` + `${user || DEFAULTS.user}`); writer.password(`md5${md5(shadow, salt)}`); break; } case 10: { const reader = new Reader(buffer, start + 4); const mechanisms = []; while (true) { const mechanism = reader.readCString(this.encoding); if (mechanism.length === 0) break; if (writer.saslInitialResponse(mechanism, this.clientNonce)) break outer; mechanisms.push(mechanism); } throw new Error(`SASL authentication unsupported (mechanisms: ${mechanisms.join(', ')})`); } case 11: { const data = buffer .subarray(start + 4, start + length) .toString('utf8'); const password = this.config.password || DEFAULTS.password || ''; this.serverSignature = writer.saslResponse(data, password, this.clientNonce); break; } case 12: { const data = buffer .subarray(start + 4, start + length) .toString('utf8'); if (!this.serverSignature) throw new Error('Server signature missing'); writer.saslFinal(data, this.serverSignature); break; } default: throw new Error(`Unsupported authentication scheme: ${code}`); } this.sendUsing(writer); break; } case Message.BackendKeyData: { this.processId = buffer.readInt32BE(start); this.secretKey = buffer.readInt32BE(start + 4); break; } case Message.BindComplete: { const info = this.bindQueue.shift(); this.cleanupQueue.expect(Cleanup.Bind); if (info) { this.activeDataHandlerInfo = info; } break; } case Message.NoData: { const preflight = this.preFlightQueue.shift(); if (preflight.dataHandler) { const info = { handler: preflight.dataHandler, description: null, }; if (preflight.bind) { this.cleanupQueue.expect(Cleanup.ParameterDescription); this.bindAndExecute(info, preflight.bind, this.parameterDescriptionQueue.shift()); } else { this.activeDataHandlerInfo = info; } } else { preflight.descriptionHandler({ columns: new Uint32Array(0), names: [], }); } this.cleanupQueue.expect(Cleanup.PreFlight); break; } case Message.EmptyQueryResponse: case Message.CommandComplete: { const info = this.activeDataHandlerInfo; if (info) { const status = buffer .subarray(start, start + length - 1) .toString(); info.handler.callback(status || null); this.activeDataHandlerInfo = null; } break; } case Message.CloseComplete: { const handler = this.closeHandlerQueue.shift(); this.cleanupQueue.expect(Cleanup.Close); if (handler) { handler(); } break; } case Message.ErrorResponse: { const error = this.parseError(buffer.subarray(start, start + length)); if (this.connecting) throw new Error(`${error.message}: ${error.detail}`); try { this.events.emit('error', error); } catch { // If there are no subscribers for the event, an error // is raised. We're not interesting in this behavior. } if (!this.handleError(error)) { throw new Error('An error occurred without an active query'); } break; } case Message.Notice: { const notice = this.parseError(buffer.subarray(start, start + length)); this.events.emit('notice', notice); break; } case Message.NotificationResponse: { const reader = new Reader(buffer, start); const processId = reader.readInt32BE(); const channel = reader.readCString(this.encoding); const payload = reader.readCString(this.encoding); this.events.emit('notification', { processId: processId, channel: channel, payload: payload, }); break; } case Message.ParseComplete: { break; } case Message.ParameterDescription: { const length = buffer.readInt16BE(start); const types = new Array(length); for (let i = 0; i < length; i++) { const offset = start + 2 + i * 4; const dataType = buffer.readInt32BE(offset); types[i] = dataType; } this.cleanupQueue.unshift(Cleanup.ParameterDescription); this.parameterDescriptionQueue.push(types); break; } case Message.ParameterStatus: { const reader = new Reader(buffer, start); const name = reader.readCString(this.encoding); const value = reader.readCString(this.encoding); this.parameters.set(name, value); break; } case Message.ReadyForQuery: { if (this.error) { this.error = false; } else if (this.connected) { this.errorHandlerQueue.shift(); this.cleanupQueue.expect(Cleanup.ErrorHandler); } else { this.connected = true; } const status = buffer.readInt8(start); this.transactionStatus = status; this.send(); break; } case Message.RowDescription: { const preflight = this.preFlightQueue.shift(); const reader = new Reader(buffer, start); const description = reader.readRowDescription(this.config.types); preflight.descriptionHandler(description); if (preflight.dataHandler) { const info = { handler: preflight.dataHandler, description: description, }; if (preflight.bind) { this.cleanupQueue.expect(Cleanup.ParameterDescription); this.bindAndExecute(info, preflight.bind, this.parameterDescriptionQueue.shift()); } else { this.activeDataHandlerInfo = info; } } this.cleanupQueue.expect(Cleanup.PreFlight); break; } default: { logger.warn(`Message not implemented: ${mtype}`); break; } } } return read; } } //# sourceMappingURL=client.js.map