Erster Docker-Stand

This commit is contained in:
Ali
2026-02-20 16:06:40 +09:00
commit f31e2e8ed3
8818 changed files with 1605323 additions and 0 deletions

View File

@@ -0,0 +1,720 @@
import type { PGlite } from '@electric-sql/pglite'
import { createServer, Server, Socket } from 'net'
// Connection queue timeout in milliseconds
export const CONNECTION_QUEUE_TIMEOUT = 60000 // 60 seconds
/**
* Options for creating a PGLiteSocketHandler
*/
export interface PGLiteSocketHandlerOptions {
/** The PGlite database instance */
db: PGlite
/** Whether to close the socket when detached (default: false) */
closeOnDetach?: boolean
/** Print the incoming and outgoing data to the console in hex and ascii */
inspect?: boolean
/** Enable debug logging of method calls */
debug?: boolean
}
/**
* Low-level handler for a single socket connection to PGLite
* Handles the raw protocol communication between a socket and PGLite
*/
export class PGLiteSocketHandler extends EventTarget {
readonly db: PGlite
private socket: Socket | null = null
private active = false
private closeOnDetach: boolean
private resolveLock?: () => void
private rejectLock?: (err: Error) => void
private inspect: boolean
private debug: boolean
private readonly id: number
// Static counter for generating unique handler IDs
private static nextHandlerId = 1
/**
* Create a new PGLiteSocketHandler
* @param options Options for the handler
*/
constructor(options: PGLiteSocketHandlerOptions) {
super()
this.db = options.db
this.closeOnDetach = options.closeOnDetach ?? false
this.inspect = options.inspect ?? false
this.debug = options.debug ?? false
this.id = PGLiteSocketHandler.nextHandlerId++
this.log('constructor: created new handler')
}
/**
* Get the unique ID of this handler
*/
public get handlerId(): number {
return this.id
}
/**
* Log a message if debug is enabled
* @private
*/
private log(message: string, ...args: any[]): void {
if (this.debug) {
console.log(`[PGLiteSocketHandler#${this.id}] ${message}`, ...args)
}
}
/**
* Attach a socket to this handler
* @param socket The socket to attach
* @returns this handler instance
* @throws Error if a socket is already attached
*/
public async attach(socket: Socket): Promise<PGLiteSocketHandler> {
this.log(
`attach: attaching socket from ${socket.remoteAddress}:${socket.remotePort}`,
)
if (this.socket) {
throw new Error('Socket already attached')
}
this.socket = socket
this.active = true
// Ensure the PGlite instance is ready
this.log(`attach: waiting for PGlite to be ready`)
await this.db.waitReady
// Hold the lock on the PGlite instance
this.log(`attach: acquiring exclusive lock on PGlite instance`)
await new Promise<void>((resolve) => {
this.db.runExclusive(() => {
// Ensure we have the lock on the PGlite instance
resolve()
// Use a promise to hold the lock on the PGlite instance
// this can be resolved or rejected by the handler to release the lock
return new Promise<void>((resolveLock, rejectLock) => {
this.resolveLock = resolveLock
this.rejectLock = rejectLock
})
})
})
// Setup event handlers
this.log(`attach: setting up socket event handlers`)
socket.on('data', async (data) => {
try {
const result = await this.handleData(data)
this.log(`socket on data sent: ${result} bytes`)
} catch (err) {
this.log('socket on data error: ', err)
}
})
socket.on('error', (err) => this.handleError(err))
socket.on('close', () => this.handleClose())
return this
}
/**
* Detach the current socket from this handler
* @param close Whether to close the socket when detaching (overrides constructor option)
* @returns this handler instance
*/
public detach(close?: boolean): PGLiteSocketHandler {
this.log(`detach: detaching socket, close=${close ?? this.closeOnDetach}`)
if (!this.socket) {
this.log(`detach: no socket attached, nothing to do`)
return this
}
// Remove all listeners
this.socket.removeAllListeners('data')
this.socket.removeAllListeners('error')
this.socket.removeAllListeners('close')
// Close the socket if requested
if (close ?? this.closeOnDetach) {
if (this.socket.writable) {
this.log(`detach: closing socket`)
this.socket.end()
this.socket.destroy()
}
}
// Release the lock on the PGlite instance
this.log(`detach: releasing exclusive lock on PGlite instance`)
this.resolveLock?.()
this.socket = null
this.active = false
return this
}
/**
* Check if a socket is currently attached
*/
public get isAttached(): boolean {
return this.socket !== null
}
/**
* Handle incoming data from the socket
*/
private async handleData(data: Buffer): Promise<number> {
if (!this.socket || !this.active) {
this.log(`handleData: no active socket, ignoring data`)
return new Promise((_, reject) => reject(`no active socket`))
}
this.log(`handleData: received ${data.length} bytes`)
// Print the incoming data to the console
this.inspectData('incoming', data)
try {
// Process the raw protocol data
this.log(`handleData: sending data to PGlite for processing`)
const result = await this.db.execProtocolRaw(new Uint8Array(data))
this.log(`handleData: received ${result.length} bytes from PGlite`)
// Print the outgoing data to the console
this.inspectData('outgoing', result)
// Send the result back if the socket is still connected
if (this.socket && this.socket.writable && this.active) {
if (result.length <= 0) {
this.log(`handleData: cowardly refusing to send empty packet`)
return new Promise((_, reject) => reject('no data'))
}
const promise = new Promise<number>((resolve, reject) => {
this.log(`handleData: writing response to socket`)
if (this.socket) {
this.socket.write(Buffer.from(result), (err?: Error) => {
if (err) {
reject(`Error while writing to the socket ${err.toString()}`)
} else {
resolve(result.length)
}
})
} else {
reject(`No socket`)
}
})
// Emit data event with byte sizes
this.dispatchEvent(
new CustomEvent('data', {
detail: { incoming: data.length, outgoing: result.length },
}),
)
return promise
} else {
this.log(
`handleData: socket no longer writable or active, discarding response`,
)
return new Promise((_, reject) =>
reject(`No socket, not active or not writeable`),
)
}
} catch (err) {
this.log(`handleData: error processing data:`, err)
this.handleError(err as Error)
return new Promise((_, reject) =>
reject(`Error while processing data ${(err as Error).toString()}`),
)
}
}
/**
* Handle errors from the socket
*/
private handleError(err: Error): void {
this.log(`handleError:`, err)
// Emit error event
this.dispatchEvent(new CustomEvent('error', { detail: err }))
// Reject the lock on the PGlite instance
this.log(`handleError: rejecting exclusive lock on PGlite instance`)
this.rejectLock?.(err)
this.resolveLock = undefined
this.rejectLock = undefined
// Close the connection on error
this.detach(true)
}
/**
* Handle socket close event
*/
private handleClose(): void {
this.log(`handleClose: socket closed`)
this.dispatchEvent(new CustomEvent('close'))
this.detach(false) // Already closed, just clean up
}
/**
* Print data in hex and ascii to the console
*/
private inspectData(
direction: 'incoming' | 'outgoing',
data: Buffer | Uint8Array,
): void {
if (!this.inspect) return
console.log('-'.repeat(75))
if (direction === 'incoming') {
console.log('-> incoming', data.length, 'bytes')
} else {
console.log('<- outgoing', data.length, 'bytes')
}
// Process 16 bytes per line
for (let offset = 0; offset < data.length; offset += 16) {
// Calculate current chunk size (may be less than 16 for the last chunk)
const chunkSize = Math.min(16, data.length - offset)
// Build the hex representation
let hexPart = ''
for (let i = 0; i < 16; i++) {
if (i < chunkSize) {
const byte = data[offset + i]
hexPart += byte.toString(16).padStart(2, '0') + ' '
} else {
hexPart += ' ' // 3 spaces for missing bytes
}
}
// Build the ASCII representation
let asciiPart = ''
for (let i = 0; i < chunkSize; i++) {
const byte = data[offset + i]
// Use printable characters (32-126), replace others with a dot
asciiPart += byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : '.'
}
// Print the line with offset in hex, hex values, and ASCII representation
console.log(
`${offset.toString(16).padStart(8, '0')} ${hexPart} ${asciiPart}`,
)
}
}
}
/**
* Represents a queued connection with timeout
*/
interface QueuedConnection {
socket: Socket
clientInfo: {
clientAddress: string
clientPort: number
}
timeoutId: NodeJS.Timeout
}
/**
* Options for creating a PGLiteSocketServer
*/
export interface PGLiteSocketServerOptions {
/** The PGlite database instance */
db: PGlite
/** The port to listen on (default: 5432) */
port?: number
/** The host to bind to (default: 127.0.0.1) */
host?: string
/** Unix socket path to bind to (default: undefined). If specified, takes precedence over host:port */
path?: string
/** Print the incoming and outgoing data to the console in hex and ascii */
inspect?: boolean
/** Connection queue timeout in milliseconds (default: 10000) */
connectionQueueTimeout?: number
/** Enable debug logging of method calls */
debug?: boolean
}
/**
* High-level server that manages socket connections to PGLite
* Creates and manages a TCP server and handles client connections
*/
export class PGLiteSocketServer extends EventTarget {
readonly db: PGlite
private server: Server | null = null
private port?: number
private host?: string
private path?: string
private active = false
private inspect: boolean
private debug: boolean
private connectionQueueTimeout: number
private activeHandler: PGLiteSocketHandler | null = null
private connectionQueue: QueuedConnection[] = []
private handlerCount: number = 0
/**
* Create a new PGLiteSocketServer
* @param options Options for the server
*/
constructor(options: PGLiteSocketServerOptions) {
super()
this.db = options.db
if (options.path) {
this.path = options.path
} else {
if (typeof options.port === 'number') {
// Keep port undefined on port 0, will be set by the OS when we start the server.
this.port = options.port ?? options.port
} else {
this.port = 5432
}
this.host = options.host || '127.0.0.1'
}
this.inspect = options.inspect ?? false
this.debug = options.debug ?? false
this.connectionQueueTimeout =
options.connectionQueueTimeout ?? CONNECTION_QUEUE_TIMEOUT
this.log(`constructor: created server on ${this.host}:${this.port}`)
this.log(
`constructor: connection queue timeout: ${this.connectionQueueTimeout}ms`,
)
}
/**
* Log a message if debug is enabled
* @private
*/
private log(message: string, ...args: any[]): void {
if (this.debug) {
console.log(`[PGLiteSocketServer] ${message}`, ...args)
}
}
/**
* Start the socket server
* @returns Promise that resolves when the server is listening
*/
public async start(): Promise<void> {
this.log(`start: starting server on ${this.getServerConn()}`)
if (this.server) {
throw new Error('Socket server already started')
}
this.active = true
this.server = createServer((socket) => this.handleConnection(socket))
return new Promise<void>((resolve, reject) => {
if (!this.server) return reject(new Error('Server not initialized'))
this.server.on('error', (err) => {
this.log(`start: server error:`, err)
this.dispatchEvent(new CustomEvent('error', { detail: err }))
reject(err)
})
if (this.path) {
this.server.listen(this.path, () => {
this.log(`start: server listening on ${this.getServerConn()}`)
this.dispatchEvent(
new CustomEvent('listening', {
detail: { path: this.path },
}),
)
resolve()
})
} else {
const server = this.server
server.listen(this.port, this.host, () => {
const address = server.address()
// We are not using pipes, so return type should be AddressInfo
if (address === null || typeof address !== 'object') {
throw Error('Expected address info')
}
// Assign the new port number
this.port = address.port
this.log(`start: server listening on ${this.getServerConn()}`)
this.dispatchEvent(
new CustomEvent('listening', {
detail: { port: this.port, host: this.host },
}),
)
resolve()
})
}
})
}
public getServerConn(): string {
if (this.path) return this.path
return `${this.host}:${this.port}`
}
/**
* Stop the socket server
* @returns Promise that resolves when the server is closed
*/
public async stop(): Promise<void> {
this.log(`stop: stopping server`)
this.active = false
// Clear connection queue
this.log(
`stop: clearing connection queue (${this.connectionQueue.length} connections)`,
)
this.connectionQueue.forEach((queuedConn) => {
clearTimeout(queuedConn.timeoutId)
if (queuedConn.socket.writable) {
this.log(
`stop: closing queued connection from ${queuedConn.clientInfo.clientAddress}:${queuedConn.clientInfo.clientPort}`,
)
queuedConn.socket.end()
}
})
this.connectionQueue = []
// Detach active handler if exists
if (this.activeHandler) {
this.log(`stop: detaching active handler #${this.activeHandlerId}`)
this.activeHandler.detach(true)
this.activeHandler = null
}
if (!this.server) {
this.log(`stop: server not running, nothing to do`)
return Promise.resolve()
}
return new Promise<void>((resolve) => {
if (!this.server) return resolve()
this.server.close(() => {
this.log(`stop: server closed`)
this.server = null
this.dispatchEvent(new CustomEvent('close'))
resolve()
})
})
}
/**
* Get the active handler ID, or null if no active handler
*/
private get activeHandlerId(): number | null {
return this.activeHandler?.handlerId ?? null
}
/**
* Handle a new client connection
*/
private async handleConnection(socket: Socket): Promise<void> {
const clientInfo = {
clientAddress: socket.remoteAddress || 'unknown',
clientPort: socket.remotePort || 0,
}
this.log(
`handleConnection: new connection from ${clientInfo.clientAddress}:${clientInfo.clientPort}`,
)
// If server is not active, close the connection immediately
if (!this.active) {
this.log(`handleConnection: server not active, closing connection`)
socket.end()
return
}
// If we don't have an active handler or it's not attached, we can use this connection immediately
if (!this.activeHandler || !this.activeHandler.isAttached) {
this.log(`handleConnection: no active handler, attaching socket directly`)
this.dispatchEvent(new CustomEvent('connection', { detail: clientInfo }))
await this.attachSocketToNewHandler(socket, clientInfo)
return
}
// Otherwise, queue the connection
this.log(
`handleConnection: active handler #${this.activeHandlerId} exists, queueing connection`,
)
this.enqueueConnection(socket, clientInfo)
}
/**
* Add a connection to the queue
*/
private enqueueConnection(
socket: Socket,
clientInfo: { clientAddress: string; clientPort: number },
): void {
this.log(
`enqueueConnection: queueing connection from ${clientInfo.clientAddress}:${clientInfo.clientPort}, timeout: ${this.connectionQueueTimeout}ms`,
)
// Set a timeout for this queued connection
const timeoutId = setTimeout(() => {
this.log(
`enqueueConnection: timeout for connection from ${clientInfo.clientAddress}:${clientInfo.clientPort}`,
)
// Remove from queue
this.connectionQueue = this.connectionQueue.filter(
(queuedConn) => queuedConn.socket !== socket,
)
// End the connection if it's still open
if (socket.writable) {
this.log(`enqueueConnection: closing timed out connection`)
socket.end()
}
this.dispatchEvent(
new CustomEvent('queueTimeout', {
detail: { ...clientInfo, queueSize: this.connectionQueue.length },
}),
)
}, this.connectionQueueTimeout)
// Add to queue
this.connectionQueue.push({ socket, clientInfo, timeoutId })
this.log(
`enqueueConnection: connection queued, queue size: ${this.connectionQueue.length}`,
)
this.dispatchEvent(
new CustomEvent('queuedConnection', {
detail: { ...clientInfo, queueSize: this.connectionQueue.length },
}),
)
}
/**
* Process the next connection in the queue
*/
private processNextInQueue(): void {
this.log(
`processNextInQueue: processing next connection, queue size: ${this.connectionQueue.length}`,
)
// No connections in queue or server not active
if (this.connectionQueue.length === 0 || !this.active) {
this.log(
`processNextInQueue: no connections in queue or server not active, nothing to do`,
)
return
}
// Get the next connection
const nextConn = this.connectionQueue.shift()
if (!nextConn) return
this.log(
`processNextInQueue: processing connection from ${nextConn.clientInfo.clientAddress}:${nextConn.clientInfo.clientPort}`,
)
// Clear the timeout
clearTimeout(nextConn.timeoutId)
// Check if the socket is still valid
if (!nextConn.socket.writable) {
this.log(
`processNextInQueue: socket no longer writable, skipping to next connection`,
)
// Socket closed while waiting, process next in queue
this.processNextInQueue()
return
}
// Attach this socket to a new handler
this.attachSocketToNewHandler(nextConn.socket, nextConn.clientInfo).catch(
(err) => {
this.log(`processNextInQueue: error attaching socket:`, err)
this.dispatchEvent(new CustomEvent('error', { detail: err }))
// Try the next connection
this.processNextInQueue()
},
)
}
/**
* Attach a socket to a new handler
*/
private async attachSocketToNewHandler(
socket: Socket,
clientInfo: { clientAddress: string; clientPort: number },
): Promise<void> {
this.handlerCount++
this.log(
`attachSocketToNewHandler: creating new handler for ${clientInfo.clientAddress}:${clientInfo.clientPort} (handler #${this.handlerCount})`,
)
// Create a new handler for this connection
const handler = new PGLiteSocketHandler({
db: this.db,
closeOnDetach: true,
inspect: this.inspect,
debug: this.debug,
})
// Forward error events from the handler
handler.addEventListener('error', (event) => {
this.log(
`handler #${handler.handlerId}: error from handler:`,
(event as CustomEvent<Error>).detail,
)
this.dispatchEvent(
new CustomEvent('error', {
detail: (event as CustomEvent<Error>).detail,
}),
)
})
// Handle close event to process next queued connection
handler.addEventListener('close', () => {
this.log(`handler #${handler.handlerId}: closed`)
// If this is our active handler, clear it
if (this.activeHandler === handler) {
this.log(
`handler #${handler.handlerId}: was active handler, processing next connection in queue`,
)
this.activeHandler = null
// Process next connection in queue
this.processNextInQueue()
}
})
try {
// Set as active handler
this.activeHandler = handler
this.log(`handler #${handler.handlerId}: attaching socket`)
// Attach the socket to the handler
await handler.attach(socket)
this.dispatchEvent(new CustomEvent('connection', { detail: clientInfo }))
} catch (err) {
// If there was an error attaching, clean up
this.log(`handler #${handler.handlerId}: error attaching socket:`, err)
this.activeHandler = null
if (socket.writable) {
socket.end()
}
throw err
}
}
}

View File

@@ -0,0 +1,415 @@
#!/usr/bin/env node
import { PGlite, DebugLevel } from '@electric-sql/pglite'
import type { Extension, Extensions } from '@electric-sql/pglite'
import { PGLiteSocketServer } from '../index'
import { parseArgs } from 'node:util'
import { spawn, ChildProcess } from 'node:child_process'
// Define command line argument options
const args = parseArgs({
options: {
db: {
type: 'string',
short: 'd',
default: 'memory://',
help: 'Database path (relative or absolute). Use memory:// for in-memory database.',
},
port: {
type: 'string',
short: 'p',
default: '5432',
help: 'Port to listen on',
},
host: {
type: 'string',
short: 'h',
default: '127.0.0.1',
help: 'Host to bind to',
},
path: {
type: 'string',
short: 'u',
default: undefined,
help: 'unix socket to bind to. Takes precedence over host:port',
},
debug: {
type: 'string',
short: 'v',
default: '0',
help: 'Debug level (0-5)',
},
extensions: {
type: 'string',
short: 'e',
default: undefined,
help: 'Comma-separated list of extensions to load (e.g., vector,pgcrypto)',
},
run: {
type: 'string',
short: 'r',
default: undefined,
help: 'Command to run after server starts',
},
'include-database-url': {
type: 'boolean',
default: false,
help: 'Include DATABASE_URL in the environment of the subprocess',
},
'shutdown-timeout': {
type: 'string',
default: '5000',
help: 'Timeout in milliseconds for graceful subprocess shutdown (default: 5000)',
},
help: {
type: 'boolean',
short: '?',
default: false,
help: 'Show help',
},
},
})
const help = `PGlite Socket Server
Usage: pglite-server [options]
Options:
-d, --db=PATH Database path (default: memory://)
-p, --port=PORT Port to listen on (default: 5432)
-h, --host=HOST Host to bind to (default: 127.0.0.1)
-u, --path=UNIX Unix socket to bind to (default: undefined). Takes precedence over host:port
-v, --debug=LEVEL Debug level 0-5 (default: 0)
-e, --extensions=LIST Comma-separated list of extensions to load
Formats: vector, pgcrypto (built-in/contrib)
@org/package/path:exportedName (npm package)
-r, --run=COMMAND Command to run after server starts
--include-database-url Include DATABASE_URL in subprocess environment
--shutdown-timeout=MS Timeout for graceful subprocess shutdown in ms (default: 5000)
`
interface ServerConfig {
dbPath: string
port: number
host: string
path?: string
debugLevel: DebugLevel
extensionNames?: string[]
runCommand?: string
includeDatabaseUrl: boolean
shutdownTimeout: number
}
class PGLiteServerRunner {
private config: ServerConfig
private db: PGlite | null = null
private server: PGLiteSocketServer | null = null
private subprocessManager: SubprocessManager | null = null
constructor(config: ServerConfig) {
this.config = config
}
static parseConfig(): ServerConfig {
const extensionsArg = args.values.extensions as string | undefined
return {
dbPath: args.values.db as string,
port: parseInt(args.values.port as string, 10),
host: args.values.host as string,
path: args.values.path as string,
debugLevel: parseInt(args.values.debug as string, 10) as DebugLevel,
extensionNames: extensionsArg
? extensionsArg.split(',').map((e) => e.trim())
: undefined,
runCommand: args.values.run as string,
includeDatabaseUrl: args.values['include-database-url'] as boolean,
shutdownTimeout: parseInt(args.values['shutdown-timeout'] as string, 10),
}
}
private createDatabaseUrl(): string {
const { host, port, path } = this.config
if (path) {
// Unix socket connection
const socketDir = path.endsWith('/.s.PGSQL.5432')
? path.slice(0, -13)
: path
return `postgresql://postgres:postgres@/postgres?host=${encodeURIComponent(socketDir)}`
} else {
// TCP connection
return `postgresql://postgres:postgres@${host}:${port}/postgres`
}
}
private async importExtensions(): Promise<Extensions | undefined> {
if (!this.config.extensionNames?.length) {
return undefined
}
const extensions: Extensions = {}
// Built-in extensions that are not in contrib
const builtInExtensions = [
'vector',
'live',
'pg_hashids',
'pg_ivm',
'pg_uuidv7',
'pgtap',
]
for (const name of this.config.extensionNames) {
let ext: Extension | null = null
try {
// Check if this is a custom package path (contains ':')
// Format: @org/package/path:exportedName or package/path:exportedName
if (name.includes(':')) {
const [packagePath, exportName] = name.split(':')
if (!packagePath || !exportName) {
throw new Error(
`Invalid extension format '${name}'. Expected: package/path:exportedName`,
)
}
const mod = await import(packagePath)
ext = mod[exportName] as Extension
if (ext) {
extensions[exportName] = ext
console.log(
`Imported extension '${exportName}' from '${packagePath}'`,
)
}
} else if (builtInExtensions.includes(name)) {
// Built-in extension (e.g., @electric-sql/pglite/vector)
const mod = await import(`@electric-sql/pglite/${name}`)
ext = mod[name] as Extension
if (ext) {
extensions[name] = ext
console.log(`Imported extension: ${name}`)
}
} else {
// Try contrib first (e.g., @electric-sql/pglite/contrib/pgcrypto)
try {
const mod = await import(`@electric-sql/pglite/contrib/${name}`)
ext = mod[name] as Extension
} catch {
// Fall back to external package (e.g., @electric-sql/pglite-<extension>)
const mod = await import(`@electric-sql/pglite-${name}`)
ext = mod[name] as Extension
}
if (ext) {
extensions[name] = ext
console.log(`Imported extension: ${name}`)
}
}
} catch (error) {
console.error(`Failed to import extension '${name}':`, error)
throw new Error(`Failed to import extension '${name}'`)
}
}
return Object.keys(extensions).length > 0 ? extensions : undefined
}
private async initializeDatabase(): Promise<void> {
console.log(`Initializing PGLite with database: ${this.config.dbPath}`)
console.log(`Debug level: ${this.config.debugLevel}`)
const extensions = await this.importExtensions()
this.db = new PGlite(this.config.dbPath, {
debug: this.config.debugLevel,
extensions,
})
await this.db.waitReady
console.log('PGlite database initialized')
}
private setupServerEventHandlers(): void {
if (!this.server || !this.subprocessManager) {
throw new Error('Server or subprocess manager not initialized')
}
this.server.addEventListener('listening', (event) => {
const detail = (
event as CustomEvent<{ port: number; host: string } | { host: string }>
).detail
console.log(`PGLiteSocketServer listening on ${JSON.stringify(detail)}`)
// Run the command after server starts listening
if (this.config.runCommand && this.subprocessManager) {
const databaseUrl = this.createDatabaseUrl()
this.subprocessManager.spawn(
this.config.runCommand,
databaseUrl,
this.config.includeDatabaseUrl,
)
}
})
this.server.addEventListener('connection', (event) => {
const { clientAddress, clientPort } = (
event as CustomEvent<{ clientAddress: string; clientPort: number }>
).detail
console.log(`Client connected from ${clientAddress}:${clientPort}`)
})
this.server.addEventListener('error', (event) => {
const error = (event as CustomEvent<Error>).detail
console.error('Socket server error:', error)
})
}
private setupSignalHandlers(): void {
process.on('SIGINT', () => this.shutdown())
process.on('SIGTERM', () => this.shutdown())
}
async start(): Promise<void> {
try {
// Initialize database
await this.initializeDatabase()
if (!this.db) {
throw new Error('Database initialization failed')
}
// Create and setup the socket server
this.server = new PGLiteSocketServer({
db: this.db,
port: this.config.port,
host: this.config.host,
path: this.config.path,
inspect: this.config.debugLevel > 0,
})
// Create subprocess manager
this.subprocessManager = new SubprocessManager((exitCode) => {
this.shutdown(exitCode)
})
// Setup event handlers
this.setupServerEventHandlers()
this.setupSignalHandlers()
// Start the server
await this.server.start()
} catch (error) {
console.error('Failed to start PGLiteSocketServer:', error)
throw error
}
}
async shutdown(exitCode: number = 0): Promise<void> {
console.log('\nShutting down PGLiteSocketServer...')
// Terminate subprocess if running
if (this.subprocessManager) {
this.subprocessManager.terminate(this.config.shutdownTimeout)
}
// Stop server
if (this.server) {
await this.server.stop()
}
// Close database
if (this.db) {
await this.db.close()
}
console.log('Server stopped')
process.exit(exitCode)
}
}
class SubprocessManager {
private childProcess: ChildProcess | null = null
private onExit: (code: number) => void
constructor(onExit: (code: number) => void) {
this.onExit = onExit
}
get process(): ChildProcess | null {
return this.childProcess
}
spawn(
command: string,
databaseUrl: string,
includeDatabaseUrl: boolean,
): void {
console.log(`Running command: ${command}`)
// Prepare environment variables
const env = { ...process.env }
if (includeDatabaseUrl) {
env.DATABASE_URL = databaseUrl
console.log(`Setting DATABASE_URL=${databaseUrl}`)
}
// Parse and spawn the command
const commandParts = command.trim().split(/\s+/)
this.childProcess = spawn(commandParts[0], commandParts.slice(1), {
env,
stdio: 'inherit',
})
this.childProcess.on('error', (error) => {
console.error('Error running command:', error)
// If subprocess fails to start, shutdown the server
console.log('Subprocess failed to start, shutting down...')
this.onExit(1)
})
this.childProcess.on('close', (code) => {
console.log(`Command exited with code ${code}`)
this.childProcess = null
// If child process exits with non-zero code, notify parent
if (code !== null && code !== 0) {
console.log(
`Child process failed with exit code ${code}, shutting down...`,
)
this.onExit(code)
}
})
}
terminate(timeout: number): void {
if (this.childProcess) {
console.log('Terminating child process...')
this.childProcess.kill('SIGTERM')
// Give it a moment to exit gracefully, then force kill if needed
setTimeout(() => {
if (this.childProcess && !this.childProcess.killed) {
console.log('Force killing child process...')
this.childProcess.kill('SIGKILL')
}
}, timeout)
}
}
}
// Main execution
async function main() {
// Show help and exit if requested
if (args.values.help) {
console.log(help)
process.exit(0)
}
try {
const config = PGLiteServerRunner.parseConfig()
const serverRunner = new PGLiteServerRunner(config)
await serverRunner.start()
} catch (error) {
console.error('Unhandled error:', error)
process.exit(1)
}
}
// Run the main function
main()