Last active
April 3, 2025 18:35
-
-
Save mikaelvesavuori/f838c02e4da491ce53368b52264330d2 to your computer and use it in GitHub Desktop.
MikroChat bundled
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// MikroChat - See LICENSE file for copyright and license details. | |
// node_modules/mikroauth/lib/index.mjs | |
import f from 'node:crypto'; | |
import { URL } from 'node:url'; | |
// node_modules/mikroconf/lib/chunk-DLM37L3L.mjs | |
var ValidationError = class extends Error { | |
constructor(message) { | |
super(message); | |
this.name = 'ValidationError'; | |
this.message = message || 'Validation did not pass'; | |
this.cause = { statusCode: 400 }; | |
} | |
}; | |
// node_modules/mikroconf/lib/chunk-DAEGQ4NM.mjs | |
import { existsSync, readFileSync } from 'node:fs'; | |
var MikroConf = class { | |
config = {}; | |
options = []; | |
validators = []; | |
autoValidate = true; | |
/** | |
* @description Creates a new MikroConf instance. | |
*/ | |
constructor(options) { | |
const configFilePath = options?.configFilePath; | |
const args = options?.args || []; | |
const configuration = options?.config || {}; | |
this.options = options?.options || []; | |
this.validators = options?.validators || []; | |
if (options?.autoValidate !== void 0) | |
this.autoValidate = options.autoValidate; | |
this.config = this.createConfig(configFilePath, args, configuration); | |
} | |
/** | |
* @description Deep merges two objects. | |
*/ | |
deepMerge(target, source) { | |
const result = { ...target }; | |
for (const key in source) { | |
if (source[key] === void 0) continue; | |
if ( | |
source[key] !== null && | |
typeof source[key] === 'object' && | |
!Array.isArray(source[key]) && | |
key in target && | |
target[key] !== null && | |
typeof target[key] === 'object' && | |
!Array.isArray(target[key]) | |
) { | |
result[key] = this.deepMerge(target[key], source[key]); | |
} else if (source[key] !== void 0) result[key] = source[key]; | |
} | |
return result; | |
} | |
/** | |
* @description Sets a value at a nested path in an object. | |
*/ | |
setValueAtPath(obj, path, value) { | |
const parts = path.split('.'); | |
let current = obj; | |
for (let i = 0; i < parts.length - 1; i++) { | |
const part = parts[i]; | |
if (!(part in current) || current[part] === null) current[part] = {}; | |
else if (typeof current[part] !== 'object') current[part] = {}; | |
current = current[part]; | |
} | |
const lastPart = parts[parts.length - 1]; | |
current[lastPart] = value; | |
} | |
/** | |
* @description Gets a value from a nested path in an object. | |
*/ | |
getValueAtPath(obj, path) { | |
const parts = path.split('.'); | |
let current = obj; | |
for (const part of parts) { | |
if (current === void 0 || current === null) return void 0; | |
current = current[part]; | |
} | |
return current; | |
} | |
/** | |
* @description Creates a configuration object by merging defaults, config file settings, | |
* explicit input, and CLI arguments. | |
*/ | |
createConfig(configFilePath, args = [], configuration = {}) { | |
const defaults3 = {}; | |
for (const option of this.options) { | |
if (option.defaultValue !== void 0) | |
this.setValueAtPath(defaults3, option.path, option.defaultValue); | |
} | |
let fileConfig = {}; | |
if (configFilePath && existsSync(configFilePath)) { | |
try { | |
const fileContent = readFileSync(configFilePath, 'utf8'); | |
fileConfig = JSON.parse(fileContent); | |
console.log(`Loaded configuration from ${configFilePath}`); | |
} catch (error) { | |
console.error( | |
`Error reading config file: ${error instanceof Error ? error.message : String(error)}` | |
); | |
} | |
} | |
const cliConfig = this.parseCliArgs(args); | |
let mergedConfig = this.deepMerge({}, defaults3); | |
mergedConfig = this.deepMerge(mergedConfig, fileConfig); | |
mergedConfig = this.deepMerge(mergedConfig, configuration); | |
mergedConfig = this.deepMerge(mergedConfig, cliConfig); | |
return mergedConfig; | |
} | |
/** | |
* @description Parses command line arguments into a configuration object based on defined options. | |
*/ | |
parseCliArgs(args) { | |
const cliConfig = {}; | |
let i = args[0]?.endsWith('node') || args[0]?.endsWith('node.exe') ? 2 : 0; | |
while (i < args.length) { | |
const arg = args[i++]; | |
const option = this.options.find((opt) => opt.flag === arg); | |
if (option) { | |
if (option.isFlag) { | |
this.setValueAtPath(cliConfig, option.path, true); | |
} else if (i < args.length && !args[i].startsWith('-')) { | |
let value = args[i++]; | |
if (option.parser) { | |
try { | |
value = option.parser(value); | |
} catch (error) { | |
console.error( | |
`Error parsing value for ${option.flag}: ${error instanceof Error ? error.message : String(error)}` | |
); | |
continue; | |
} | |
} | |
if (option.validator) { | |
const validationResult = option.validator(value); | |
if ( | |
validationResult !== true && | |
typeof validationResult === 'string' | |
) { | |
console.error( | |
`Invalid value for ${option.flag}: ${validationResult}` | |
); | |
continue; | |
} | |
if (validationResult === false) { | |
console.error(`Invalid value for ${option.flag}`); | |
continue; | |
} | |
} | |
this.setValueAtPath(cliConfig, option.path, value); | |
} else { | |
console.error(`Missing value for option ${arg}`); | |
} | |
} | |
} | |
return cliConfig; | |
} | |
/** | |
* @description Validates the configuration against defined validators. | |
*/ | |
validate() { | |
for (const validator of this.validators) { | |
const value = this.getValueAtPath(this.config, validator.path); | |
const result = validator.validator(value, this.config); | |
if (result === false) throw new ValidationError(validator.message); | |
if (typeof result === 'string') throw new ValidationError(result); | |
} | |
} | |
/** | |
* @description Returns the complete configuration. | |
* @returns The configuration object. | |
*/ | |
get() { | |
if (this.autoValidate) this.validate(); | |
return this.config; | |
} | |
/** | |
* @description Gets a specific configuration value by path. | |
* @param path The dot-notation path to the configuration value. | |
* @param defaultValue Optional default value if the path doesn't exist. | |
*/ | |
getValue(path, defaultValue) { | |
const value = this.getValueAtPath(this.config, path); | |
return value !== void 0 ? value : defaultValue; | |
} | |
/** | |
* @description Sets a specific configuration value by path. | |
* @param path The dot-notation path to set. | |
* @param value The value to set. | |
*/ | |
setValue(path, value) { | |
if (typeof value === 'object' && value !== null && !Array.isArray(value)) { | |
const currentValue = this.getValueAtPath(this.config, path) || {}; | |
if (typeof currentValue === 'object' && !Array.isArray(currentValue)) { | |
const mergedValue = this.deepMerge(currentValue, value); | |
this.setValueAtPath(this.config, path, mergedValue); | |
return; | |
} | |
} | |
this.setValueAtPath(this.config, path, value); | |
} | |
/** | |
* @description Generates help text based on the defined options. | |
*/ | |
getHelpText() { | |
let help = 'Available configuration options:\n\n'; | |
for (const option of this.options) { | |
help += `${option.flag}${option.isFlag ? '' : ' <value>'} | |
`; | |
if (option.description) | |
help += ` ${option.description} | |
`; | |
if (option.defaultValue !== void 0) | |
help += ` Default: ${JSON.stringify(option.defaultValue)} | |
`; | |
help += '\n'; | |
} | |
return help; | |
} | |
}; | |
// node_modules/mikroconf/lib/chunk-OCFH3FON.mjs | |
var parsers = { | |
/** | |
* @description Parses a string to an integer. | |
*/ | |
int: (value) => { | |
const trimmedValue = value.trim(); | |
if (!/^[+-]?\d+$/.test(trimmedValue)) | |
throw new Error(`Cannot parse "${value}" as an integer`); | |
const parsed = Number.parseInt(trimmedValue, 10); | |
if (Number.isNaN(parsed)) | |
throw new Error(`Cannot parse "${value}" as an integer`); | |
return parsed; | |
}, | |
/** | |
* @description Parses a string to a float. | |
*/ | |
float: (value) => { | |
const trimmedValue = value.trim(); | |
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(trimmedValue)) { | |
if (trimmedValue === 'Infinity' || trimmedValue === '-Infinity') | |
return trimmedValue === 'Infinity' | |
? Number.POSITIVE_INFINITY | |
: Number.NEGATIVE_INFINITY; | |
throw new Error(`Cannot parse "${value}" as a number`); | |
} | |
const parsed = Number.parseFloat(trimmedValue); | |
if (Number.isNaN(parsed)) | |
throw new Error(`Cannot parse "${value}" as a number`); | |
return parsed; | |
}, | |
/** | |
* @description Parses a string to a boolean. | |
*/ | |
boolean: (value) => { | |
const lowerValue = value.trim().toLowerCase(); | |
if (['true', 'yes', '1', 'y'].includes(lowerValue)) return true; | |
if (['false', 'no', '0', 'n'].includes(lowerValue)) return false; | |
throw new Error(`Cannot parse "${value}" as a boolean`); | |
}, | |
/** | |
* @description Parses a comma-separated string to an array. | |
*/ | |
array: (value) => { | |
return value.split(',').map((item) => item.trim()); | |
}, | |
/** | |
* @description Parses a JSON string. | |
*/ | |
json: (value) => { | |
try { | |
return JSON.parse(value); | |
} catch (_error) { | |
throw new Error(`Cannot parse "${value}" as JSON`); | |
} | |
} | |
}; | |
// node_modules/mikroauth/lib/index.mjs | |
import { EventEmitter } from 'node:events'; | |
// node_modules/mikromail/lib/chunk-47VXJTWV.mjs | |
var ValidationError2 = class extends Error { | |
constructor(message) { | |
super(); | |
this.name = 'ValidationError'; | |
this.message = message; | |
this.cause = { statusCode: 400 }; | |
} | |
}; | |
// node_modules/mikromail/lib/chunk-YVXB6HCK.mjs | |
import { | |
existsSync as existsSync2, | |
readFileSync as readFileSync2 | |
} from 'node:fs'; | |
var Configuration = class { | |
config; | |
defaults = { | |
configFilePath: 'mikromail.config.json', | |
args: [] | |
}; | |
/** | |
* @description Creates a new Configuration instance. | |
*/ | |
constructor(options) { | |
const configuration = options?.config || {}; | |
const configFilePath = | |
options?.configFilePath || this.defaults.configFilePath; | |
const args = options?.args || this.defaults.args; | |
this.config = this.create(configFilePath, args, configuration); | |
} | |
/** | |
* @description Creates a configuration object by merging defaults, config file settings, | |
* and CLI arguments (in order of increasing precedence) | |
* @param configFilePath Path to the configuration file. | |
* @param args Command line arguments array. | |
* @param configuration User-provided configuration input. | |
* @returns The merged configuration object. | |
*/ | |
create(configFilePath, args, configuration) { | |
const defaults3 = { | |
host: '', | |
user: '', | |
password: '', | |
port: 465, | |
secure: true, | |
debug: false, | |
maxRetries: 2 | |
}; | |
let fileConfig = {}; | |
if (existsSync2(configFilePath)) { | |
try { | |
const fileContent = readFileSync2(configFilePath, 'utf8'); | |
fileConfig = JSON.parse(fileContent); | |
console.log(`Loaded configuration from ${configFilePath}`); | |
} catch (error) { | |
console.error( | |
`Error reading config file: ${error instanceof Error ? error.message : String(error)}` | |
); | |
} | |
} | |
const cliConfig = this.parseCliArgs(args); | |
return { | |
...defaults3, | |
...configuration, | |
...fileConfig, | |
...cliConfig | |
}; | |
} | |
/** | |
* @description Parses command line arguments into a configuration object. | |
* @param args Command line arguments array. | |
* @returns Parsed CLI configuration. | |
*/ | |
parseCliArgs(args) { | |
const cliConfig = {}; | |
for (let i = 2; i < args.length; i++) { | |
const arg = args[i]; | |
switch (arg) { | |
case '--host': | |
if (i + 1 < args.length) cliConfig.host = args[++i]; | |
break; | |
case '--user': | |
if (i + 1 < args.length) cliConfig.user = args[++i]; | |
break; | |
case '--password': | |
if (i + 1 < args.length) cliConfig.password = args[++i]; | |
break; | |
case '--port': | |
if (i + 1 < args.length) { | |
const value = Number.parseInt(args[++i], 10); | |
if (!Number.isNaN(value)) cliConfig.port = value; | |
} | |
break; | |
case '--secure': | |
cliConfig.secure = true; | |
break; | |
case '--debug': | |
cliConfig.debug = true; | |
break; | |
case '--retries': | |
if (i + 1 < args.length) { | |
const value = Number.parseInt(args[++i], 10); | |
if (!Number.isNaN(value)) cliConfig.maxRetries = value; | |
} | |
break; | |
} | |
} | |
return cliConfig; | |
} | |
/** | |
* @description Validates the configuration. | |
* @throws Error if the configuration is invalid. | |
*/ | |
validate() { | |
if (!this.config.host) throw new ValidationError2('Host value not found'); | |
} | |
/** | |
* @description Returns the complete configuration. | |
* @returns The configuration object. | |
*/ | |
get() { | |
this.validate(); | |
return this.config; | |
} | |
}; | |
// node_modules/mikromail/lib/chunk-UDLJWUFN.mjs | |
import { promises as dnsPromises } from 'node:dns'; | |
function validateEmail(email) { | |
try { | |
const [localPart, domain] = email.split('@'); | |
if (!localPart || localPart.length > 64) return false; | |
if ( | |
localPart.startsWith('.') || | |
localPart.endsWith('.') || | |
localPart.includes('..') | |
) | |
return false; | |
if (!/^[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~.]+$/.test(localPart)) return false; | |
if (!domain || domain.length > 255) return false; | |
if (domain.startsWith('[') && domain.endsWith(']')) { | |
const ipContent = domain.slice(1, -1); | |
if (ipContent.startsWith('IPv6:')) return true; | |
const ipv4Regex = /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/; | |
return ipv4Regex.test(ipContent); | |
} | |
if (domain.startsWith('.') || domain.endsWith('.') || domain.includes('..')) | |
return false; | |
const domainParts = domain.split('.'); | |
if ( | |
domainParts.length < 2 || | |
domainParts[domainParts.length - 1].length < 2 | |
) | |
return false; | |
for (const part of domainParts) { | |
if (!part || part.length > 63) return false; | |
if (!/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$/.test(part)) return false; | |
} | |
return true; | |
} catch (_error) { | |
return false; | |
} | |
} | |
async function verifyMXRecords(domain) { | |
try { | |
const records = await dnsPromises.resolveMx(domain); | |
return !!records && records.length > 0; | |
} catch (_error) { | |
return false; | |
} | |
} | |
async function verifyEmailDomain(email) { | |
try { | |
const domain = email.split('@')[1]; | |
if (!domain) return false; | |
return await verifyMXRecords(domain); | |
} catch (_error) { | |
return false; | |
} | |
} | |
// node_modules/mikromail/lib/chunk-NGT3KX7A.mjs | |
import { Buffer as Buffer2 } from 'node:buffer'; | |
import crypto from 'node:crypto'; | |
import net from 'node:net'; | |
import os from 'node:os'; | |
import tls from 'node:tls'; | |
var SMTPClient = class { | |
config; | |
socket; | |
connected; | |
lastCommand; | |
serverCapabilities; | |
secureMode; | |
retryCount; | |
maxEmailSize = 10485760; | |
// 10MB | |
constructor(config) { | |
this.config = { | |
host: config.host, | |
user: config.user, | |
password: config.password, | |
port: config.port ?? (config.secure ? 465 : 587), | |
secure: config.secure ?? true, | |
debug: config.debug ?? false, | |
timeout: config.timeout ?? 1e4, | |
clientName: config.clientName ?? os.hostname(), | |
maxRetries: config.maxRetries ?? 3, | |
retryDelay: config.retryDelay ?? 1e3, | |
skipAuthentication: config.skipAuthentication || false | |
}; | |
console.log('SMTP CONFIG', this.config); | |
this.socket = null; | |
this.connected = false; | |
this.lastCommand = ''; | |
this.serverCapabilities = []; | |
this.secureMode = this.config.secure; | |
this.retryCount = 0; | |
} | |
/** | |
* Log debug messages if debug mode is enabled | |
*/ | |
log(message, isError = false) { | |
if (this.config.debug) { | |
const prefix = isError ? 'SMTP ERROR: ' : 'SMTP: '; | |
console.log(`${prefix}${message}`); | |
} | |
} | |
/** | |
* Connect to the SMTP server | |
*/ | |
async connect() { | |
return new Promise((resolve, reject) => { | |
const connectionTimeout = setTimeout(() => { | |
reject(new Error(`Connection timeout after ${this.config.timeout}ms`)); | |
this.socket?.destroy(); | |
}, this.config.timeout); | |
try { | |
if (this.config.secure) { | |
this.createTLSConnection(connectionTimeout, resolve, reject); | |
} else { | |
this.createPlainConnection(connectionTimeout, resolve, reject); | |
} | |
} catch (error) { | |
clearTimeout(connectionTimeout); | |
this.log(`Failed to create socket: ${error.message}`, true); | |
reject(error); | |
} | |
}); | |
} | |
/** | |
* Create a secure TLS connection | |
*/ | |
createTLSConnection(connectionTimeout, resolve, reject) { | |
this.socket = tls.connect({ | |
host: this.config.host, | |
port: this.config.port, | |
rejectUnauthorized: true, | |
// Always validate TLS certificates | |
minVersion: 'TLSv1.2', | |
// Enforce TLS 1.2 or higher | |
ciphers: 'HIGH:!aNULL:!MD5:!RC4' | |
}); | |
this.setupSocketEventHandlers(connectionTimeout, resolve, reject); | |
} | |
/** | |
* Create a plain socket connection (for later STARTTLS upgrade) | |
*/ | |
createPlainConnection(connectionTimeout, resolve, reject) { | |
this.socket = net.createConnection({ | |
host: this.config.host, | |
port: this.config.port | |
}); | |
this.setupSocketEventHandlers(connectionTimeout, resolve, reject); | |
} | |
/** | |
* Set up common socket event handlers | |
*/ | |
setupSocketEventHandlers(connectionTimeout, resolve, reject) { | |
if (!this.socket) return; | |
this.socket.once('error', (err) => { | |
clearTimeout(connectionTimeout); | |
this.log(`Connection error: ${err.message}`, true); | |
reject(new Error(`SMTP connection error: ${err.message}`)); | |
}); | |
this.socket.once('connect', () => { | |
this.log('Connected to SMTP server'); | |
clearTimeout(connectionTimeout); | |
this.socket.once('data', (data) => { | |
const greeting = data.toString().trim(); | |
this.log(`Server greeting: ${greeting}`); | |
if (greeting.startsWith('220')) { | |
this.connected = true; | |
this.secureMode = this.config.secure; | |
resolve(); | |
} else { | |
reject(new Error(`Unexpected server greeting: ${greeting}`)); | |
this.socket.destroy(); | |
} | |
}); | |
}); | |
this.socket.once('close', (hadError) => { | |
if (this.connected) { | |
this.log(`Connection closed${hadError ? ' with error' : ''}`); | |
} else { | |
clearTimeout(connectionTimeout); | |
reject(new Error('Connection closed before initialization completed')); | |
} | |
this.connected = false; | |
}); | |
} | |
/** | |
* Upgrade connection to TLS using STARTTLS | |
*/ | |
async upgradeToTLS() { | |
if (!this.socket || this.secureMode) return; | |
return new Promise((resolve, reject) => { | |
const plainSocket = this.socket; | |
const tlsOptions = { | |
socket: plainSocket, | |
host: this.config.host, | |
rejectUnauthorized: true, | |
minVersion: 'TLSv1.2', | |
ciphers: 'HIGH:!aNULL:!MD5:!RC4' | |
}; | |
const tlsSocket = tls.connect(tlsOptions); | |
tlsSocket.once('error', (err) => { | |
this.log(`TLS upgrade error: ${err.message}`, true); | |
reject(new Error(`STARTTLS error: ${err.message}`)); | |
}); | |
tlsSocket.once('secureConnect', () => { | |
this.log('Connection upgraded to TLS'); | |
if (tlsSocket.authorized) { | |
this.socket = tlsSocket; | |
this.secureMode = true; | |
resolve(); | |
} else { | |
reject( | |
new Error( | |
`TLS certificate verification failed: ${tlsSocket.authorizationError}` | |
) | |
); | |
} | |
}); | |
}); | |
} | |
/** | |
* Send an SMTP command and await response | |
*/ | |
async sendCommand(command, expectedCode, timeout = this.config.timeout) { | |
if (!this.socket || !this.connected) { | |
throw new Error('Not connected to SMTP server'); | |
} | |
return new Promise((resolve, reject) => { | |
const commandTimeout = setTimeout(() => { | |
this.socket?.removeListener('data', onData); | |
reject(new Error(`Command timeout after ${timeout}ms: ${command}`)); | |
}, timeout); | |
let responseData = ''; | |
const onData = (chunk) => { | |
responseData += chunk.toString(); | |
const lines = responseData.split('\r\n'); | |
if (lines.length > 0 && lines[lines.length - 1] === '') { | |
const lastLine = lines[lines.length - 2] || ''; | |
const matches = /^(\d{3})(.?)/.exec(lastLine); | |
if (matches?.[1] && matches[2] !== '-') { | |
this.socket?.removeListener('data', onData); | |
clearTimeout(commandTimeout); | |
this.log(`SMTP Response: ${responseData.trim()}`); | |
if (matches[1] === expectedCode.toString()) { | |
resolve(responseData.trim()); | |
} else { | |
reject(new Error(`SMTP Error: ${responseData.trim()}`)); | |
} | |
} | |
} | |
}; | |
this.socket.on('data', onData); | |
if ( | |
command.startsWith('AUTH PLAIN') || | |
command.startsWith('AUTH LOGIN') || | |
(this.lastCommand === 'AUTH LOGIN' && !command.startsWith('AUTH')) | |
) { | |
this.log('SMTP Command: [Credentials hidden]'); | |
} else { | |
this.log(`SMTP Command: ${command}`); | |
} | |
this.lastCommand = command; | |
this.socket.write(`${command}\r | |
`); | |
}); | |
} | |
/** | |
* Parse EHLO response to determine server capabilities | |
*/ | |
parseCapabilities(ehloResponse) { | |
const lines = ehloResponse.split('\r\n'); | |
this.serverCapabilities = []; | |
for (let i = 1; i < lines.length; i++) { | |
const line = lines[i]; | |
if (line.match(/^\d{3}/) && line.charAt(3) === ' ') { | |
const capability = line.substr(4).toUpperCase(); | |
this.serverCapabilities.push(capability); | |
} | |
} | |
this.log(`Server capabilities: ${this.serverCapabilities.join(', ')}`); | |
} | |
/** | |
* Determine the best authentication method supported by the server | |
*/ | |
getBestAuthMethod() { | |
const capabilities = this.serverCapabilities.map( | |
(cap) => cap.split(' ')[0] | |
); | |
if (capabilities.includes('AUTH')) { | |
const authLine = this.serverCapabilities.find((cap) => | |
cap.startsWith('AUTH ') | |
); | |
if (authLine) { | |
const methods = authLine.split(' ').slice(1); | |
if (methods.includes('CRAM-MD5')) return 'CRAM-MD5'; | |
if (methods.includes('LOGIN')) return 'LOGIN'; | |
if (methods.includes('PLAIN')) return 'PLAIN'; | |
} | |
} | |
return 'PLAIN'; | |
} | |
/** | |
* Authenticate with the SMTP server using the best available method | |
*/ | |
async authenticate() { | |
const authMethod = this.getBestAuthMethod(); | |
switch (authMethod) { | |
case 'CRAM-MD5': | |
await this.authenticateCramMD5(); | |
break; | |
case 'LOGIN': | |
await this.authenticateLogin(); | |
break; | |
default: | |
await this.authenticatePlain(); | |
break; | |
} | |
} | |
/** | |
* Authenticate using PLAIN method | |
*/ | |
async authenticatePlain() { | |
const authPlain = Buffer2.from( | |
`\0${this.config.user}\0${this.config.password}` | |
).toString('base64'); | |
await this.sendCommand(`AUTH PLAIN ${authPlain}`, 235); | |
} | |
/** | |
* Authenticate using LOGIN method | |
*/ | |
async authenticateLogin() { | |
await this.sendCommand('AUTH LOGIN', 334); | |
await this.sendCommand( | |
Buffer2.from(this.config.user).toString('base64'), | |
334 | |
); | |
await this.sendCommand( | |
Buffer2.from(this.config.password).toString('base64'), | |
235 | |
); | |
} | |
/** | |
* Authenticate using CRAM-MD5 method | |
*/ | |
async authenticateCramMD5() { | |
const response = await this.sendCommand('AUTH CRAM-MD5', 334); | |
const challenge = Buffer2.from(response.substr(4), 'base64').toString( | |
'utf8' | |
); | |
const hmac = crypto.createHmac('md5', this.config.password); | |
hmac.update(challenge); | |
const digest = hmac.digest('hex'); | |
const cramResponse = `${this.config.user} ${digest}`; | |
const encodedResponse = Buffer2.from(cramResponse).toString('base64'); | |
await this.sendCommand(encodedResponse, 235); | |
} | |
/** | |
* Generate a unique message ID | |
*/ | |
generateMessageId() { | |
const random = crypto.randomBytes(16).toString('hex'); | |
const domain = this.config.user.split('@')[1] || 'localhost'; | |
return `<${random}@${domain}>`; | |
} | |
/** | |
* Generate MIME boundary for multipart messages | |
*/ | |
generateBoundary() { | |
return `----=_NextPart_${crypto.randomBytes(12).toString('hex')}`; | |
} | |
/** | |
* Encode string according to RFC 2047 for headers with non-ASCII characters | |
*/ | |
encodeHeaderValue(value) { | |
if (/^[\x00-\x7F]*$/.test(value)) { | |
return value; | |
} | |
return `=?UTF-8?Q?${ | |
// biome-ignore lint/suspicious/noControlCharactersInRegex: <explanation> | |
value.replace(/[^\x00-\x7F]/g, (c) => { | |
const hex = c.charCodeAt(0).toString(16).toUpperCase(); | |
return `=${hex.length < 2 ? `0${hex}` : hex}`; | |
}) | |
}?=`; | |
} | |
/** | |
* Sanitize and encode header value to prevent injection and handle internationalization | |
*/ | |
sanitizeHeader(value) { | |
const sanitized = value | |
.replace(/[\r\n\t]+/g, ' ') | |
.replace(/\s{2,}/g, ' ') | |
.trim(); | |
return this.encodeHeaderValue(sanitized); | |
} | |
/** | |
* Create email headers with proper sanitization | |
*/ | |
createEmailHeaders(options) { | |
const messageId = this.generateMessageId(); | |
const date = /* @__PURE__ */ new Date().toUTCString(); | |
const from = options.from || this.config.user; | |
const recipients = Array.isArray(options.to) | |
? options.to.join(', ') | |
: options.to; | |
const headers = [ | |
`From: ${this.sanitizeHeader(from)}`, | |
`To: ${this.sanitizeHeader(recipients)}`, | |
`Subject: ${this.sanitizeHeader(options.subject)}`, | |
`Message-ID: ${messageId}`, | |
`Date: ${date}`, | |
'MIME-Version: 1.0' | |
]; | |
if (options.cc) { | |
const cc = Array.isArray(options.cc) ? options.cc.join(', ') : options.cc; | |
headers.push(`Cc: ${this.sanitizeHeader(cc)}`); | |
} | |
if (options.replyTo) | |
headers.push(`Reply-To: ${this.sanitizeHeader(options.replyTo)}`); | |
if (options.headers) { | |
for (const [name, value] of Object.entries(options.headers)) { | |
if (!/^[a-zA-Z0-9-]+$/.test(name)) continue; | |
if (/^(from|to|cc|bcc|subject|date|message-id)$/i.test(name)) continue; | |
headers.push(`${name}: ${this.sanitizeHeader(value)}`); | |
} | |
} | |
return headers; | |
} | |
/** | |
* Create a multipart email with text and HTML parts | |
*/ | |
createMultipartEmail(options) { | |
const { text, html } = options; | |
const headers = this.createEmailHeaders(options); | |
const boundary = this.generateBoundary(); | |
if (html && text) { | |
headers.push( | |
`Content-Type: multipart/alternative; boundary="${boundary}"` | |
); | |
return `${headers.join('\r\n')}\r | |
\r | |
--${boundary}\r | |
Content-Type: text/plain; charset=utf-8\r | |
\r | |
${text || ''}\r | |
\r | |
--${boundary}\r | |
Content-Type: text/html; charset=utf-8\r | |
\r | |
${html || ''}\r | |
\r | |
--${boundary}--\r | |
`; | |
} | |
headers.push('Content-Type: text/html; charset=utf-8'); | |
if (html) | |
return `${headers.join('\r\n')}\r | |
\r | |
${html}`; | |
return `${headers.join('\r\n')}\r | |
\r | |
${text || ''}`; | |
} | |
/** | |
* Perform full SMTP handshake, including STARTTLS if needed | |
*/ | |
async smtpHandshake() { | |
const ehloResponse = await this.sendCommand( | |
`EHLO ${this.config.clientName}`, | |
250 | |
); | |
this.parseCapabilities(ehloResponse); | |
if (!this.secureMode && this.serverCapabilities.includes('STARTTLS')) { | |
await this.sendCommand('STARTTLS', 220); | |
await this.upgradeToTLS(); | |
const secureEhloResponse = await this.sendCommand( | |
`EHLO ${this.config.clientName}`, | |
250 | |
); | |
this.parseCapabilities(secureEhloResponse); | |
} | |
if (!this.config.skipAuthentication) { | |
await this.authenticate(); | |
} else { | |
this.log('Authentication skipped (testing mode)'); | |
} | |
} | |
/** | |
* Send an email with retry capability | |
*/ | |
async sendEmail(options) { | |
const from = options.from || this.config.user; | |
const { to, subject } = options; | |
const text = options.text || ''; | |
const html = options.html || ''; | |
if (!from || !to || !subject || (!text && !html)) { | |
return { | |
success: false, | |
error: | |
'Missing required email parameters (from, to, subject, and either text or html)' | |
}; | |
} | |
if (!validateEmail(from)) { | |
return { | |
success: false, | |
error: 'Invalid email address format' | |
}; | |
} | |
const recipients = Array.isArray(options.to) ? options.to : [options.to]; | |
for (const recipient of recipients) { | |
if (!validateEmail(recipient)) { | |
return { | |
success: false, | |
error: `Invalid recipient email address format: ${recipient}` | |
}; | |
} | |
} | |
for ( | |
this.retryCount = 0; | |
this.retryCount <= this.config.maxRetries; | |
this.retryCount++ | |
) { | |
try { | |
if (this.retryCount > 0) { | |
this.log( | |
`Retrying email send (attempt ${this.retryCount} of ${this.config.maxRetries})...` | |
); | |
await new Promise((resolve) => | |
setTimeout(resolve, this.config.retryDelay) | |
); | |
} | |
if (!this.connected) { | |
await this.connect(); | |
await this.smtpHandshake(); | |
} | |
await this.sendCommand(`MAIL FROM:<${from}>`, 250); | |
for (const recipient of recipients) | |
await this.sendCommand(`RCPT TO:<${recipient}>`, 250); | |
if (options.cc) { | |
const ccList = Array.isArray(options.cc) ? options.cc : [options.cc]; | |
for (const cc of ccList) { | |
if (validateEmail(cc)) | |
await this.sendCommand(`RCPT TO:<${cc}>`, 250); | |
} | |
} | |
if (options.bcc) { | |
const bccList = Array.isArray(options.bcc) | |
? options.bcc | |
: [options.bcc]; | |
for (const bcc of bccList) { | |
if (validateEmail(bcc)) | |
await this.sendCommand(`RCPT TO:<${bcc}>`, 250); | |
} | |
} | |
await this.sendCommand('DATA', 354); | |
const emailContent = this.createMultipartEmail(options); | |
if (emailContent.length > this.maxEmailSize) { | |
return { | |
success: false, | |
error: 'Email size exceeds maximum allowed' | |
}; | |
} | |
await this.sendCommand( | |
`${emailContent}\r | |
.`, | |
250 | |
); | |
const messageIdMatch = /Message-ID: (.*)/i.exec(emailContent); | |
const messageId = messageIdMatch ? messageIdMatch[1].trim() : void 0; | |
return { | |
success: true, | |
messageId, | |
message: 'Email sent successfully' | |
}; | |
} catch (error) { | |
const errorMessage = error.message; | |
this.log(`Error sending email: ${errorMessage}`, true); | |
const isPermanentError = | |
errorMessage.includes('5.') || // 5xx SMTP errors are permanent | |
errorMessage.includes('Authentication failed') || | |
errorMessage.includes('certificate'); | |
if (isPermanentError || this.retryCount >= this.config.maxRetries) { | |
return { | |
success: false, | |
error: errorMessage | |
}; | |
} | |
try { | |
if (this.connected) { | |
await this.sendCommand('RSET', 250); | |
} | |
} catch (_error) {} | |
this.socket?.end(); | |
this.connected = false; | |
} | |
} | |
return { | |
success: false, | |
error: 'Maximum retry count exceeded' | |
}; | |
} | |
/** | |
* Close the connection gracefully | |
*/ | |
async close() { | |
try { | |
if (this.connected) { | |
await this.sendCommand('QUIT', 221); | |
} | |
} catch (e) { | |
this.log(`Error during QUIT: ${e.message}`, true); | |
} finally { | |
if (this.socket) { | |
this.socket.destroy(); | |
this.socket = null; | |
this.connected = false; | |
} | |
} | |
} | |
}; | |
// node_modules/mikromail/lib/chunk-422R3NOG.mjs | |
var MikroMail = class { | |
smtpClient; | |
constructor(options) { | |
const config = new Configuration(options).get(); | |
const smtpClient = new SMTPClient(config); | |
this.smtpClient = smtpClient; | |
} | |
/** | |
* Sends an email to valid domains. | |
*/ | |
async send(emailOptions) { | |
try { | |
const recipients = Array.isArray(emailOptions.to) | |
? emailOptions.to | |
: [emailOptions.to]; | |
for (const recipient of recipients) { | |
const hasMXRecords = await verifyEmailDomain(recipient); | |
if (!hasMXRecords) | |
console.error( | |
`Warning: No MX records found for recipient domain: ${recipient}` | |
); | |
} | |
console.log('BEFORE SENDING') | |
const result = await this.smtpClient.sendEmail(emailOptions); | |
console.log('EMAIL RESULT', result) | |
console.log('AFTER SENDING') | |
if (result.success) console.log(`Message ID: ${result.messageId}`); | |
else console.error(`Failed to send email: ${result.error}`); | |
await this.smtpClient.close(); | |
} catch (error) { | |
console.error('Error in email sending process:', error.message); | |
} | |
} | |
}; | |
// node_modules/mikroauth/lib/index.mjs | |
var g = () => { | |
const o = T(process.env.DEBUG) || false; | |
return { | |
auth: { | |
jwtSecret: process.env.AUTH_JWT_SECRET || 'your-jwt-secret', | |
magicLinkExpirySeconds: 900, | |
jwtExpirySeconds: 3600, | |
refreshTokenExpirySeconds: 604800, | |
maxActiveSessions: 3, | |
appUrl: process.env.APP_URL || 'http://localhost:3000', | |
templates: null, | |
debug: o | |
}, | |
email: { | |
emailSubject: 'Your Secure Login Link', | |
user: process.env.EMAIL_USER || '', | |
host: process.env.EMAIL_HOST || '', | |
password: process.env.EMAIL_PASSWORD || '', | |
port: 465, | |
secure: true, | |
maxRetries: 2, | |
debug: o | |
}, | |
storage: { | |
databaseDirectory: 'mikroauth', | |
encryptionKey: process.env.STORAGE_KEY || '', | |
debug: o | |
}, | |
server: { | |
port: Number(process.env.PORT) || 3e3, | |
host: process.env.HOST || '0.0.0.0', | |
useHttps: false, | |
useHttp2: false, | |
sslCert: '', | |
sslKey: '', | |
sslCa: '', | |
rateLimit: { enabled: true, requestsPerMinute: 100 }, | |
allowedDomains: ['*'], | |
debug: o | |
} | |
}; | |
}; | |
function T(o) { | |
return o === 'true' || o === true; | |
} | |
var y = class { | |
algorithm = 'HS256'; | |
secret = 'HS256'; | |
constructor(e) { | |
if ( | |
process.env.NODE_ENV === 'production' && | |
(!e || e.length < 32 || e === g().auth.jwtSecret) | |
) | |
throw new Error( | |
'Production environment requires a strong JWT secret (min 32 chars)' | |
); | |
this.secret = e; | |
} | |
sign(e, t = {}) { | |
const r = { alg: this.algorithm, typ: 'JWT' }, | |
i = Math.floor(Date.now() / 1e3), | |
s = { ...e, iat: i }; | |
t.exp !== void 0 && (s.exp = i + t.exp), | |
t.notBefore !== void 0 && (s.nbf = i + t.notBefore), | |
t.issuer && (s.iss = t.issuer), | |
t.audience && (s.aud = t.audience), | |
t.subject && (s.sub = t.subject), | |
t.jwtid && (s.jti = t.jwtid); | |
const a = this.base64UrlEncode(JSON.stringify(r)), | |
d = this.base64UrlEncode(JSON.stringify(s)), | |
l = `${a}.${d}`, | |
c = this.createSignature(l); | |
return `${l}.${c}`; | |
} | |
verify(e, t = {}) { | |
const r = this.decode(e); | |
if (r.header.alg !== this.algorithm) | |
throw new Error( | |
`Invalid algorithm. Expected ${this.algorithm}, got ${r.header.alg}` | |
); | |
const [i, s] = e.split('.'), | |
a = `${i}.${s}`; | |
if (this.createSignature(a) !== r.signature) | |
throw new Error('Invalid signature'); | |
const l = r.payload, | |
c = Math.floor(Date.now() / 1e3), | |
u = t.clockTolerance || 0; | |
if (l.exp !== void 0 && l.exp + u < c) throw new Error('Token expired'); | |
if (l.nbf !== void 0 && l.nbf - u > c) | |
throw new Error('Token not yet valid'); | |
if (t.issuer && l.iss !== t.issuer) throw new Error('Invalid issuer'); | |
if (t.audience && l.aud !== t.audience) throw new Error('Invalid audience'); | |
if (t.subject && l.sub !== t.subject) throw new Error('Invalid subject'); | |
return l; | |
} | |
decode(e) { | |
const t = e.split('.'); | |
if (t.length !== 3) throw new Error('Invalid token format'); | |
try { | |
const [r, i, s] = t, | |
a = JSON.parse(this.base64UrlDecode(r)), | |
d = JSON.parse(this.base64UrlDecode(i)); | |
return { header: a, payload: d, signature: s }; | |
} catch { | |
throw new Error('Failed to decode token'); | |
} | |
} | |
createSignature(e) { | |
const t = f.createHmac('sha256', this.secret).update(e).digest(); | |
return this.base64UrlEncode(t); | |
} | |
base64UrlEncode(e) { | |
let t; | |
return ( | |
typeof e == 'string' ? (t = Buffer.from(e)) : (t = e), | |
t | |
.toString('base64') | |
.replace(/=/g, '') | |
.replace(/\+/g, '-') | |
.replace(/\//g, '_') | |
); | |
} | |
base64UrlDecode(e) { | |
let t = e.replace(/-/g, '+').replace(/_/g, '/'); | |
switch (t.length % 4) { | |
case 0: | |
break; | |
case 2: | |
t += '=='; | |
break; | |
case 3: | |
t += '='; | |
break; | |
default: | |
throw new Error('Invalid base64 string'); | |
} | |
return Buffer.from(t, 'base64').toString(); | |
} | |
}; | |
var w = class { | |
templates; | |
constructor(e) { | |
e ? (this.templates = e) : (this.templates = L); | |
} | |
getText(e, t) { | |
return this.templates.textVersion(e, t).trim(); | |
} | |
getHtml(e, t) { | |
return this.templates.htmlVersion(e, t).trim(); | |
} | |
}; | |
var L = { | |
textVersion: (o, e) => ` | |
Click this link to login: ${o} | |
Security Information: | |
- Expires in ${e} minutes | |
- Can only be used once | |
- Should only be used by you | |
If you didn't request this link, please ignore this email. | |
`, | |
htmlVersion: (o, e) => ` | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Your Login Link</title> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
line-height: 1.6; | |
color: #333; | |
max-width: 600px; | |
margin: 0 auto; | |
padding: 20px; | |
} | |
.container { | |
border: 1px solid #e1e1e1; | |
border-radius: 5px; | |
padding: 20px; | |
} | |
.button { | |
display: inline-block; | |
background-color: #4CAF50; | |
color: white; | |
text-decoration: none; | |
padding: 10px 20px; | |
border-radius: 5px; | |
margin: 20px 0; | |
} | |
.security-info { | |
background-color: #f8f8f8; | |
padding: 15px; | |
border-radius: 5px; | |
margin-top: 20px; | |
} | |
.footer { | |
margin-top: 20px; | |
font-size: 12px; | |
color: #888; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<h2>Your Secure Login Link</h2> | |
<p>Click the button below to log in to your account:</p> | |
<a href="${o}" class="button">Login to Your Account</a> | |
<p> | |
Hello, this is a test email! Hall\xE5, MikroMail has international support for, among others, espa\xF1ol, fran\xE7ais, portugu\xEAs, \u4E2D\u6587, \u65E5\u672C\u8A9E, and \u0420\u0443\u0441\u0441\u043A\u0438\u0439! | |
</p> | |
<div class="security-info"> | |
<h3>Security Information:</h3> | |
<ul> | |
<li>This link expires in ${e} minutes</li> | |
<li>Can only be used once</li> | |
<li>Should only be used by you</li> | |
</ul> | |
</div> | |
<p>If you didn't request this link, please ignore this email.</p> | |
<div class="footer"> | |
<p>This is an automated message, please do not reply to this email.</p> | |
</div> | |
</div> | |
</body> | |
</html> | |
` | |
}; | |
var h = class { | |
constructor(e) { | |
this.options = e; | |
} | |
sentEmails = []; | |
async sendMail(e) { | |
this.sentEmails.push(e), | |
this.options?.logToConsole && | |
(console.log('Email sent:'), | |
console.log(`From: ${e.from}`), | |
console.log(`To: ${e.to}`), | |
console.log(`Subject: ${e.subject}`), | |
console.log(`Text: ${e.text}`)), | |
this.options?.onSend && this.options.onSend(e); | |
} | |
getSentEmails() { | |
return [...this.sentEmails]; | |
} | |
clearSentEmails() { | |
this.sentEmails = []; | |
} | |
}; | |
var m = class { | |
data = /* @__PURE__ */ new Map(); | |
collections = /* @__PURE__ */ new Map(); | |
expiryEmitter = new EventEmitter(); | |
expiryCheckInterval; | |
constructor(e = 1e3) { | |
this.expiryCheckInterval = setInterval(() => this.checkExpiredItems(), e); | |
} | |
destroy() { | |
clearInterval(this.expiryCheckInterval), | |
this.data.clear(), | |
this.collections.clear(), | |
this.expiryEmitter.removeAllListeners(); | |
} | |
checkExpiredItems() { | |
const e = Date.now(); | |
for (const [t, r] of this.data.entries()) | |
r.expiry && | |
r.expiry < e && | |
(this.data.delete(t), this.expiryEmitter.emit('expired', t)); | |
for (const [t, r] of this.collections.entries()) | |
r.expiry && | |
r.expiry < e && | |
(this.collections.delete(t), this.expiryEmitter.emit('expired', t)); | |
} | |
async set(e, t, r) { | |
const i = r ? Date.now() + r * 1e3 : null; | |
this.data.set(e, { value: t, expiry: i }); | |
} | |
async get(e) { | |
const t = this.data.get(e); | |
return t | |
? t.expiry && t.expiry < Date.now() | |
? (this.data.delete(e), null) | |
: t.value | |
: null; | |
} | |
async delete(e) { | |
this.data.delete(e), this.collections.delete(e); | |
} | |
async addToCollection(e, t, r) { | |
this.collections.has(e) || | |
this.collections.set(e, { | |
items: [], | |
expiry: r ? Date.now() + r * 1e3 : null | |
}); | |
const i = this.collections.get(e); | |
r && (i.expiry = Date.now() + r * 1e3), i.items.push(t); | |
} | |
async removeFromCollection(e, t) { | |
const r = this.collections.get(e); | |
r && (r.items = r.items.filter((i) => i !== t)); | |
} | |
async getCollection(e) { | |
const t = this.collections.get(e); | |
return t ? [...t.items] : []; | |
} | |
async getCollectionSize(e) { | |
const t = this.collections.get(e); | |
return t ? t.items.length : 0; | |
} | |
async removeOldestFromCollection(e) { | |
const t = this.collections.get(e); | |
return !t || t.items.length === 0 ? null : t.items.shift() || null; | |
} | |
async findKeys(e) { | |
const t = e.replace(/\*/g, '.*').replace(/\?/g, '.'), | |
r = new RegExp(`^${t}$`), | |
i = Array.from(this.data.keys()).filter((a) => r.test(a)), | |
s = Array.from(this.collections.keys()).filter((a) => r.test(a)); | |
return [.../* @__PURE__ */ new Set([...i, ...s])]; | |
} | |
}; | |
function S(o) { | |
if (!o || o.trim() === '' || (o.match(/@/g) || []).length !== 1) return false; | |
const [t, r] = o.split('@'); | |
return !(!t || !r || o.includes('..') || !I(t) || !$(r)); | |
} | |
function I(o) { | |
return o.startsWith('"') && o.endsWith('"') | |
? !o.slice(1, -1).includes('"') | |
: o.length > 64 || o.startsWith('.') || o.endsWith('.') | |
? false | |
: /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+$/.test(o); | |
} | |
function $(o) { | |
if (o.startsWith('[') && o.endsWith(']')) { | |
const t = o.slice(1, -1); | |
return t.startsWith('IPv6:') ? R(t.slice(5)) : M(t); | |
} | |
const e = o.split('.'); | |
if (e.length === 0) return false; | |
for (const t of e) | |
if ( | |
!t || | |
t.length > 63 || | |
!/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(t) | |
) | |
return false; | |
if (e.length > 1) { | |
const t = e[e.length - 1]; | |
if (!/^[a-zA-Z]{2,}$/.test(t)) return false; | |
} | |
return true; | |
} | |
function M(o) { | |
return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/.test( | |
o | |
); | |
} | |
function R(o) { | |
if (!/^[a-fA-F0-9:]+$/.test(o)) return false; | |
const t = o.split(':'); | |
return !(t.length < 2 || t.length > 8); | |
} | |
var n = g(); | |
var P = (o) => { | |
const e = { | |
configFilePath: 'mikroauth.config.json', | |
args: process.argv, | |
options: [ | |
{ | |
flag: '--jwtSecret', | |
path: 'auth.jwtSecret', | |
defaultValue: n.auth.jwtSecret | |
}, | |
{ | |
flag: '--magicLinkExpirySeconds', | |
path: 'auth.magicLinkExpirySeconds', | |
defaultValue: n.auth.magicLinkExpirySeconds | |
}, | |
{ | |
flag: '--jwtExpirySeconds', | |
path: 'auth.jwtExpirySeconds', | |
defaultValue: n.auth.jwtExpirySeconds | |
}, | |
{ | |
flag: '--refreshTokenExpirySeconds', | |
path: 'auth.refreshTokenExpirySeconds', | |
defaultValue: n.auth.refreshTokenExpirySeconds | |
}, | |
{ | |
flag: '--maxActiveSessions', | |
path: 'auth.maxActiveSessions', | |
defaultValue: n.auth.maxActiveSessions | |
}, | |
{ flag: '--appUrl', path: 'auth.appUrl', defaultValue: n.auth.appUrl }, | |
{ | |
flag: '--debug', | |
path: 'auth.debug', | |
isFlag: true, | |
defaultValue: n.auth.debug | |
}, | |
{ | |
flag: '--emailSubject', | |
path: 'email.emailSubject', | |
defaultValue: 'Your Secure Login Link' | |
}, | |
{ flag: '--emailHost', path: 'email.host', defaultValue: n.email.host }, | |
{ flag: '--emailUser', path: 'email.user', defaultValue: n.email.user }, | |
{ | |
flag: '--emailPassword', | |
path: 'email.password', | |
defaultValue: n.email.password | |
}, | |
{ flag: '--emailPort', path: 'email.port', defaultValue: n.email.port }, | |
{ | |
flag: '--emailSecure', | |
path: 'email.secure', | |
isFlag: true, | |
defaultValue: n.email.secure | |
}, | |
{ | |
flag: '--emailMaxRetries', | |
path: 'email.maxRetries', | |
defaultValue: n.email.maxRetries | |
}, | |
{ | |
flag: '--debug', | |
path: 'email.debug', | |
isFlag: true, | |
defaultValue: n.email.debug | |
}, | |
{ | |
flag: '--dir', | |
path: 'storage.databaseDirectory', | |
defaultValue: n.storage.databaseDirectory | |
}, | |
{ | |
flag: '--encryptionKey', | |
path: 'storage.encryptionKey', | |
defaultValue: n.storage.encryptionKey | |
}, | |
{ flag: '--debug', path: 'storage.debug', defaultValue: n.storage.debug }, | |
{ flag: '--port', path: 'server.port', defaultValue: n.server.port }, | |
{ flag: '--host', path: 'server.host', defaultValue: n.server.host }, | |
{ | |
flag: '--https', | |
path: 'server.useHttps', | |
isFlag: true, | |
defaultValue: n.server.useHttps | |
}, | |
{ | |
flag: '--https', | |
path: 'server.useHttp2', | |
isFlag: true, | |
defaultValue: n.server.useHttp2 | |
}, | |
{ | |
flag: '--cert', | |
path: 'server.sslCert', | |
defaultValue: n.server.sslCert | |
}, | |
{ flag: '--key', path: 'server.sslKey', defaultValue: n.server.sslKey }, | |
{ flag: '--ca', path: 'server.sslCa', defaultValue: n.server.sslCa }, | |
{ | |
flag: '--ratelimit', | |
path: 'server.rateLimit.enabled', | |
defaultValue: n.server.rateLimit.enabled, | |
isFlag: true | |
}, | |
{ | |
flag: '--rps', | |
path: 'server.rateLimit.requestsPerMinute', | |
defaultValue: n.server.rateLimit.requestsPerMinute | |
}, | |
{ | |
flag: '--allowed', | |
path: 'server.allowedDomains', | |
defaultValue: n.server.allowedDomains, | |
parser: parsers.array | |
}, | |
{ | |
flag: '--debug', | |
path: 'server.debug', | |
isFlag: true, | |
defaultValue: n.server.debug | |
} | |
] | |
}; | |
return o && (e.config = o), e; | |
}; | |
var v = { | |
linkSent: 'If a matching account was found, a magic link has been sent.', | |
revokedSuccess: 'All other sessions revoked successfully.', | |
logoutSuccess: 'Logged out successfully.' | |
}; | |
var E = class { | |
config; | |
email; | |
storage; | |
jwtService; | |
templates; | |
constructor(e, t, r) { | |
const i = new MikroConf(P({ auth: e.auth, email: e.email })).get(); | |
i.auth.debug && console.log('Using configuration:', i), | |
(this.config = i), | |
(this.email = t || new h()), | |
(this.storage = r || new m()), | |
(this.jwtService = new y(i.auth.jwtSecret)), | |
(this.templates = new w(i?.auth.templates)), | |
this.checkIfUsingDefaultCredentialsInProduction(); | |
} | |
checkIfUsingDefaultCredentialsInProduction() { | |
process.env.NODE_ENV === 'production' && | |
this.config.auth.jwtSecret === g().auth.jwtSecret && | |
(console.error( | |
'WARNING: Using default secrets in production environment!' | |
), | |
process.exit(1)); | |
} | |
generateToken(e) { | |
const t = Date.now().toString(), | |
r = f.randomBytes(32).toString('hex'); | |
return f.createHash('sha256').update(`${e}:${t}:${r}`).digest('hex'); | |
} | |
generateJsonWebToken(e) { | |
return this.jwtService.sign({ | |
sub: e.id, | |
email: e.email, | |
username: e.username, | |
role: e.role, | |
exp: Math.floor(Date.now() / 1e3) + 60 * 60 * 24 | |
}); | |
} | |
generateRefreshToken() { | |
return f.randomBytes(40).toString('hex'); | |
} | |
async trackSession(e, t, r) { | |
const i = `sessions:${e}`; | |
if ( | |
(await this.storage.getCollectionSize(i)) >= | |
this.config.auth.maxActiveSessions | |
) { | |
const a = await this.storage.removeOldestFromCollection(i); | |
a && (await this.storage.delete(`refresh:${a}`)); | |
} | |
await this.storage.addToCollection( | |
i, | |
t, | |
this.config.auth.refreshTokenExpirySeconds | |
), | |
await this.storage.set( | |
`refresh:${t}`, | |
JSON.stringify(r), | |
this.config.auth.refreshTokenExpirySeconds | |
); | |
} | |
generateMagicLinkUrl(e) { | |
const { token: t, email: r } = e; | |
try { | |
return ( | |
new URL(this.config.auth.appUrl), | |
`${this.config.auth.appUrl}?token=${encodeURIComponent(t)}&email=${encodeURIComponent(r)}` | |
); | |
} catch { | |
throw new Error('Invalid base URL configuration'); | |
} | |
} | |
async createMagicLink(e) { | |
const { email: t, ip: r } = e; | |
if (!S(t)) throw new Error('Valid email required'); | |
try { | |
const i = this.generateToken(t), | |
s = `magic_link:${i}`, | |
a = { email: t, ipAddress: r || 'unknown', createdAt: Date.now() }; | |
await this.storage.set( | |
s, | |
JSON.stringify(a), | |
this.config.auth.magicLinkExpirySeconds | |
); | |
const d = await this.storage.findKeys('magic_link:*'); | |
for (const u of d) { | |
if (u === s) continue; | |
const p = await this.storage.get(u); | |
if (p) | |
try { | |
JSON.parse(p).email === t && (await this.storage.delete(u)); | |
} catch {} | |
} | |
console.log('this.config.email', this.config.email); | |
const l = this.generateMagicLinkUrl({ token: i, email: t }), | |
c = Math.ceil(this.config.auth.magicLinkExpirySeconds / 60); | |
return ( | |
await this.email.sendMail({ | |
from: this.config.email.user, | |
to: t, | |
subject: this.config.email.emailSubject, | |
text: this.templates.getText(l, c), | |
html: this.templates.getHtml(l, c) | |
}), | |
{ message: v.linkSent } | |
); | |
} catch (i) { | |
throw ( | |
(console.error(`Failed to process magic link request: ${i}`), | |
new Error('Failed to process magic link request')) | |
); | |
} | |
} | |
async verifyToken(e) { | |
const { token: t, email: r } = e; | |
try { | |
const i = `magic_link:${t}`, | |
s = await this.storage.get(i); | |
if (!s) throw new Error('Invalid or expired token'); | |
const a = JSON.parse(s); | |
if (a.email !== r) throw new Error('Email mismatch'); | |
const d = a.username, | |
l = a.role; | |
await this.storage.delete(i); | |
const c = f.randomBytes(16).toString('hex'), | |
u = this.generateRefreshToken(), | |
p = { | |
sub: r, | |
username: d, | |
role: l, | |
jti: c, | |
lastLogin: a.createdAt, | |
metadata: { ip: a.ipAddress }, | |
exp: Math.floor(Date.now() / 1e3) + 60 * 60 * 24 | |
}, | |
b = this.jwtService.sign(p, { exp: this.config.auth.jwtExpirySeconds }); | |
return ( | |
await this.trackSession(r, u, { | |
...a, | |
tokenId: c, | |
createdAt: Date.now() | |
}), | |
{ | |
accessToken: b, | |
refreshToken: u, | |
exp: this.config.auth.jwtExpirySeconds, | |
tokenType: 'Bearer' | |
} | |
); | |
} catch (i) { | |
throw ( | |
(console.error('Token verification error:', i), | |
new Error('Verification failed')) | |
); | |
} | |
} | |
async refreshAccessToken(e) { | |
try { | |
const t = await this.storage.get(`refresh:${e}`); | |
if (!t) throw new Error('Invalid or expired refresh token'); | |
const r = JSON.parse(t), | |
i = r.email; | |
if (!i) throw new Error('Invalid refresh token data'); | |
const s = r.username, | |
a = r.role, | |
d = f.randomBytes(16).toString('hex'), | |
l = { | |
sub: i, | |
username: s, | |
role: a, | |
jti: d, | |
lastLogin: r.lastLogin || r.createdAt, | |
metadata: { ip: r.ipAddress } | |
}, | |
c = this.jwtService.sign(l, { exp: this.config.auth.jwtExpirySeconds }); | |
return ( | |
(r.lastUsed = Date.now()), | |
await this.storage.set( | |
`refresh:${e}`, | |
JSON.stringify(r), | |
this.config.auth.refreshTokenExpirySeconds | |
), | |
{ | |
accessToken: c, | |
refreshToken: e, | |
exp: this.config.auth.jwtExpirySeconds, | |
tokenType: 'Bearer' | |
} | |
); | |
} catch (t) { | |
throw ( | |
(console.error('Token refresh error:', t), | |
new Error('Token refresh failed')) | |
); | |
} | |
} | |
verify(e) { | |
try { | |
return this.jwtService.verify(e); | |
} catch { | |
throw new Error('Invalid token'); | |
} | |
} | |
async logout(e) { | |
try { | |
if (!e || typeof e != 'string') | |
throw new Error('Refresh token is required'); | |
const t = await this.storage.get(`refresh:${e}`); | |
if (!t) return { message: v.logoutSuccess }; | |
const i = JSON.parse(t).email; | |
if (!i) throw new Error('Invalid refresh token data'); | |
await this.storage.delete(`refresh:${e}`); | |
const s = `sessions:${i}`; | |
return ( | |
await this.storage.removeFromCollection(s, e), | |
{ message: v.logoutSuccess } | |
); | |
} catch (t) { | |
throw (console.error('Logout error:', t), new Error('Logout failed')); | |
} | |
} | |
async getSessions(e) { | |
try { | |
if (!e.user?.email) throw new Error('User not authenticated'); | |
const t = e.user.email, | |
r = e.body?.refreshToken, | |
i = `sessions:${t}`, | |
a = (await this.storage.getCollection(i)).map(async (l) => { | |
try { | |
const c = await this.storage.get(`refresh:${l}`); | |
if (!c) return await this.storage.removeFromCollection(i, l), null; | |
const u = JSON.parse(c); | |
return { | |
id: `${l.substring(0, 8)}...`, | |
createdAt: u.createdAt || 0, | |
lastLogin: u.lastLogin || u.createdAt || 0, | |
lastUsed: u.lastUsed || u.createdAt || 0, | |
metadata: { ip: u.ipAddress }, | |
isCurrentSession: l === r | |
}; | |
} catch { | |
return await this.storage.removeFromCollection(i, l), null; | |
} | |
}), | |
d = (await Promise.all(a)).filter(Boolean); | |
return d.sort((l, c) => c.createdAt - l.createdAt), { sessions: d }; | |
} catch (t) { | |
throw ( | |
(console.error('Get sessions error:', t), | |
new Error('Failed to fetch sessions')) | |
); | |
} | |
} | |
async revokeSessions(e) { | |
try { | |
if (!e.user?.email) throw new Error('User not authenticated'); | |
const t = e.user.email, | |
r = e.body?.refreshToken, | |
i = `sessions:${t}`, | |
s = await this.storage.getCollection(i); | |
for (const a of s) | |
(r && a === r) || (await this.storage.delete(`refresh:${a}`)); | |
return ( | |
await this.storage.delete(i), | |
r && | |
(await this.storage.get(`refresh:${r}`)) && | |
(await this.storage.addToCollection( | |
i, | |
r, | |
this.config.auth.refreshTokenExpirySeconds | |
)), | |
{ message: v.revokedSuccess } | |
); | |
} catch (t) { | |
throw ( | |
(console.error('Revoke sessions error:', t), | |
new Error('Failed to revoke sessions')) | |
); | |
} | |
} | |
authenticate(e, t) { | |
try { | |
const r = e.headers?.authorization; | |
if (!r || !r.startsWith('Bearer ')) | |
throw new Error('Authentication required'); | |
const i = r.split(' ')[1]; | |
try { | |
const s = this.verify(i); | |
(e.user = { email: s.sub }), t(); | |
} catch { | |
throw new Error('Invalid or expired token'); | |
} | |
} catch (r) { | |
t(r); | |
} | |
} | |
}; | |
var x = class { | |
db; | |
PREFIX_KV = 'kv:'; | |
PREFIX_COLLECTION = 'coll:'; | |
TABLE_NAME = 'mikroauth'; | |
constructor(e) { | |
this.db = e; | |
} | |
async start() { | |
await this.db.start(); | |
} | |
async close() { | |
await this.db.close(); | |
} | |
async set(e, t, r) { | |
const i = `${this.PREFIX_KV}${e}`, | |
s = r ? Date.now() + r * 1e3 : void 0; | |
await this.db.write({ | |
tableName: this.TABLE_NAME, | |
key: i, | |
value: t, | |
expiration: s | |
}); | |
} | |
async get(e) { | |
const t = `${this.PREFIX_KV}${e}`, | |
r = await this.db.get({ tableName: this.TABLE_NAME, key: t }); | |
return r || null; | |
} | |
async delete(e) { | |
const t = `${this.PREFIX_KV}${e}`; | |
await this.db.delete({ tableName: this.TABLE_NAME, key: t }); | |
} | |
async addToCollection(e, t, r) { | |
let i = `${this.PREFIX_COLLECTION}${e}`, | |
s = await this.db.get({ tableName: this.TABLE_NAME, key: i }), | |
a = []; | |
s && (a = JSON.parse(s)), a.includes(t) || a.push(t); | |
const d = r ? Date.now() + r * 1e3 : void 0; | |
await this.db.write({ | |
tableName: this.TABLE_NAME, | |
key: i, | |
value: JSON.stringify(a), | |
expiration: d | |
}); | |
} | |
async removeFromCollection(e, t) { | |
const r = `${this.PREFIX_COLLECTION}${e}`, | |
i = await this.db.get({ tableName: this.TABLE_NAME, key: r }); | |
if (!i) return; | |
let s = JSON.parse(i); | |
(s = s.filter((a) => a !== t)), | |
await this.db.write({ | |
tableName: this.TABLE_NAME, | |
key: r, | |
value: JSON.stringify(s) | |
}); | |
} | |
async getCollection(e) { | |
const t = `${this.PREFIX_COLLECTION}${e}`, | |
r = await this.db.get({ tableName: this.TABLE_NAME, key: t }); | |
return r ? JSON.parse(r) : []; | |
} | |
async getCollectionSize(e) { | |
return (await this.getCollection(e)).length; | |
} | |
async removeOldestFromCollection(e) { | |
const t = `${this.PREFIX_COLLECTION}${e}`, | |
r = await this.db.get({ tableName: this.TABLE_NAME, key: t }); | |
if (!r) return null; | |
const i = JSON.parse(r); | |
if (i.length === 0) return null; | |
const s = i.shift(); | |
return ( | |
await this.db.write({ | |
tableName: this.TABLE_NAME, | |
key: t, | |
value: JSON.stringify(i) | |
}), | |
s | |
); | |
} | |
async findKeys(e) { | |
const t = e.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'), | |
r = new RegExp(`^${t}$`); | |
return (await this.db.get({ tableName: this.TABLE_NAME })) | |
.filter((s) => { | |
const a = s[0]; | |
return typeof a == 'string' && a.startsWith(this.PREFIX_KV); | |
}) | |
.map((s) => s[0].substring(this.PREFIX_KV.length)) | |
.filter((s) => r.test(s)); | |
} | |
}; | |
var k = class { | |
email; | |
sender; | |
constructor(e) { | |
(this.sender = e.user), (this.email = new MikroMail({ config: e })); | |
} | |
async sendMail(e) { | |
await this.email.send({ | |
from: this.sender, | |
to: e.to, | |
cc: e.cc, | |
bcc: e.bcc, | |
subject: e.subject, | |
text: e.text, | |
html: e.html | |
}); | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-XNHPVSW4.mjs | |
var NotFoundError = class extends Error { | |
constructor(message) { | |
super(); | |
this.name = 'NotFoundError'; | |
this.message = message || 'Resource not found'; | |
this.cause = { statusCode: 404 }; | |
} | |
}; | |
var CheckpointError = class extends Error { | |
constructor(message) { | |
super(); | |
this.name = 'CheckpointError'; | |
this.message = message; | |
this.cause = { statusCode: 500 }; | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-P5SMO7C5.mjs | |
var time = () => Date.now(); | |
var getJsonValueFromEntry = (json, operation) => { | |
if (operation === 'D') return null; | |
if (json.length === 0 || json.join(' ') === 'null') return null; | |
return getJsonValue(json); | |
}; | |
var getJsonValue = (value) => { | |
try { | |
return JSON.parse(value.join(' ')); | |
} catch (_error) { | |
return void 0; | |
} | |
}; | |
function getTruthyValue(value) { | |
if (value === 'true' || value === true) return true; | |
return false; | |
} | |
function createGetOptions(options) { | |
const getValue = (value, format) => { | |
if (!value) return void 0; | |
if (format === 'json') return getJsonValue(value) || value; | |
if (format === 'number') return Number.parseInt(value, 10); | |
return void 0; | |
}; | |
const filter = getValue(options?.filter, 'json'); | |
const sort = getValue(options?.sort, 'json'); | |
const limit = getValue(options?.limit, 'number'); | |
const offset = getValue(options?.offset, 'number'); | |
if (!filter && !sort && !limit && !offset) return void 0; | |
return { | |
filter, | |
sort, | |
limit, | |
offset | |
}; | |
} | |
// node_modules/mikrodb/lib/chunk-2CSO3WVR.mjs | |
import { existsSync as existsSync3 } from 'node:fs'; | |
import { readFile, unlink, writeFile } from 'node:fs/promises'; | |
var Checkpoint = class { | |
/** | |
* Checkpoint interval in milliseconds. | |
*/ | |
checkpointInterval; | |
/** | |
* Default time in milliseconds before checkpointing. | |
*/ | |
defaultCheckpointIntervalMs = 10 * 1e3; | |
/** | |
* Flag to prevent concurrent checkpoints. | |
*/ | |
isCheckpointing = false; | |
/** | |
* Timestamp of the last successful checkpoint. | |
*/ | |
lastCheckpointTime = 0; | |
/** | |
* Write-ahead log file path. | |
*/ | |
walFile; | |
/** | |
* Checkpoint interval timer. | |
*/ | |
checkpointTimer = null; | |
wal; | |
table; | |
constructor(options) { | |
const { table, wal, walFile, checkpointIntervalMs } = options; | |
this.defaultCheckpointIntervalMs = | |
checkpointIntervalMs || this.defaultCheckpointIntervalMs; | |
this.table = table; | |
this.wal = wal; | |
this.walFile = walFile; | |
this.checkpointInterval = checkpointIntervalMs; | |
this.lastCheckpointTime = time(); | |
} | |
/** | |
* @description Start the checkpoint service. | |
*/ | |
async start() { | |
const checkpointFile = `${this.walFile}.checkpoint`; | |
if (existsSync3(checkpointFile)) { | |
console.log('Incomplete checkpoint detected, running recovery...'); | |
try { | |
const checkpointTimestamp = await readFile(checkpointFile, 'utf8'); | |
console.log( | |
`Incomplete checkpoint from: ${new Date(Number.parseInt(checkpointTimestamp))}` | |
); | |
await this.checkpoint(true); | |
} catch (error) { | |
throw new NotFoundError(`Error reading checkpoint file: ${error}`); | |
} | |
} | |
this.checkpointTimer = setInterval(async () => { | |
try { | |
await this.checkpoint(); | |
} catch (error) { | |
throw new CheckpointError(`Checkpoint interval failed: ${error}`); | |
} | |
}, this.checkpointInterval); | |
} | |
/** | |
* @description Stop the checkpoint service. | |
*/ | |
stop() { | |
if (this.checkpointTimer) { | |
clearInterval(this.checkpointTimer); | |
this.checkpointTimer = null; | |
} | |
this.isCheckpointing = false; | |
} | |
/** | |
* @description Perform a checkpoint operation to clean up WAL. | |
* This ensures all tables mentioned in the WAL are persisted to disk, | |
* and then truncates the WAL file. | |
*/ | |
async checkpoint(force = false) { | |
if (this.isCheckpointing) return; | |
const now = time(); | |
if (!force && now - this.lastCheckpointTime < this.checkpointInterval) | |
return; | |
this.isCheckpointing = true; | |
try { | |
await this.wal.flushWAL(); | |
const checkpointFile = `${this.walFile}.checkpoint`; | |
await writeFile(checkpointFile, now.toString(), 'utf8'); | |
const tablesInWAL = await this.getTablesFromWAL(); | |
await this.persistTables(tablesInWAL); | |
try { | |
await writeFile(this.walFile, '', 'utf8'); | |
if (process.env.DEBUG === 'true') | |
console.log('WAL truncated successfully'); | |
} catch (error) { | |
throw new CheckpointError(`Failed to truncate WAL: ${error}`); | |
} | |
if (existsSync3(checkpointFile)) await unlink(checkpointFile); | |
this.wal.clearPositions(); | |
this.lastCheckpointTime = now; | |
if (process.env.DEBUG === 'true') console.log('Checkpoint complete'); | |
} catch (error) { | |
throw new CheckpointError(`Checkpoint failed: ${error}`); | |
} finally { | |
this.isCheckpointing = false; | |
} | |
} | |
/** | |
* @description Extract table names from WAL entries. | |
*/ | |
async getTablesFromWAL() { | |
const tablesInWAL = /* @__PURE__ */ new Set(); | |
if (!existsSync3(this.walFile)) return tablesInWAL; | |
try { | |
const walContent = await readFile(this.walFile, 'utf8'); | |
if (!walContent.trim()) return tablesInWAL; | |
const entries = walContent.trim().split('\n'); | |
for (const entry of entries) { | |
if (!entry.trim()) continue; | |
const parts = entry.split(' '); | |
if (parts.length >= 3) tablesInWAL.add(parts[2]); | |
} | |
} catch (error) { | |
throw new CheckpointError(`Error reading WAL file: ${error}`); | |
} | |
return tablesInWAL; | |
} | |
/** | |
* @description Persist tables to disk. | |
*/ | |
async persistTables(tableNames) { | |
const persistPromises = Array.from(tableNames).map(async (tableName) => { | |
try { | |
await this.table.flushTableToDisk(tableName); | |
console.log(`Checkpointed table "${tableName}"`); | |
} catch (error) { | |
throw new CheckpointError( | |
`Failed to checkpoint table "${tableName}": ${error.message}` | |
); | |
} | |
}); | |
await Promise.all(persistPromises); | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-SWRLKQPO.mjs | |
import { existsSync as existsSync4, statSync, writeFileSync } from 'node:fs'; | |
import { appendFile, readFile as readFile2 } from 'node:fs/promises'; | |
var WriteAheadLog = class { | |
/** | |
* The path to the WAL file. | |
*/ | |
walFile; | |
/** | |
* WAL flush interval in milliseconds. | |
*/ | |
walInterval; | |
/** | |
* Buffer of all WAL changes. | |
*/ | |
walBuffer = []; | |
/** | |
* The maximum number of WAL entries before flushing. | |
*/ | |
maxWalBufferEntries = Number.parseInt( | |
process.env.MAX_WAL_BUFFER_ENTRIES || '100' | |
); | |
/** | |
* The maximum size of the WAL buffer before flushing. | |
*/ | |
maxWalBufferSize = Number.parseInt( | |
process.env.MAX_WAL_BUFFER_SIZE || (1024 * 1024 * 0.01).toString() | |
// 10 KB | |
); | |
/** | |
* The maximum size of the WAL buffer before checkpointing. | |
*/ | |
maxWalSizeBeforeCheckpoint = Number.parseInt( | |
process.env.MAX_WAL_BUFFER_SIZE || (1024 * 1024 * 0.01).toString() | |
// 10 KB | |
); | |
/** | |
* Keeps track of the last processed entries. | |
*/ | |
lastProcessedEntryCount = /* @__PURE__ */ new Map(); | |
/** | |
* A callback function to run when checkpointing. | |
*/ | |
checkpointCallback = null; | |
walTimer = null; | |
constructor(walFile, walInterval) { | |
this.walFile = walFile; | |
this.walInterval = walInterval; | |
this.start(); | |
} | |
/** | |
* @description Sets the checkpoint callback function. | |
*/ | |
setCheckpointCallback(callback) { | |
this.checkpointCallback = callback; | |
} | |
/** | |
* @description Start the WAL functionality. | |
*/ | |
start() { | |
if (!existsSync4(this.walFile)) writeFileSync(this.walFile, '', 'utf-8'); | |
this.walTimer = setInterval(async () => { | |
try { | |
if (this.walBuffer.length > 0) await this.flushWAL(); | |
} catch (error) { | |
console.error('WAL flush interval failed:', error); | |
} | |
}, this.walInterval); | |
} | |
/** | |
* @description Stop the WAL timer. Primarily used for tests. | |
*/ | |
stop() { | |
if (this.walTimer) { | |
clearInterval(this.walTimer); | |
this.walTimer = null; | |
} | |
} | |
/** | |
* @description Check if the WAL file exists. | |
*/ | |
checkWalFileExists() { | |
if (!existsSync4(this.walFile)) | |
throw new NotFoundError(`WAL file "${this.walFile}" does not exist`); | |
} | |
/** | |
* @description Load WAL entries and return operations to be applied. | |
*/ | |
async loadWAL(table) { | |
this.checkWalFileExists(); | |
const operations = []; | |
const walSize = statSync(this.walFile)?.size || 0; | |
if (walSize === 0) return operations; | |
try { | |
const walData = await readFile2(this.walFile, 'utf8'); | |
const logEntries = walData.trim().split('\n'); | |
const now = time(); | |
for (let index = 0; index < logEntries.length; index++) { | |
const entry = logEntries[index]; | |
if (!entry.trim()) continue; | |
const lastPosition = this.lastProcessedEntryCount.get(table || '') || 0; | |
if (table && index < lastPosition) continue; | |
const [ | |
_timestamp, | |
operation, | |
tableName, | |
version, | |
expiration, | |
key, | |
...json | |
] = logEntries[index].split(' '); | |
if (table && tableName !== table) continue; | |
const parsedVersion = Number(version.split(':')[1]); | |
const parsedExpiration = | |
expiration === '0' ? null : Number(expiration.split(':')[1]); | |
if (parsedExpiration && parsedExpiration < now) continue; | |
const value = getJsonValueFromEntry(json, operation); | |
if (value === void 0) continue; | |
if (table) this.lastProcessedEntryCount.set(table, index + 1); | |
if (operation === 'W') { | |
operations.push({ | |
operation: 'W', | |
tableName, | |
key, | |
data: { | |
value, | |
version: parsedVersion, | |
timestamp: now, | |
expiration: parsedExpiration | |
} | |
}); | |
} else if (operation === 'D') { | |
operations.push({ | |
operation: 'D', | |
tableName, | |
key | |
}); | |
} | |
} | |
return operations; | |
} catch (error) { | |
if (table) | |
console.error( | |
`Failed to replay WAL for table "${table}": ${error.message}` | |
); | |
else console.error(`Failed to replay WAL: ${error.message}`); | |
return operations; | |
} | |
} | |
/** | |
* @description Checks if there are any new WAL entries for a table. | |
*/ | |
async hasNewWALEntriesForTable(tableName) { | |
this.checkWalFileExists(); | |
try { | |
const walData = await readFile2(this.walFile, 'utf8'); | |
if (!walData.trim()) return false; | |
const logEntries = walData.trim().split('\n'); | |
const lastEntryCount = this.lastProcessedEntryCount.get(tableName) || 0; | |
if (lastEntryCount >= logEntries.length) return false; | |
for (let i = lastEntryCount; i < logEntries.length; i++) { | |
const entry = logEntries[i]; | |
if (!entry.trim()) continue; | |
const parts = entry.split(' '); | |
if (parts.length >= 3 && parts[2] === tableName) return true; | |
} | |
return false; | |
} catch (error) { | |
console.error(`Error checking WAL for ${tableName}:`, error); | |
return true; | |
} | |
} | |
/** | |
* @description Flush the WAL buffer to disk. | |
*/ | |
async flushWAL() { | |
this.checkWalFileExists(); | |
if (this.walBuffer.length === 0) return; | |
const bufferToFlush = [...this.walBuffer]; | |
this.walBuffer = []; | |
try { | |
await appendFile(this.walFile, bufferToFlush.join(''), 'utf8'); | |
const stats = statSync(this.walFile); | |
if (stats.size > this.maxWalSizeBeforeCheckpoint) { | |
if (process.env.DEBUG === 'true') | |
console.log( | |
`WAL size (${stats.size}) exceeds limit (${this.maxWalSizeBeforeCheckpoint}), triggering checkpoint` | |
); | |
if (this.checkpointCallback) { | |
setImmediate(async () => { | |
try { | |
await this.checkpointCallback(); | |
} catch (error) { | |
console.error('Error during automatic checkpoint:', error); | |
} | |
}); | |
} | |
} | |
} catch (error) { | |
console.error(`Failed to flush WAL: ${error.message}`); | |
this.walBuffer = [...bufferToFlush, ...this.walBuffer]; | |
throw error; | |
} | |
} | |
/** | |
* @description Append operation to the WAL (with version, timestamp, and expiration). | |
*/ | |
async appendToWAL(tableName, operation, key, value, version, expiration = 0) { | |
this.checkWalFileExists(); | |
const timestamp = time(); | |
const logEntry = `${timestamp} ${operation} ${tableName} v:${version} x:${expiration} ${key} ${JSON.stringify(value)} | |
`; | |
this.walBuffer.push(logEntry); | |
if (this.walBuffer.length >= this.maxWalBufferEntries) | |
await this.flushWAL(); | |
const estimatedBufferSize = this.walBuffer.reduce( | |
(size, entry) => size + entry.length, | |
0 | |
); | |
if (estimatedBufferSize >= this.maxWalBufferSize) await this.flushWAL(); | |
const stats = statSync(this.walFile); | |
if (stats.size > this.maxWalSizeBeforeCheckpoint) { | |
if (process.env.DEBUG === 'true') | |
console.log( | |
`WAL size (${stats.size}) exceeds limit (${this.maxWalSizeBeforeCheckpoint}), triggering checkpoint` | |
); | |
if (this.checkpointCallback) { | |
setImmediate(async () => { | |
try { | |
await this.checkpointCallback(); | |
} catch (error) { | |
console.error('Error during automatic checkpoint:', error); | |
} | |
}); | |
} | |
} | |
} | |
/** | |
* @description Reset the count or position of the last processed entries that are tracked. | |
*/ | |
clearPositions() { | |
this.lastProcessedEntryCount.clear(); | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-IRTCGE36.mjs | |
var configDefaults = () => { | |
return { | |
db: { | |
dbName: 'mikrodb', | |
databaseDirectory: 'mikrodb', | |
walFileName: 'wal.log', | |
walInterval: 2e3, | |
encryptionKey: '', | |
maxWriteOpsBeforeFlush: 100, | |
debug: getTruthyValue(process.env.DEBUG) || false | |
}, | |
events: {}, | |
server: { | |
port: Number(process.env.PORT) || 3e3, | |
host: process.env.HOST || '0.0.0.0', | |
useHttps: false, | |
useHttp2: false, | |
sslCert: '', | |
sslKey: '', | |
sslCa: '', | |
rateLimit: { | |
enabled: true, | |
requestsPerMinute: 100 | |
}, | |
allowedDomains: ['*'], | |
debug: getTruthyValue(process.env.DEBUG) || false | |
} | |
}; | |
}; | |
// node_modules/mikrodb/lib/chunk-LFATMQSS.mjs | |
import { | |
createCipheriv, | |
createDecipheriv, | |
randomBytes, | |
scryptSync | |
} from 'node:crypto'; | |
var Encryption = class { | |
algo = 'aes-256-gcm'; | |
KEY_LENGTH = 32; | |
// 256 bits | |
IV_LENGTH = 12; | |
// 96 bits - recommended for GCM | |
/** | |
* @description Derives a key from password and salt using scrypt | |
*/ | |
generateKey(password, salt) { | |
return scryptSync(`${salt}#${password}`, salt, this.KEY_LENGTH); | |
} | |
/** | |
* @description Generates a random IV of appropriate length | |
*/ | |
generateIV() { | |
return randomBytes(this.IV_LENGTH); | |
} | |
/** | |
* @description Encrypts plain text using AES-256-GCM | |
* @example | |
* const key = encryption.generateKey(password, salt); | |
* const iv = encryption.generateIV(); | |
* const encryptedData = encryption.encrypt(plainText, key, iv); | |
*/ | |
encrypt(plainText, key, iv) { | |
if (key.length !== this.KEY_LENGTH) { | |
throw new Error( | |
`Invalid key length: ${key.length} bytes. Expected: ${this.KEY_LENGTH} bytes` | |
); | |
} | |
if (iv.length !== this.IV_LENGTH) { | |
throw new Error( | |
`Invalid IV length: ${iv.length} bytes. Expected: ${this.IV_LENGTH} bytes` | |
); | |
} | |
const cipher = createCipheriv(this.algo, key, iv); | |
const encrypted = Buffer.concat([ | |
cipher.update(plainText, 'utf8'), | |
cipher.final() | |
]); | |
const authTag = cipher.getAuthTag(); | |
return { iv, encrypted, authTag }; | |
} | |
/** | |
* @description Decrypts encrypted data using AES-256-GCM | |
* @example | |
* const key = encryption.generateKey(password, salt); | |
* const decrypted = encryption.decrypt(encryptedData, key); | |
*/ | |
decrypt(encryptedData, key) { | |
const { iv, encrypted, authTag } = encryptedData; | |
if (key.length !== this.KEY_LENGTH) { | |
throw new Error( | |
`Invalid key length: ${key.length} bytes. Expected: ${this.KEY_LENGTH} bytes` | |
); | |
} | |
if (iv.length !== this.IV_LENGTH) { | |
throw new Error( | |
`Invalid IV length: ${iv.length} bytes. Expected: ${this.IV_LENGTH} bytes` | |
); | |
} | |
const decipher = createDecipheriv(this.algo, key, iv); | |
decipher.setAuthTag(authTag); | |
return Buffer.concat([ | |
decipher.update(encrypted), | |
decipher.final() | |
]).toString('utf8'); | |
} | |
/** | |
* @description Combines encrypted data into a single buffer for storage | |
*/ | |
serialize(data) { | |
return Buffer.concat([ | |
// First byte: version for future format changes | |
Buffer.from([1]), | |
// Next byte: IV length | |
Buffer.from([data.iv.length]), | |
// IV | |
data.iv, | |
// Next byte: authTag length | |
Buffer.from([data.authTag.length]), | |
// AuthTag | |
data.authTag, | |
// Encrypted data | |
data.encrypted | |
]); | |
} | |
/** | |
* @description Extracts encrypted data components from a serialized buffer | |
*/ | |
deserialize(serialized) { | |
let offset = 0; | |
const version = serialized[offset++]; | |
if (version !== 1) { | |
throw new Error(`Unsupported encryption format version: ${version}`); | |
} | |
const ivLength = serialized[offset++]; | |
const iv = serialized.subarray(offset, offset + ivLength); | |
offset += ivLength; | |
const authTagLength = serialized[offset++]; | |
const authTag = serialized.subarray(offset, offset + authTagLength); | |
offset += authTagLength; | |
const encrypted = serialized.subarray(offset); | |
return { iv, authTag, encrypted }; | |
} | |
// Utility methods | |
toHex(value) { | |
return value.toString('hex'); | |
} | |
toUtf8(value) { | |
return value.toString('utf8'); | |
} | |
toBuffer(hexString) { | |
return Buffer.from(hexString, 'hex'); | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-EU6BRX7P.mjs | |
var Persistence = class { | |
/** | |
* @description Read table data from binary format with optimizations. | |
*/ | |
readTableFromBinaryBuffer(buffer) { | |
if ( | |
buffer.length < 8 || | |
buffer[0] !== 77 || // 'M' | |
buffer[1] !== 68 || // 'D' | |
buffer[2] !== 66 || // 'B' | |
buffer[3] !== 1 | |
) { | |
throw new Error('Invalid table file format'); | |
} | |
const recordCount = buffer.readUInt32LE(4); | |
const tableData = /* @__PURE__ */ new Map(); | |
let offset = 8; | |
const now = time(); | |
for (let i = 0; i < recordCount && offset + 26 <= buffer.length; i++) { | |
const keyLength = buffer.readUInt16LE(offset); | |
offset += 2; | |
const valueLength = buffer.readUInt32LE(offset); | |
offset += 4; | |
const version = buffer.readUInt32LE(offset); | |
offset += 4; | |
const timestamp = Number(buffer.readBigUInt64LE(offset)); | |
offset += 8; | |
const expiration = Number(buffer.readBigUInt64LE(offset)); | |
offset += 8; | |
if (offset + keyLength + valueLength > buffer.length) break; | |
if (expiration && expiration <= now) { | |
offset += keyLength + valueLength; | |
continue; | |
} | |
const key = buffer.toString('utf8', offset, offset + keyLength); | |
offset += keyLength; | |
const valueBuffer = buffer.slice(offset, offset + valueLength); | |
offset += valueLength; | |
const value = this.decodeValue(valueBuffer); | |
tableData.set(key, { | |
value, | |
version, | |
timestamp, | |
expiration: expiration || null | |
}); | |
} | |
return tableData; | |
} | |
/** | |
* @description Write table data to a binary file format. | |
*/ | |
toBinaryBuffer(tableData) { | |
const chunks = []; | |
const header = Buffer.from([77, 68, 66, 1]); | |
chunks.push(header); | |
const validEntries = Array.from(tableData.entries()).filter( | |
([key]) => typeof key === 'string' | |
); | |
const countBuffer = Buffer.alloc(4); | |
countBuffer.writeUInt32LE(validEntries.length, 0); | |
chunks.push(countBuffer); | |
for (const [key, record] of validEntries) { | |
if (key === null || typeof key !== 'string') continue; | |
const keyBuffer = Buffer.from(key); | |
const valueBuffer = this.encodeValue(record.value); | |
const keyLenBuffer = Buffer.alloc(2); | |
keyLenBuffer.writeUInt16LE(keyBuffer.length, 0); | |
chunks.push(keyLenBuffer); | |
const valueLenBuffer = Buffer.alloc(4); | |
valueLenBuffer.writeUInt32LE(valueBuffer.length, 0); | |
chunks.push(valueLenBuffer); | |
const versionBuffer = Buffer.alloc(4); | |
versionBuffer.writeUInt32LE(record.version || 0, 0); | |
chunks.push(versionBuffer); | |
const timestampBuffer = Buffer.alloc(8); | |
timestampBuffer.writeBigUInt64LE(BigInt(record.timestamp || 0), 0); | |
chunks.push(timestampBuffer); | |
const expirationBuffer = Buffer.alloc(8); | |
expirationBuffer.writeBigUInt64LE(BigInt(record.expiration || 0), 0); | |
chunks.push(expirationBuffer); | |
chunks.push(keyBuffer); | |
chunks.push(valueBuffer); | |
} | |
return Buffer.concat(chunks); | |
} | |
/** | |
* @description Encode a JavaScript value to a binary format. | |
*/ | |
encodeValue(value) { | |
if (value === null || value === void 0) return Buffer.from([0]); | |
if (typeof value === 'boolean') return Buffer.from([1, value ? 1 : 0]); | |
if (typeof value === 'number') { | |
if ( | |
Number.isInteger(value) && | |
value >= -2147483648 && | |
value <= 2147483647 | |
) { | |
const buf2 = Buffer.alloc(5); | |
buf2[0] = 2; | |
buf2.writeInt32LE(value, 1); | |
return buf2; | |
} | |
const buf = Buffer.alloc(9); | |
buf[0] = 3; | |
buf.writeDoubleLE(value, 1); | |
return buf; | |
} | |
if (typeof value === 'string') { | |
const strBuf = Buffer.from(value, 'utf8'); | |
const buf = Buffer.alloc(5 + strBuf.length); | |
buf[0] = 4; | |
buf.writeUInt32LE(strBuf.length, 1); | |
strBuf.copy(buf, 5); | |
return buf; | |
} | |
if (Array.isArray(value)) { | |
const encodedItems = []; | |
const countBuf = Buffer.alloc(5); | |
countBuf[0] = 5; | |
countBuf.writeUInt32LE(value.length, 1); | |
encodedItems.push(countBuf); | |
for (const item of value) encodedItems.push(this.encodeValue(item)); | |
return Buffer.concat(encodedItems); | |
} | |
if (typeof value === 'object') { | |
if (value instanceof Date) { | |
const buf = Buffer.alloc(9); | |
buf[0] = 7; | |
buf.writeBigInt64LE(BigInt(value.getTime()), 1); | |
return buf; | |
} | |
const keys = Object.keys(value); | |
const encodedItems = []; | |
const countBuf = Buffer.alloc(5); | |
countBuf[0] = 6; | |
countBuf.writeUInt32LE(keys.length, 1); | |
encodedItems.push(countBuf); | |
for (const key of keys) { | |
const keyBuf = Buffer.from(key, 'utf8'); | |
const keyLenBuf = Buffer.alloc(2); | |
keyLenBuf.writeUInt16LE(keyBuf.length, 0); | |
encodedItems.push(keyLenBuf); | |
encodedItems.push(keyBuf); | |
encodedItems.push(this.encodeValue(value[key])); | |
} | |
return Buffer.concat(encodedItems); | |
} | |
return this.encodeValue(String(value)); | |
} | |
/** | |
* @description Optimized decode value with specialized fast paths | |
*/ | |
decodeValue(buffer) { | |
if (buffer.length === 0) return null; | |
const type = buffer[0]; | |
switch (type) { | |
case 0: | |
return null; | |
case 1: | |
return buffer[1] === 1; | |
case 2: | |
return buffer.readInt32LE(1); | |
case 3: | |
return buffer.readDoubleLE(1); | |
case 4: { | |
const length = buffer.readUInt32LE(1); | |
return buffer.toString('utf8', 5, 5 + length); | |
} | |
case 5: { | |
const count = buffer.readUInt32LE(1); | |
const array = new Array(count); | |
let offset = 5; | |
for (let i = 0; i < count; i++) { | |
const { value, bytesRead } = this.decodeValueWithSize(buffer, offset); | |
array[i] = value; | |
offset += bytesRead; | |
} | |
return array; | |
} | |
case 6: { | |
const count = buffer.readUInt32LE(1); | |
const obj = {}; | |
let offset = 5; | |
for (let i = 0; i < count; i++) { | |
const keyLength = buffer.readUInt16LE(offset); | |
offset += 2; | |
const key = buffer.toString('utf8', offset, offset + keyLength); | |
offset += keyLength; | |
const { value, bytesRead } = this.decodeValueWithSize(buffer, offset); | |
obj[key] = value; | |
offset += bytesRead; | |
} | |
return obj; | |
} | |
case 7: | |
return new Date(Number(buffer.readBigInt64LE(1))); | |
default: | |
console.warn(`Unknown type byte: ${type}`); | |
return null; | |
} | |
} | |
/** | |
* @description Optimized helper method to decode a value using absolute offsets | |
*/ | |
decodeValueWithSize(buffer, startOffset = 0) { | |
if (startOffset >= buffer.length) return { value: null, bytesRead: 0 }; | |
const type = buffer[startOffset]; | |
switch (type) { | |
case 0: | |
return { value: null, bytesRead: 1 }; | |
case 1: | |
return { value: buffer[startOffset + 1] === 1, bytesRead: 2 }; | |
case 2: | |
return { value: buffer.readInt32LE(startOffset + 1), bytesRead: 5 }; | |
case 3: | |
return { value: buffer.readDoubleLE(startOffset + 1), bytesRead: 9 }; | |
case 4: { | |
const length = buffer.readUInt32LE(startOffset + 1); | |
const value = buffer.toString( | |
'utf8', | |
startOffset + 5, | |
startOffset + 5 + length | |
); | |
return { value, bytesRead: 5 + length }; | |
} | |
case 5: { | |
const count = buffer.readUInt32LE(startOffset + 1); | |
const array = new Array(count); | |
let offset = startOffset + 5; | |
for (let i = 0; i < count; i++) { | |
const result = this.decodeValueWithSize(buffer, offset); | |
array[i] = result.value; | |
offset += result.bytesRead; | |
} | |
return { value: array, bytesRead: offset - startOffset }; | |
} | |
case 6: { | |
const count = buffer.readUInt32LE(startOffset + 1); | |
const obj = {}; | |
let offset = startOffset + 5; | |
for (let i = 0; i < count; i++) { | |
const keyLength = buffer.readUInt16LE(offset); | |
offset += 2; | |
const key = buffer.toString('utf8', offset, offset + keyLength); | |
offset += keyLength; | |
const result = this.decodeValueWithSize(buffer, offset); | |
obj[key] = result.value; | |
offset += result.bytesRead; | |
} | |
return { value: obj, bytesRead: offset - startOffset }; | |
} | |
case 7: | |
return { | |
value: new Date(Number(buffer.readBigInt64LE(startOffset + 1))), | |
bytesRead: 9 | |
}; | |
default: | |
console.warn(`Unknown type byte: ${type} at offset ${startOffset}`); | |
return { value: null, bytesRead: 1 }; | |
} | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-HV5HISKT.mjs | |
import { writeFile as writeFile2 } from 'node:fs/promises'; | |
async function writeToDisk(filePath, data, encryptionKey) { | |
const encryption = new Encryption(); | |
const persistence = new Persistence(); | |
let buffer = persistence.toBinaryBuffer(data); | |
if (!buffer) { | |
console.log('Buffer is empty, skipping...'); | |
return; | |
} | |
if (encryptionKey) { | |
try { | |
const key = encryption.generateKey(encryptionKey, 'salt'); | |
const dataString = buffer.toString('binary'); | |
const iv = encryption.generateIV(); | |
const encryptedData = encryption.encrypt(dataString, key, iv); | |
buffer = encryption.serialize(encryptedData); | |
} catch (error) { | |
console.error('Encryption failed:', error); | |
} | |
} | |
await writeFile2(filePath, buffer); | |
} | |
// node_modules/mikrodb/lib/chunk-JW5KA4ZR.mjs | |
var Cache = class { | |
/** | |
* Max number of tables to cache in memory. | |
*/ | |
cacheLimit; | |
/** | |
* Track table access times for Least Recently Used (LRU) eviction. | |
*/ | |
tableAccessTimes = /* @__PURE__ */ new Map(); | |
constructor(options = {}) { | |
this.cacheLimit = options.cacheLimit ?? 20; | |
} | |
/** | |
* @description Track access to a table to update LRU information. | |
*/ | |
trackTableAccess(tableName) { | |
this.tableAccessTimes.set(tableName, time()); | |
} | |
/** | |
* @description Find tables that should be evicted based on LRU policy. | |
*/ | |
findTablesForEviction(currentTableCount) { | |
if (currentTableCount <= this.cacheLimit) return []; | |
const evictionCount = currentTableCount - this.cacheLimit; | |
const evictionCandidates = Array.from(this.tableAccessTimes.entries()) | |
.sort((a, b) => a[1] - b[1]) | |
.slice(0, evictionCount) | |
.map(([tableName]) => tableName); | |
for (const tableName of evictionCandidates) | |
this.tableAccessTimes.delete(tableName); | |
return evictionCandidates; | |
} | |
/** | |
* @description Remove table from tracking. | |
*/ | |
removeTable(tableName) { | |
this.tableAccessTimes.delete(tableName); | |
} | |
/** | |
* @description Check if items are expired. | |
*/ | |
findExpiredItems(items) { | |
const currentTimestamp = time(); | |
const expiredItems = []; | |
for (const [key, item] of items.entries()) { | |
if (item.x && item.x < currentTimestamp) expiredItems.push([key, item]); | |
} | |
return expiredItems; | |
} | |
/** | |
* @description Clear all cache tracking data. | |
*/ | |
clear() { | |
this.tableAccessTimes.clear(); | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-UIMLZ7S2.mjs | |
var Query = class { | |
/** | |
* @description Query a value from a table with filtering criteria. | |
*/ | |
async query(table, filter, limit = 50) { | |
const records = /* @__PURE__ */ new Map(); | |
for (const [k2, v2] of table.entries()) { | |
if ( | |
!filter || | |
(typeof filter === 'function' | |
? filter(v2.value) | |
: this.evaluateFilter(v2.value, filter)) | |
) { | |
records.set(k2, v2.value); | |
if (limit && records.size >= limit) break; | |
} | |
} | |
return Array.from(records.values()); | |
} | |
/** | |
* @description Evaluates a single filter condition against a value. | |
*/ | |
evaluateCondition(value, condition) { | |
if (!condition || typeof condition !== 'object' || !condition.operator) | |
return value === condition; | |
const { operator, value: targetValue } = condition; | |
switch (operator) { | |
case 'eq': | |
return value === targetValue; | |
case 'neq': | |
return value !== targetValue; | |
case 'gt': | |
return value > targetValue; | |
case 'gte': | |
return value >= targetValue; | |
case 'lt': | |
return value < targetValue; | |
case 'lte': | |
return value <= targetValue; | |
case 'in': | |
return Array.isArray(targetValue) && targetValue.includes(value); | |
case 'nin': | |
return Array.isArray(targetValue) && !targetValue.includes(value); | |
case 'like': | |
return ( | |
typeof value === 'string' && | |
typeof targetValue === 'string' && | |
value.toLowerCase().includes(targetValue.toLowerCase()) | |
); | |
case 'between': | |
return ( | |
Array.isArray(targetValue) && | |
targetValue.length === 2 && | |
value >= targetValue[0] && | |
value <= targetValue[1] | |
); | |
case 'regex': | |
try { | |
const regex = new RegExp(targetValue); | |
return typeof value === 'string' && regex.test(value); | |
} catch (e) { | |
console.error('Invalid regex pattern:', e); | |
return false; | |
} | |
case 'contains': | |
return Array.isArray(value) && value.includes(targetValue); | |
case 'containsAll': | |
return ( | |
Array.isArray(value) && | |
Array.isArray(targetValue) && | |
targetValue.every((item) => value.includes(item)) | |
); | |
case 'containsAny': | |
return ( | |
Array.isArray(value) && | |
Array.isArray(targetValue) && | |
targetValue.some((item) => value.includes(item)) | |
); | |
case 'size': | |
return Array.isArray(value) && value.length === targetValue; | |
default: | |
return false; | |
} | |
} | |
/** | |
* @description Evaluates a complex filter query against a record. | |
*/ | |
evaluateFilter(record, filter) { | |
if (record === null || record === void 0) { | |
return false; | |
} | |
if ('$or' in filter) | |
return filter.$or.some((subFilter) => | |
this.evaluateFilter(record, subFilter) | |
); | |
for (const [field, condition] of Object.entries(filter)) { | |
if (field.startsWith('$')) continue; | |
if (field.includes('.')) { | |
const parts = field.split('.'); | |
let value = record; | |
for (const part of parts) { | |
value = value?.[part]; | |
if (value === void 0 || value === null) return false; | |
} | |
if (!this.evaluateCondition(value, condition)) return false; | |
} else if ( | |
condition && | |
typeof condition === 'object' && | |
!('operator' in condition) | |
) { | |
const nestedValue = record[field]; | |
if (nestedValue === void 0 || nestedValue === null) return false; | |
if (!this.evaluateFilter(nestedValue, condition)) return false; | |
} else { | |
const value = record[field]; | |
if (!this.evaluateCondition(value, condition)) return false; | |
} | |
} | |
return true; | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-3QUL2PJV.mjs | |
import { existsSync as existsSync5, mkdirSync } from 'node:fs'; | |
import { | |
readFile as readFile3, | |
writeFile as writeFile3 | |
} from 'node:fs/promises'; | |
import { join } from 'node:path'; | |
// node_modules/mikroevent/lib/chunk-S5YKFESI.mjs | |
import { EventEmitter as EventEmitter2 } from 'node:events'; | |
var MikroEvent = class { | |
emitter; | |
targets = {}; | |
options; | |
constructor(options) { | |
this.emitter = new EventEmitter2(); | |
if (options?.maxListeners === 0) this.emitter.setMaxListeners(0); | |
else this.emitter.setMaxListeners(options?.maxListeners || 10); | |
this.options = { | |
errorHandler: options?.errorHandler || ((error) => console.error(error)) | |
}; | |
} | |
/** | |
* @description Add one or more Targets for events. | |
* @example | |
* // Add single Target that is triggered on all events | |
* events.addTarget({ name: 'my-internal-api', events: ['*'] }); | |
* // Add single Target using HTTPS fetch | |
* events.addTarget({ name: 'my-external-api', url: 'https://api.mydomain.com', events: ['*'] }); | |
* // Add multiple Targets, responding to multiple events (single Target shown) | |
* events.addTarget([{ name: 'my-interla-api', events: ['user.added', 'user.updated'] }]); | |
* @returns Boolean that expresses if all Targets were successfully added. | |
*/ | |
addTarget(target) { | |
const targets = Array.isArray(target) ? target : [target]; | |
const results = targets.map((target2) => { | |
if (this.targets[target2.name]) { | |
console.error(`Target with name '${target2.name}' already exists.`); | |
return false; | |
} | |
this.targets[target2.name] = { | |
name: target2.name, | |
url: target2.url, | |
headers: target2.headers || {}, | |
events: target2.events || [] | |
}; | |
return true; | |
}); | |
return results.every((result) => result === true); | |
} | |
/** | |
* @description Update an existing Target. | |
* @example | |
* events.updateTarget('system_a', { url: 'http://localhost:8000', events: ['user.updated'] }; | |
* @returns Boolean that expresses if the Target was successfully added. | |
*/ | |
updateTarget(name, update) { | |
if (!this.targets[name]) { | |
console.error(`Target with name '${name}' does not exist.`); | |
return false; | |
} | |
const target = this.targets[name]; | |
if (update.url !== void 0) target.url = update.url; | |
if (update.headers) | |
target.headers = { ...target.headers, ...update.headers }; | |
if (update.events) target.events = update.events; | |
return true; | |
} | |
/** | |
* @description Remove a Target. | |
* @example | |
* events.removeTarget('system_a'); | |
* @returns Boolean that expresses if the Target was successfully removed. | |
*/ | |
removeTarget(name) { | |
if (!this.targets[name]) { | |
console.error(`Target with name '${name}' does not exist.`); | |
return false; | |
} | |
delete this.targets[name]; | |
return true; | |
} | |
/** | |
* @description Add one or more events to an existing Target. | |
* @example | |
* events.addEventToTarget('system_a', ['user.updated', 'user.deleted']); | |
* @returns Boolean that expresses if all events were successfully added. | |
*/ | |
addEventToTarget(name, events) { | |
if (!this.targets[name]) { | |
console.error(`Target with name '${name}' does not exist.`); | |
return false; | |
} | |
const eventsArray = Array.isArray(events) ? events : [events]; | |
const target = this.targets[name]; | |
eventsArray.forEach((event) => { | |
if (!target.events.includes(event)) target.events.push(event); | |
}); | |
return true; | |
} | |
/** | |
* @description Register an event handler for internal events. | |
*/ | |
on(eventName, handler) { | |
this.emitter.on(eventName, handler); | |
return this; | |
} | |
/** | |
* @description Remove an event handler. | |
*/ | |
off(eventName, handler) { | |
this.emitter.off(eventName, handler); | |
return this; | |
} | |
/** | |
* @description Register a one-time event handler. | |
*/ | |
once(eventName, handler) { | |
this.emitter.once(eventName, handler); | |
return this; | |
} | |
/** | |
* @description Emit an event locally and to its bound Targets. | |
* @example | |
* await events.emit('user.added', { id: 'abc123', name: 'Sam Person' }); | |
* @return Returns a result object with success status and any errors. | |
*/ | |
async emit(eventName, data) { | |
const result = { | |
success: true, | |
errors: [] | |
}; | |
const makeError = (targetName, eventName2, error) => ({ | |
target: targetName, | |
event: eventName2, | |
error | |
}); | |
const targets = Object.values(this.targets).filter( | |
(target) => | |
target.events.includes(eventName) || target.events.includes('*') | |
); | |
targets | |
.filter((target) => !target.url) | |
.forEach((target) => { | |
try { | |
this.emitter.emit(eventName, data); | |
} catch (error) { | |
const actualError = | |
error instanceof Error ? error : new Error(String(error)); | |
result.errors.push({ | |
target: target.name, | |
event: eventName, | |
error: actualError | |
}); | |
this.options.errorHandler(actualError, eventName, data); | |
result.success = false; | |
} | |
}); | |
const externalTargets = targets.filter((target) => target.url); | |
if (externalTargets.length > 0) { | |
const promises = externalTargets.map(async (target) => { | |
try { | |
const response = await fetch(target.url, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
...target.headers | |
}, | |
body: JSON.stringify({ | |
eventName, | |
data | |
}) | |
}); | |
if (!response.ok) { | |
const errorMessage = `HTTP error! Status: ${response.status}: ${response.statusText}`; | |
const httpError = new Error(errorMessage); | |
result.errors.push(makeError(target.name, eventName, httpError)); | |
this.options.errorHandler(httpError, eventName, data); | |
result.success = false; | |
} | |
} catch (error) { | |
const actualError = | |
error instanceof Error ? error : new Error(String(error)); | |
result.errors.push(makeError(target.name, eventName, actualError)); | |
this.options.errorHandler(actualError, eventName, data); | |
result.success = false; | |
} | |
}); | |
await Promise.allSettled(promises); | |
} | |
return result; | |
} | |
/** | |
* @description Handle an incoming event arriving over HTTP. | |
* Used for server integrations, when you want to manually handle | |
* the incoming event payload. | |
* | |
* The processing will be async using `process.nextTick()` | |
* and running in a non-blocking fashion. | |
* @example | |
* await mikroEvent.handleIncomingEvent({ | |
* eventName: 'user.created', | |
* data: { id: '123', name: 'Test User' } | |
* }); | |
*/ | |
async handleIncomingEvent(body) { | |
try { | |
const { eventName, data } = | |
typeof body === 'string' ? JSON.parse(body) : body; | |
process.nextTick(() => { | |
try { | |
this.emitter.emit(eventName, data); | |
} catch (error) { | |
this.options.errorHandler( | |
error instanceof Error ? error : new Error(String(error)), | |
eventName, | |
data | |
); | |
} | |
}); | |
} catch (error) { | |
this.options.errorHandler( | |
error instanceof Error ? error : new Error(String(error)), | |
'parse_event' | |
); | |
throw error; | |
} | |
} | |
/** | |
* @description Create middleware for Express-style servers, i.e. | |
* using `req` and `res` objects. This is an approach that replaces | |
* using `handleIncomingEvent()` manually. | |
* @example | |
* const middleware = mikroEvent.createMiddleware(); | |
* await middleware(req, res, next); | |
*/ | |
createMiddleware() { | |
return async (req, res, next) => { | |
if (req.method !== 'POST') { | |
if (next) next(); | |
return; | |
} | |
if (req.body) { | |
try { | |
await this.handleIncomingEvent(req.body); | |
res.statusCode = 202; | |
res.end(); | |
} catch (error) { | |
res.statusCode = 400; | |
res.end(JSON.stringify({ error: 'Invalid event format' })); | |
if (next) next(error); | |
} | |
} else { | |
let body = ''; | |
req.on('data', (chunk) => (body += chunk.toString())); | |
req.on('end', async () => { | |
try { | |
await this.handleIncomingEvent(body); | |
res.statusCode = 202; | |
res.end(); | |
} catch (error) { | |
res.statusCode = 400; | |
res.end(JSON.stringify({ error: 'Invalid event format' })); | |
if (next) next(error); | |
} | |
}); | |
} | |
}; | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-3QUL2PJV.mjs | |
var Table = class { | |
/** | |
* Directory where the table files will be stored. | |
*/ | |
databaseDirectory; | |
/** | |
* Write-ahead log file path. | |
*/ | |
walFile; | |
/** | |
* Tracks the currently active table. | |
*/ | |
activeTable = null; | |
/** | |
* In-memory store for active tables. | |
*/ | |
data = /* @__PURE__ */ new Map(); | |
/** | |
* Buffer to store committed write operations before flushing them to disk. | |
*/ | |
writeBuffer = []; | |
/** | |
* The maximum number of writes that happen before flushing data to disk. | |
*/ | |
maxWriteOpsBeforeFlush = process.env.MAX_WRITE_OPS_BEFORE_FLUSH | |
? Number.parseInt(process.env.MAX_WRITE_OPS_BEFORE_FLUSH) | |
: configDefaults().db.maxWriteOpsBeforeFlush; | |
/** | |
* Optional encryption key to use for encrypt and decrypt actions. | |
*/ | |
encryptionKey; | |
// Class references | |
cache; | |
encryption; | |
persistence; | |
query; | |
wal; | |
mikroEvent; | |
constructor(options, eventOptions) { | |
const { databaseDirectory, walFileName, walInterval } = options; | |
this.databaseDirectory = databaseDirectory; | |
this.walFile = join(this.databaseDirectory, walFileName); | |
this.encryptionKey = options.encryptionKey ? options.encryptionKey : null; | |
if (!existsSync5(this.databaseDirectory)) mkdirSync(this.databaseDirectory); | |
this.cache = new Cache(); | |
this.encryption = new Encryption(); | |
this.persistence = new Persistence(); | |
this.query = new Query(); | |
this.wal = new WriteAheadLog(this.walFile, walInterval); | |
this.mikroEvent = new MikroEvent(); | |
this.setupEvents(eventOptions); | |
} | |
/** | |
* @description Start the table and apply any stale/dormant WAL entries at once. | |
*/ | |
async start() { | |
await this.applyWALEntries(); | |
} | |
/** | |
* @description Setup change data capture to stream events with MikroEvent. | |
* @see https://github.com/mikaelvesavuori/mikroevent | |
*/ | |
setupEvents(config) { | |
config?.targets?.forEach((target) => { | |
this.mikroEvent.addTarget({ | |
name: target.name, | |
url: target.url, | |
headers: target.headers, | |
events: target.events | |
}); | |
}); | |
config?.listeners?.forEach((listener) => | |
this.mikroEvent.on(listener.event, listener.handler) | |
); | |
} | |
/** | |
* @description Switch the active table by loading it into memory if it's not already loaded. | |
* The table will be created if it does not exist. | |
*/ | |
async setActiveTable(tableName) { | |
if (this.activeTable === tableName) return; | |
if (!this.hasTable(tableName)) await this.loadTable(tableName); | |
await this.applyWALEntries(tableName); | |
await this.evictTablesIfNeeded(); | |
this.activeTable = tableName; | |
} | |
/** | |
* @description Apply WAL entries with optional table filtering. | |
*/ | |
async applyWALEntries(tableName) { | |
const operations = await this.wal.loadWAL(tableName); | |
if (operations.length === 0) return; | |
const tables = tableName | |
? [tableName] | |
: [...new Set(operations.map((op) => op.tableName))]; | |
for (const table of tables) { | |
const tableOps = operations.filter((op) => op.tableName === table); | |
if (tableName && !this.hasTable(table)) await this.loadTable(table); | |
else this.createTable(table); | |
for (const op of tableOps) { | |
if (op.operation === 'W' && op.data) | |
this.setItem(table, op.key, op.data); | |
else if (op.operation === 'D') await this.deleteItem(table, op.key); | |
} | |
} | |
} | |
/** | |
* @description Load the table from disk into memory using a binary format. | |
*/ | |
async loadTable(tableName) { | |
if (this.hasTable(tableName)) return; | |
const filePath = join(this.databaseDirectory, tableName); | |
if (!existsSync5(filePath)) { | |
this.createTable(tableName); | |
return; | |
} | |
const encryptedBuffer = await readFile3(filePath); | |
let fileBuffer = encryptedBuffer; | |
if ( | |
this.encryptionKey && | |
encryptedBuffer.length > 0 && | |
encryptedBuffer[0] === 1 | |
) { | |
try { | |
const encryptedData = this.encryption.deserialize(encryptedBuffer); | |
const key = this.encryption.generateKey(this.encryptionKey, 'salt'); | |
const decryptedString = this.encryption.decrypt(encryptedData, key); | |
fileBuffer = Buffer.from(decryptedString, 'binary'); | |
} catch (error) { | |
console.error(`Failed to decrypt ${tableName}:`, error); | |
} | |
} | |
const tableData = this.persistence.readTableFromBinaryBuffer(fileBuffer); | |
this.data.set(tableName, tableData); | |
if (this.data.size > this.cache.cacheLimit) | |
setImmediate(() => this.evictTablesIfNeeded()); | |
} | |
/** | |
* @description Get data with optional filtering and sorting. | |
*/ | |
async get(operation) { | |
const { tableName } = operation; | |
const key = operation.key; | |
const options = operation.options; | |
await this.setActiveTable(tableName); | |
if (!options) { | |
if (!key) return [...this.getAll(tableName)]; | |
return this.getItem(tableName, key)?.value; | |
} | |
const table = this.getTable(tableName); | |
let results = await this.query.query(table, options.filter, options.limit); | |
if (options.sort) results = results.sort(options.sort); | |
if (options.offset != null || options.limit != null) { | |
const start2 = options.offset || 0; | |
const end = options.limit ? start2 + options.limit : void 0; | |
results = results.slice(start2, end); | |
} | |
return results; | |
} | |
/** | |
* @description Perform a batch write operation, which allows multiple writes to different tables. | |
*/ | |
async write(operation, options = {}) { | |
const { concurrencyLimit = 10, flushImmediately = false } = options; | |
const operations = Array.isArray(operation) ? operation : [operation]; | |
const totalOperations = operations.length; | |
let processedOperations = 0; | |
while (processedOperations < totalOperations) { | |
const batch = operations.slice( | |
processedOperations, | |
processedOperations + concurrencyLimit | |
); | |
const promises = batch.map((operation2) => | |
this.writeItem(operation2, false) | |
); | |
const results = await Promise.all(promises); | |
if (results.includes(false)) return false; | |
processedOperations += batch.length; | |
if (this.writeBuffer.length >= this.maxWriteOpsBeforeFlush) | |
await this.flushWrites(); | |
} | |
if (flushImmediately || totalOperations >= 0) await this.flush(); | |
return true; | |
} | |
/** | |
* @description Write data to the active table with version control and expiration. | |
*/ | |
async writeItem(operation, flushImmediately = false) { | |
const { | |
tableName, | |
key, | |
value, | |
expectedVersion = null, | |
expiration = 0 | |
} = operation; | |
await this.setActiveTable(tableName); | |
const { success, newVersion } = this.getItemVersion( | |
tableName, | |
key, | |
expectedVersion | |
); | |
if (!success) return false; | |
await this.wal.appendToWAL( | |
tableName, | |
'W', | |
key, | |
value, | |
newVersion, | |
expiration | |
); | |
this.setItem(tableName, key, { | |
value, | |
v: newVersion, | |
t: time(), | |
x: expiration | |
}); | |
this.addToWriteBuffer(tableName, key); | |
if (flushImmediately) await this.flush(); | |
return true; | |
} | |
/** | |
* @description Delete a key from a table with version control and expiration. | |
*/ | |
async delete( | |
tableName, | |
key, | |
expectedVersion = null, | |
flushImmediately = false | |
) { | |
await this.setActiveTable(tableName); | |
if (!this.hasTable(tableName) || !this.hasKey(tableName, key)) { | |
console.log(`Key ${key} not found in table ${tableName}`); | |
return false; | |
} | |
const { success, currentVersion, expiration } = this.getItemVersion( | |
tableName, | |
key, | |
expectedVersion | |
); | |
if (!success) return false; | |
await this.wal.appendToWAL( | |
tableName, | |
'D', | |
key, | |
null, | |
currentVersion, | |
expiration | |
); | |
await this.deleteItem(tableName, key); | |
if (flushImmediately) await this.flush(); | |
return true; | |
} | |
/** | |
* @description Get a version of an item. | |
*/ | |
getItemVersion(tableName, key, expectedVersion) { | |
const currentRecord = this.getItem(tableName, key); | |
const currentVersion = currentRecord ? currentRecord.version : 0; | |
const newVersion = currentVersion + 1; | |
const expiration = currentRecord ? currentRecord.expiration || 0 : 0; | |
let success = true; | |
if (expectedVersion !== null && currentVersion !== expectedVersion) { | |
console.log( | |
`Version mismatch for ${tableName}:${key}. Expected ${expectedVersion}, found ${currentVersion}` | |
); | |
success = false; | |
} | |
return { success, currentRecord, currentVersion, newVersion, expiration }; | |
} | |
//////////////////////////////////// | |
// Public methods towards MikroDB // | |
//////////////////////////////////// | |
/** | |
* @description Create a table if it does not exist. | |
*/ | |
createTable(tableName) { | |
this.trackTableAccess(tableName); | |
if (!this.hasTable(tableName)) | |
this.data.set(tableName, /* @__PURE__ */ new Map()); | |
} | |
/** | |
* @description Get a table. | |
*/ | |
getTable(tableName) { | |
this.trackTableAccess(tableName); | |
if (!this.hasTable(tableName)) return /* @__PURE__ */ new Map(); | |
return this.data.get(tableName); | |
} | |
/** | |
* @description Get the size of a table. | |
*/ | |
async getTableSize(tableName) { | |
await this.setActiveTable(tableName); | |
this.trackTableAccess(tableName); | |
return this.data.get(tableName)?.size; | |
} | |
/** | |
* @description Delete a table. | |
*/ | |
async deleteTable(tableName) { | |
this.trackTableAccess(tableName); | |
this.data.delete(tableName); | |
const operation = 'table.deleted'; | |
const { success, errors } = await this.mikroEvent.emit(operation, { | |
operation, | |
table: tableName | |
}); | |
if (!success) console.error('Error when emitting events:', errors); | |
} | |
/** | |
* @description Check if a table exists. | |
*/ | |
hasTable(tableName) { | |
this.trackTableAccess(tableName); | |
return this.data.has(tableName); | |
} | |
/** | |
* @description Check if a table has a given key. | |
*/ | |
hasKey(tableName, key) { | |
this.trackTableAccess(tableName); | |
return this.data.get(tableName)?.has(key); | |
} | |
/** | |
* @description Get an item. | |
*/ | |
getItem(tableName, key) { | |
this.trackTableAccess(tableName); | |
const item = this.data.get(tableName)?.get(key); | |
if (!item) return; | |
if (item?.x !== 0 && Date.now() > item?.x) { | |
this.deleteItem(tableName, key); | |
return; | |
} | |
return { | |
value: item.value, | |
version: item.v, | |
timestamp: item.t, | |
expiration: item.x | |
}; | |
} | |
/** | |
* @description Get all items from a table. | |
*/ | |
getAll(tableName) { | |
this.trackTableAccess(tableName); | |
const data = this.data.get(tableName); | |
if (!data) return []; | |
return Array.from(data); | |
} | |
/** | |
* @description Set an item in a table. | |
*/ | |
setItem(tableName, key, item) { | |
this.trackTableAccess(tableName); | |
this.createTable(tableName); | |
this.data.get(tableName)?.set(key, item); | |
} | |
/** | |
* @description Delete an item from a table. | |
*/ | |
async deleteItem(tableName, key) { | |
this.data.get(tableName)?.delete(key); | |
const operation = 'item.deleted'; | |
const { success, errors } = await this.mikroEvent.emit(operation, { | |
operation, | |
table: tableName, | |
key | |
}); | |
if (!success) console.error('Error when emitting events:', errors); | |
} | |
/** | |
* @description Add item to write buffer. | |
*/ | |
addToWriteBuffer(tableName, key) { | |
const record = this.getItem(tableName, key); | |
this.writeBuffer.push(JSON.stringify({ tableName, key, record })); | |
} | |
/** | |
* @description Update in methods that access tables. | |
*/ | |
trackTableAccess(tableName) { | |
this.cache.trackTableAccess(tableName); | |
} | |
/** | |
* @description Flush the WAL and any writes. | |
*/ | |
async flush() { | |
await this.flushWAL(); | |
await this.flushWrites(); | |
} | |
/** | |
* @description Flush only the WAL. | |
*/ | |
async flushWAL() { | |
await this.wal.flushWAL(); | |
} | |
/** | |
* @description Flush buffered writes to their respective table files using a binary format | |
*/ | |
async flushWrites() { | |
if (this.writeBuffer.length === 0) return; | |
try { | |
const tableOperations = /* @__PURE__ */ new Map(); | |
const bufferSnapshot = [...this.writeBuffer]; | |
for (const entry of bufferSnapshot) { | |
const operation = JSON.parse(entry); | |
if (!tableOperations.has(operation.tableName)) | |
tableOperations.set(operation.tableName, /* @__PURE__ */ new Map()); | |
const tableData = tableOperations.get(operation.tableName); | |
tableData.set(operation.key, operation.record); | |
const operationName = 'item.written'; | |
const { success, errors } = await this.mikroEvent.emit(operationName, { | |
operation: operationName, | |
table: operation.tableName, | |
key: operation.key, | |
record: operation.record | |
}); | |
if (!success) console.error('Error when emitting events:', errors); | |
} | |
const writePromises = Array.from(tableOperations.entries()).map( | |
async ([tableName]) => { | |
const fullTableData = this.getTable(tableName); | |
const tablePath = join(this.databaseDirectory, tableName); | |
await writeToDisk(tablePath, fullTableData, this.encryptionKey); | |
} | |
); | |
await Promise.all(writePromises); | |
this.writeBuffer = this.writeBuffer.slice(bufferSnapshot.length); | |
} catch (error) { | |
console.error(`Failed to flush writes: ${error.message}`); | |
} | |
} | |
/** | |
* @description Write (flush) a table to disk. | |
*/ | |
async flushTableToDisk(tableName) { | |
await this.setActiveTable(tableName); | |
const tableData = this.getTable(tableName); | |
if (tableData.size === 0) return; | |
for (const [key, _] of tableData.entries()) | |
this.addToWriteBuffer(tableName, key); | |
await this.flushWrites(); | |
} | |
/** | |
* @description Evict any tables that are flagged for cleaning. | |
*/ | |
async evictTablesIfNeeded() { | |
const tablesToEvict = this.cache.findTablesForEviction(this.data.size); | |
for (const tableName of tablesToEvict) { | |
await this.flushTableToDisk(tableName); | |
this.data.delete(tableName); | |
} | |
} | |
/** | |
* @description Remove expired items from all tables. | |
*/ | |
async cleanupExpiredItems() { | |
for (const [tableName, tableData] of this.data.entries()) { | |
const expiredItems = this.cache.findExpiredItems(tableData); | |
for (const [key, item] of expiredItems) { | |
await this.wal.appendToWAL(tableName, 'D', key, null, item.v, item.x); | |
tableData.delete(key); | |
const operation = 'item.expired'; | |
const { success, errors } = await this.mikroEvent.emit(operation, { | |
operation, | |
table: tableName, | |
key, | |
record: item | |
}); | |
if (!success) console.error('Error when emitting events:', errors); | |
} | |
} | |
} | |
/** | |
* @description Dump (write) one or more tables to disk in JSON format. | |
*/ | |
async dump(tableName) { | |
if (tableName) await this.setActiveTable(tableName); | |
const table = this.getAll(this.activeTable); | |
await writeFile3( | |
`${this.databaseDirectory}/${this.activeTable}_dump.json`, | |
JSON.stringify(table), | |
'utf8' | |
); | |
} | |
/** | |
* @description Returns the WAL instance. | |
*/ | |
getWAL() { | |
return this.wal; | |
} | |
/** | |
* @description Returns the Persistence instance. | |
*/ | |
getPersistence() { | |
return this.persistence; | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-2IZXMK3O.mjs | |
import { join as join2 } from 'node:path'; | |
var MikroDB = class { | |
table; | |
checkpoint; | |
constructor(options) { | |
const defaults3 = configDefaults(); | |
const databaseDirectory = | |
options?.databaseDirectory || defaults3.db.databaseDirectory; | |
const walFileName = options?.walFileName || defaults3.db.walFileName; | |
const walInterval = options?.walInterval || defaults3.db.walInterval; | |
const encryptionKey = options?.encryptionKey || defaults3.db.encryptionKey; | |
const maxWriteOpsBeforeFlush = | |
options?.maxWriteOpsBeforeFlush || defaults3.db.maxWriteOpsBeforeFlush; | |
const events = options?.events || {}; | |
if (options?.debug) process.env.DEBUG = 'true'; | |
if (options?.maxWriteOpsBeforeFlush) | |
process.env.MAX_WRITE_OPS_BEFORE_FLUSH = | |
maxWriteOpsBeforeFlush.toString(); | |
this.table = new Table( | |
{ | |
databaseDirectory, | |
walFileName, | |
walInterval, | |
encryptionKey | |
}, | |
events | |
); | |
const wal = this.table.getWAL(); | |
this.table | |
.getWAL() | |
.setCheckpointCallback(() => this.checkpoint.checkpoint(true)); | |
this.checkpoint = new Checkpoint({ | |
table: this.table, | |
wal, | |
walFile: join2(databaseDirectory, walFileName), | |
checkpointIntervalMs: walInterval | |
}); | |
this.checkpoint | |
.start() | |
.catch((error) => | |
console.error('Failed to start checkpoint service:', error) | |
); | |
} | |
/** | |
* @description Setup and start internal processes. | |
*/ | |
async start() { | |
await this.table.start(); | |
await this.checkpoint.start(); | |
} | |
/** | |
* @description Get an item from the database. | |
* @example | |
* // Get everything in table | |
* await db.get({ tableName: 'my-table' }); | |
* | |
* // Get a specific key in the table | |
* await db.get({ tableName: 'my-table', key: 'my-item' }); | |
* | |
* // You can use several types of filters | |
* await db.get({ | |
* tableName: 'my-table', | |
* options: { | |
* filter: { | |
* age: | |
* { | |
* operator: 'gt', | |
* value: 21 | |
* } | |
* } | |
* } | |
* }); | |
*/ | |
async get(operation) { | |
return await this.table.get(operation); | |
} | |
/** | |
* @description Get the size of a table, if it exists. | |
*/ | |
async getTableSize(tableName) { | |
return await this.table.getTableSize(tableName); | |
} | |
/** | |
* @description Write one or more items to the database. | |
* @example | |
* await db.write({ | |
* tableName, | |
* key, | |
* value: { name: 'John Doe', age: 30 }, | |
* expectedVersion: 3 // Optional | |
* }); | |
*/ | |
async write(operation, options) { | |
return await this.table.write(operation, options); | |
} | |
/** | |
* @description Delete an item from the database. | |
* @example | |
* await db.delete({ tableName: 'users', key: 'john.doe' }); | |
* await db.delete({ tableName: 'users', key: 'john.doe', expectedVersion: 4 }); // Remove version 4 of this key | |
*/ | |
async delete(operation) { | |
const { tableName, key } = operation; | |
const expectedVersion = operation?.expectedVersion || null; | |
return await this.table.delete(tableName, key, expectedVersion); | |
} | |
/** | |
* @description Deletes a table. | |
*/ | |
async deleteTable(tableName) { | |
return await this.table.deleteTable(tableName); | |
} | |
/** | |
* @description Alias for `flush()`. | |
*/ | |
async close() { | |
if (this.checkpoint) this.checkpoint.stop(); | |
if (this.table?.getWAL()) this.table.getWAL().stop(); | |
try { | |
await this.flush(); | |
} catch (error) { | |
console.error('Error flushing during close:', error); | |
} | |
} | |
/** | |
* @description Flushes all pending operations to disk. | |
* This ensures all Write Ahead Log (WAL) entries and writes are persisted. | |
*/ | |
async flush() { | |
await this.table.flush(); | |
} | |
/** | |
* @description Flush only the Write Ahead Log (WAL). | |
*/ | |
async flushWAL() { | |
await this.table.flushWAL(); | |
} | |
/** | |
* @description Dump a single—or if no table name is provided, all—tables to JSON file(s) on disk. | |
*/ | |
async dump(tableName) { | |
await this.table.dump(tableName); | |
} | |
/** | |
* @description Manually start a cleanup task to remove expired items. | |
*/ | |
async cleanupExpiredItems() { | |
await this.table.cleanupExpiredItems(); | |
} | |
}; | |
// node_modules/mikroserve/lib/chunk-ZFBBESGU.mjs | |
var RateLimiter = class { | |
requests = /* @__PURE__ */ new Map(); | |
limit; | |
windowMs; | |
constructor(limit = 100, windowSeconds = 60) { | |
this.limit = limit; | |
this.windowMs = windowSeconds * 1e3; | |
setInterval(() => this.cleanup(), this.windowMs); | |
} | |
getLimit() { | |
return this.limit; | |
} | |
isAllowed(ip) { | |
const now = Date.now(); | |
const key = ip || 'unknown'; | |
let entry = this.requests.get(key); | |
if (!entry || entry.resetTime < now) { | |
entry = { count: 0, resetTime: now + this.windowMs }; | |
this.requests.set(key, entry); | |
} | |
entry.count++; | |
return entry.count <= this.limit; | |
} | |
getRemainingRequests(ip) { | |
const now = Date.now(); | |
const key = ip || 'unknown'; | |
const entry = this.requests.get(key); | |
if (!entry || entry.resetTime < now) return this.limit; | |
return Math.max(0, this.limit - entry.count); | |
} | |
getResetTime(ip) { | |
const now = Date.now(); | |
const key = ip || 'unknown'; | |
const entry = this.requests.get(key); | |
if (!entry || entry.resetTime < now) | |
return Math.floor((now + this.windowMs) / 1e3); | |
return Math.floor(entry.resetTime / 1e3); | |
} | |
cleanup() { | |
const now = Date.now(); | |
for (const [key, entry] of this.requests.entries()) { | |
if (entry.resetTime < now) this.requests.delete(key); | |
} | |
} | |
}; | |
// node_modules/mikroserve/lib/chunk-GUYBTPZH.mjs | |
import { URL as URL2 } from 'node:url'; | |
var Router = class { | |
routes = []; | |
globalMiddlewares = []; | |
pathPatterns = /* @__PURE__ */ new Map(); | |
/** | |
* Add a global middleware | |
*/ | |
use(middleware) { | |
this.globalMiddlewares.push(middleware); | |
return this; | |
} | |
/** | |
* Register a route with specified method | |
*/ | |
register(method, path, handler, middlewares = []) { | |
this.routes.push({ method, path, handler, middlewares }); | |
this.pathPatterns.set(path, this.createPathPattern(path)); | |
return this; | |
} | |
/** | |
* Register a GET route | |
*/ | |
get(path, ...handlers) { | |
const handler = handlers.pop(); | |
return this.register('GET', path, handler, handlers); | |
} | |
/** | |
* Register a POST route | |
*/ | |
post(path, ...handlers) { | |
const handler = handlers.pop(); | |
return this.register('POST', path, handler, handlers); | |
} | |
/** | |
* Register a PUT route | |
*/ | |
put(path, ...handlers) { | |
const handler = handlers.pop(); | |
return this.register('PUT', path, handler, handlers); | |
} | |
/** | |
* Register a DELETE route | |
*/ | |
delete(path, ...handlers) { | |
const handler = handlers.pop(); | |
return this.register('DELETE', path, handler, handlers); | |
} | |
/** | |
* Register a PATCH route | |
*/ | |
patch(path, ...handlers) { | |
const handler = handlers.pop(); | |
return this.register('PATCH', path, handler, handlers); | |
} | |
/** | |
* Register an OPTIONS route | |
*/ | |
options(path, ...handlers) { | |
const handler = handlers.pop(); | |
return this.register('OPTIONS', path, handler, handlers); | |
} | |
/** | |
* Match a request to a route | |
*/ | |
match(method, path) { | |
for (const route of this.routes) { | |
if (route.method !== method) continue; | |
const pathPattern = this.pathPatterns.get(route.path); | |
if (!pathPattern) continue; | |
const match = pathPattern.pattern.exec(path); | |
if (!match) continue; | |
const params = {}; | |
pathPattern.paramNames.forEach((name, index) => { | |
params[name] = match[index + 1] || ''; | |
}); | |
return { route, params }; | |
} | |
return null; | |
} | |
/** | |
* Create a regex pattern for path matching | |
*/ | |
createPathPattern(path) { | |
const paramNames = []; | |
const pattern = path | |
.replace(/\/:[^/]+/g, (match) => { | |
const paramName = match.slice(2); | |
paramNames.push(paramName); | |
return '/([^/]+)'; | |
}) | |
.replace(/\/$/, '/?'); | |
return { | |
pattern: new RegExp(`^${pattern}$`), | |
paramNames | |
}; | |
} | |
/** | |
* Handle a request and find the matching route | |
*/ | |
async handle(req, res) { | |
const method = req.method || 'GET'; | |
const url = new URL2(req.url || '/', `http://${req.headers.host}`); | |
const path = url.pathname; | |
const matched = this.match(method, path); | |
if (!matched) return null; | |
const { route, params } = matched; | |
const query = {}; | |
url.searchParams.forEach((value, key) => { | |
query[key] = value; | |
}); | |
const context = { | |
req, | |
res, | |
params, | |
query, | |
// @ts-ignore | |
body: req.body || {}, | |
headers: req.headers, | |
path, | |
state: {}, | |
// Add the missing state property | |
raw: () => res, | |
binary: ( | |
content, | |
contentType = 'application/octet-stream', | |
status = 200 | |
) => ({ | |
statusCode: status, | |
body: content, | |
headers: { | |
'Content-Type': contentType, | |
'Content-Length': content.length.toString() | |
}, | |
isRaw: true | |
}), | |
text: (content, status = 200) => ({ | |
statusCode: status, | |
body: content, | |
headers: { 'Content-Type': 'text/plain' } | |
}), | |
form: (content, status = 200) => ({ | |
statusCode: status, | |
body: content, | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } | |
}), | |
json: (content, status = 200) => ({ | |
statusCode: status, | |
body: content, | |
headers: { 'Content-Type': 'application/json' } | |
}), | |
html: (content, status = 200) => ({ | |
statusCode: status, | |
body: content, | |
headers: { 'Content-Type': 'text/html' } | |
}), | |
redirect: (url2, status = 302) => ({ | |
statusCode: status, | |
body: null, | |
headers: { Location: url2 } | |
}), | |
status: function (code) { | |
return { | |
raw: () => res, | |
binary: (content, contentType = 'application/octet-stream') => ({ | |
statusCode: code, | |
body: content, | |
headers: { | |
'Content-Type': contentType, | |
'Content-Length': content.length.toString() | |
}, | |
isRaw: true | |
}), | |
text: (content) => ({ | |
statusCode: code, | |
body: content, | |
headers: { 'Content-Type': 'text/plain' } | |
}), | |
json: (data) => ({ | |
statusCode: code, | |
body: data, | |
headers: { 'Content-Type': 'application/json' } | |
}), | |
html: (content) => ({ | |
statusCode: code, | |
body: content, | |
headers: { 'Content-Type': 'text/html' } | |
}), | |
form: (content) => ({ | |
// Make sure form method is included here | |
statusCode: code, | |
body: content, | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } | |
}), | |
redirect: (url2, redirectStatus = 302) => ({ | |
statusCode: redirectStatus, | |
body: null, | |
headers: { Location: url2 } | |
}), | |
status: (updatedCode) => this.status(updatedCode) | |
}; | |
} | |
}; | |
const middlewares = [...this.globalMiddlewares, ...route.middlewares]; | |
return this.executeMiddlewareChain(context, middlewares, route.handler); | |
} | |
/** | |
* Execute middleware chain and final handler | |
*/ | |
async executeMiddlewareChain(context, middlewares, finalHandler) { | |
let currentIndex = 0; | |
const next = async () => { | |
if (currentIndex < middlewares.length) { | |
const middleware = middlewares[currentIndex++]; | |
return middleware(context, next); | |
} | |
return finalHandler(context); | |
}; | |
return next(); | |
} | |
}; | |
// node_modules/mikroserve/lib/chunk-JJX5XRNB.mjs | |
var configDefaults2 = () => { | |
return { | |
port: Number(process.env.PORT) || 3e3, | |
host: process.env.HOST || '0.0.0.0', | |
useHttps: false, | |
useHttp2: false, | |
sslCert: '', | |
sslKey: '', | |
sslCa: '', | |
debug: getTruthyValue2(process.env.DEBUG) || false, | |
rateLimit: { | |
enabled: true, | |
requestsPerMinute: 100 | |
}, | |
allowedDomains: ['*'] | |
}; | |
}; | |
function getTruthyValue2(value) { | |
if (value === 'true' || value === true) return true; | |
return false; | |
} | |
// node_modules/mikroserve/lib/chunk-YOHL3T54.mjs | |
var defaults = configDefaults2(); | |
var baseConfig = (options) => ({ | |
configFilePath: 'mikroserve.config.json', | |
args: process.argv, | |
options: [ | |
{ flag: '--port', path: 'port', defaultValue: defaults.port }, | |
{ flag: '--host', path: 'host', defaultValue: defaults.host }, | |
{ | |
flag: '--https', | |
path: 'useHttps', | |
defaultValue: defaults.useHttps, | |
isFlag: true | |
}, | |
{ | |
flag: '--http2', | |
path: 'useHttp2', | |
defaultValue: defaults.useHttp2, | |
isFlag: true | |
}, | |
{ flag: '--cert', path: 'sslCert', defaultValue: defaults.sslCert }, | |
{ flag: '--key', path: 'sslKey', defaultValue: defaults.sslKey }, | |
{ flag: '--ca', path: 'sslCa', defaultValue: defaults.sslCa }, | |
{ | |
flag: '--ratelimit', | |
path: 'rateLimit.enabled', | |
defaultValue: defaults.rateLimit.enabled, | |
isFlag: true | |
}, | |
{ | |
flag: '--rps', | |
path: 'rateLimit.requestsPerMinute', | |
defaultValue: defaults.rateLimit.requestsPerMinute | |
}, | |
{ | |
flag: '--allowed', | |
path: 'allowedDomains', | |
defaultValue: defaults.allowedDomains, | |
parser: parsers.array | |
}, | |
{ | |
flag: '--debug', | |
path: 'debug', | |
defaultValue: defaults.debug, | |
isFlag: true | |
} | |
], | |
config: options | |
}); | |
// node_modules/mikroserve/lib/chunk-TQN6BEGA.mjs | |
import { readFileSync as readFileSync3 } from 'node:fs'; | |
import http from 'node:http'; | |
import http2 from 'node:http2'; | |
import https from 'node:https'; | |
var MikroServe = class { | |
config; | |
rateLimiter; | |
router; | |
/** | |
* @description Creates a new MikroServe instance. | |
*/ | |
constructor(options) { | |
const config = new MikroConf(baseConfig(options || {})).get(); | |
if (config.debug) console.log('Using configuration:', config); | |
this.config = config; | |
this.router = new Router(); | |
const requestsPerMinute = | |
config.rateLimit.requestsPerMinute || | |
configDefaults2().rateLimit.requestsPerMinute; | |
this.rateLimiter = new RateLimiter(requestsPerMinute, 60); | |
if (config.rateLimit.enabled === true) | |
this.use(this.rateLimitMiddleware.bind(this)); | |
} | |
/** | |
* @description Register a global middleware. | |
*/ | |
use(middleware) { | |
this.router.use(middleware); | |
return this; | |
} | |
/** | |
* @description Register a GET route. | |
*/ | |
get(path, ...handlers) { | |
this.router.get(path, ...handlers); | |
return this; | |
} | |
/** | |
* @description Register a POST route. | |
*/ | |
post(path, ...handlers) { | |
this.router.post(path, ...handlers); | |
return this; | |
} | |
/** | |
* @description Register a PUT route. | |
*/ | |
put(path, ...handlers) { | |
this.router.put(path, ...handlers); | |
return this; | |
} | |
/** | |
* @description Register a DELETE route. | |
*/ | |
delete(path, ...handlers) { | |
this.router.delete(path, ...handlers); | |
return this; | |
} | |
/** | |
* @description Register a PATCH route. | |
*/ | |
patch(path, ...handlers) { | |
this.router.patch(path, ...handlers); | |
return this; | |
} | |
/** | |
* @description Register an OPTIONS route. | |
*/ | |
options(path, ...handlers) { | |
this.router.options(path, ...handlers); | |
return this; | |
} | |
/** | |
* @description Creates an HTTP/HTTPS server, sets up graceful shutdown, and starts listening. | |
*/ | |
start() { | |
const server = this.createServer(); | |
const { port, host } = this.config; | |
this.setupGracefulShutdown(server); | |
server.listen(port, host, () => { | |
const address = server.address(); | |
const protocol = | |
this.config.useHttps || this.config.useHttp2 ? 'https' : 'http'; | |
console.log( | |
`MikroServe running at ${protocol}://${address.address !== '::' ? address.address : 'localhost'}:${address.port}` | |
); | |
}); | |
return server; | |
} | |
/** | |
* @description Creates and configures a server instance without starting it. | |
*/ | |
createServer() { | |
const boundRequestHandler = this.requestHandler.bind(this); | |
if (this.config.useHttp2) { | |
if (!this.config.sslCert || !this.config.sslKey) | |
throw new Error( | |
'SSL certificate and key paths are required when useHttp2 is true' | |
); | |
try { | |
const httpsOptions = { | |
key: readFileSync3(this.config.sslKey), | |
cert: readFileSync3(this.config.sslCert), | |
...(this.config.sslCa ? { ca: readFileSync3(this.config.sslCa) } : {}) | |
}; | |
return http2.createSecureServer(httpsOptions, boundRequestHandler); | |
} catch (error) { | |
if (error.message.includes('key values mismatch')) | |
throw new Error( | |
`SSL certificate and key do not match: ${error.message}` | |
); | |
throw error; | |
} | |
} else if (this.config.useHttps) { | |
if (!this.config.sslCert || !this.config.sslKey) | |
throw new Error( | |
'SSL certificate and key paths are required when useHttps is true' | |
); | |
try { | |
const httpsOptions = { | |
key: readFileSync3(this.config.sslKey), | |
cert: readFileSync3(this.config.sslCert), | |
...(this.config.sslCa ? { ca: readFileSync3(this.config.sslCa) } : {}) | |
}; | |
return https.createServer(httpsOptions, boundRequestHandler); | |
} catch (error) { | |
if (error.message.includes('key values mismatch')) | |
throw new Error( | |
`SSL certificate and key do not match: ${error.message}` | |
); | |
throw error; | |
} | |
} | |
return http.createServer(boundRequestHandler); | |
} | |
/** | |
* @description Rate limiting middleware. | |
*/ | |
async rateLimitMiddleware(context, next) { | |
const ip = context.req.socket.remoteAddress || 'unknown'; | |
context.res.setHeader( | |
'X-RateLimit-Limit', | |
this.rateLimiter.getLimit().toString() | |
); | |
context.res.setHeader( | |
'X-RateLimit-Remaining', | |
this.rateLimiter.getRemainingRequests(ip).toString() | |
); | |
context.res.setHeader( | |
'X-RateLimit-Reset', | |
this.rateLimiter.getResetTime(ip).toString() | |
); | |
if (!this.rateLimiter.isAllowed(ip)) { | |
return { | |
statusCode: 429, | |
body: { | |
error: 'Too Many Requests', | |
message: 'Rate limit exceeded, please try again later' | |
}, | |
headers: { 'Content-Type': 'application/json' } | |
}; | |
} | |
return next(); | |
} | |
/** | |
* @description Request handler for HTTP and HTTPS servers. | |
*/ | |
async requestHandler(req, res) { | |
const start2 = Date.now(); | |
const method = req.method || 'UNKNOWN'; | |
const url = req.url || '/unknown'; | |
const isDebug = this.config.debug; | |
try { | |
this.setCorsHeaders(res, req); | |
this.setSecurityHeaders(res, this.config.useHttps); | |
if (isDebug) console.log(`${method} ${url}`); | |
if (req.method === 'OPTIONS') { | |
if (res instanceof http.ServerResponse) { | |
res.statusCode = 204; | |
res.end(); | |
} else { | |
const h2Res = res; | |
h2Res.writeHead(204); | |
h2Res.end(); | |
} | |
return; | |
} | |
try { | |
req.body = await this.parseBody(req); | |
} catch (error) { | |
if (isDebug) console.error('Body parsing error:', error.message); | |
return this.respond(res, { | |
statusCode: 400, | |
body: { | |
error: 'Bad Request', | |
message: error.message | |
} | |
}); | |
} | |
const result = await this.router.handle(req, res); | |
if (result) { | |
if (result._handled) return; | |
return this.respond(res, result); | |
} | |
return this.respond(res, { | |
statusCode: 404, | |
body: { | |
error: 'Not Found', | |
message: 'The requested endpoint does not exist' | |
} | |
}); | |
} catch (error) { | |
console.error('Server error:', error); | |
return this.respond(res, { | |
statusCode: 500, | |
body: { | |
error: 'Internal Server Error', | |
message: isDebug ? error.message : 'An unexpected error occurred' | |
} | |
}); | |
} finally { | |
if (isDebug) this.logDuration(start2, method, url); | |
} | |
} | |
/** | |
* @description Writes out a clean log to represent the duration of the request. | |
*/ | |
logDuration(start2, method, url) { | |
const duration = Date.now() - start2; | |
console.log(`${method} ${url} completed in ${duration}ms`); | |
} | |
/** | |
* @description Parses the request body based on content type. | |
*/ | |
async parseBody(req) { | |
return new Promise((resolve, reject) => { | |
const bodyChunks = []; | |
let bodySize = 0; | |
const MAX_BODY_SIZE = 1024 * 1024; | |
let rejected = false; | |
const isDebug = this.config.debug; | |
const contentType = req.headers['content-type'] || ''; | |
if (isDebug) { | |
console.log('Content-Type:', contentType); | |
} | |
req.on('data', (chunk) => { | |
bodySize += chunk.length; | |
if (isDebug) | |
console.log( | |
`Received chunk: ${chunk.length} bytes, total size: ${bodySize}` | |
); | |
if (bodySize > MAX_BODY_SIZE && !rejected) { | |
rejected = true; | |
if (isDebug) | |
console.log( | |
`Body size exceeded limit: ${bodySize} > ${MAX_BODY_SIZE}` | |
); | |
reject(new Error('Request body too large')); | |
return; | |
} | |
if (!rejected) bodyChunks.push(chunk); | |
}); | |
req.on('end', () => { | |
if (rejected) return; | |
if (isDebug) console.log(`Request body complete: ${bodySize} bytes`); | |
try { | |
if (bodyChunks.length > 0) { | |
const bodyString = Buffer.concat(bodyChunks).toString('utf8'); | |
if (contentType.includes('application/json')) { | |
try { | |
resolve(JSON.parse(bodyString)); | |
} catch (error) { | |
reject( | |
new Error(`Invalid JSON in request body: ${error.message}`) | |
); | |
} | |
} else if ( | |
contentType.includes('application/x-www-form-urlencoded') | |
) { | |
const formData = {}; | |
new URLSearchParams(bodyString).forEach((value, key) => { | |
formData[key] = value; | |
}); | |
resolve(formData); | |
} else { | |
resolve(bodyString); | |
} | |
} else { | |
resolve({}); | |
} | |
} catch (error) { | |
reject(new Error(`Invalid request body: ${error}`)); | |
} | |
}); | |
req.on('error', (error) => { | |
if (!rejected) | |
reject(new Error(`Error reading request body: ${error.message}`)); | |
}); | |
}); | |
} | |
/** | |
* @description CORS middleware. | |
*/ | |
setCorsHeaders(res, req) { | |
const origin = req.headers.origin; | |
const { allowedDomains = ['*'] } = this.config; | |
if (!origin || allowedDomains.length === 0) | |
res.setHeader('Access-Control-Allow-Origin', '*'); | |
else if (allowedDomains.includes('*')) | |
res.setHeader('Access-Control-Allow-Origin', '*'); | |
else if (allowedDomains.includes(origin)) { | |
res.setHeader('Access-Control-Allow-Origin', origin); | |
res.setHeader('Vary', 'Origin'); | |
} | |
res.setHeader( | |
'Access-Control-Allow-Methods', | |
'GET, POST, PUT, DELETE, PATCH, OPTIONS' | |
); | |
res.setHeader( | |
'Access-Control-Allow-Headers', | |
'Content-Type, Authorization' | |
); | |
res.setHeader('Access-Control-Max-Age', '86400'); | |
} | |
/** | |
* @description Set security headers. | |
*/ | |
setSecurityHeaders(res, isHttps = false) { | |
const securityHeaders = { | |
'X-Content-Type-Options': 'nosniff', | |
'X-Frame-Options': 'DENY', | |
'Content-Security-Policy': | |
"default-src 'self'; script-src 'self'; object-src 'none'", | |
'X-XSS-Protection': '1; mode=block' | |
}; | |
if (isHttps || this.config.useHttp2) | |
securityHeaders['Strict-Transport-Security'] = | |
'max-age=31536000; includeSubDomains'; | |
if (res instanceof http.ServerResponse) { | |
Object.entries(securityHeaders).forEach(([name, value]) => { | |
res.setHeader(name, value); | |
}); | |
} else { | |
const h2Res = res; | |
Object.entries(securityHeaders).forEach(([name, value]) => { | |
h2Res.setHeader(name, value); | |
}); | |
} | |
} | |
/** | |
* @description Sends a response with appropriate headers. | |
*/ | |
respond(res, response) { | |
const headers = { | |
...(response.headers || {}) | |
}; | |
const hasWriteHead = (res2) => { | |
return ( | |
typeof res2.writeHead === 'function' && typeof res2.end === 'function' | |
); | |
}; | |
if (hasWriteHead(res)) { | |
res.writeHead(response.statusCode, headers); | |
if (response.body === null || response.body === void 0) res.end(); | |
else if (response.isRaw) res.end(response.body); | |
else if (typeof response.body === 'string') res.end(response.body); | |
else res.end(JSON.stringify(response.body)); | |
} else { | |
console.warn( | |
'Unexpected response object type without writeHead/end methods' | |
); | |
res.writeHead?.(response.statusCode, headers); | |
if (response.body === null || response.body === void 0) res.end?.(); | |
else if (response.isRaw) res.end?.(response.body); | |
else if (typeof response.body === 'string') res.end?.(response.body); | |
else res.end?.(JSON.stringify(response.body)); | |
} | |
} | |
/** | |
* @description Sets up graceful shutdown handlers for a server. | |
*/ | |
setupGracefulShutdown(server) { | |
const shutdown = (error) => { | |
console.log('Shutting down MikroServe server...'); | |
if (error) console.error('Error:', error); | |
server.close(() => { | |
console.log('Server closed successfully'); | |
setImmediate(() => process.exit(error ? 1 : 0)); | |
}); | |
}; | |
process.on('SIGINT', () => shutdown()); | |
process.on('SIGTERM', () => shutdown()); | |
process.on('uncaughtException', shutdown); | |
process.on('unhandledRejection', shutdown); | |
} | |
}; | |
// node_modules/mikrodb/lib/chunk-Q2EVQW6J.mjs | |
async function startServer(config) { | |
const db = new MikroDB({ ...config.db, events: config?.events }); | |
await db.start(); | |
const server = new MikroServe(config.server); | |
server.get('/table', async (c) => { | |
const body = c.req.body; | |
if (!body.tableName) | |
return c.json({ statusCode: 400, body: 'tableName is required' }); | |
const result = await db.getTableSize(body.tableName); | |
if (!result) c.json({ statusCode: 404, body: null }); | |
return c.json({ statusCode: 200, body: result }); | |
}); | |
server.post('/get', async (c) => { | |
const body = c.req.body; | |
if (!body.tableName) | |
return c.json({ statusCode: 400, body: 'tableName is required' }); | |
const operation = { | |
tableName: body.tableName, | |
key: body.key | |
}; | |
const options = createGetOptions(body?.options); | |
if (options) operation.options = options; | |
const result = await db.get(operation); | |
if (!result) c.json({ statusCode: 404, body: null }); | |
return c.json({ statusCode: 200, body: result }); | |
}); | |
server.post('/write', async (c) => { | |
const body = c.req.body; | |
if (!body.tableName || body.value === void 0) | |
return c.json({ | |
statusCode: 400, | |
body: 'tableName and value are required' | |
}); | |
const operation = { | |
tableName: body.tableName, | |
key: body.key, | |
value: body.value, | |
expectedVersion: body.expectedVersion, | |
expiration: body.expiration | |
}; | |
const options = { | |
concurrencyLimit: body.concurrencyLimit, | |
flushImmediately: body.flushImmediately | |
}; | |
const result = await db.write(operation, options); | |
return c.json({ statusCode: 200, body: result }); | |
}); | |
server.delete('/delete', async (c) => { | |
const query = c.params; | |
if (!query.tableName || !query.key) | |
return c.json({ | |
statusCode: 400, | |
body: 'tableName and key are required' | |
}); | |
const operation = { | |
tableName: query.tableName, | |
key: query.key | |
}; | |
const result = await db.delete(operation); | |
return c.json({ statusCode: 200, body: result }); | |
}); | |
server.start(); | |
return server; | |
} | |
// node_modules/mikrodb/lib/index.mjs | |
async function main() { | |
const args = process.argv; | |
const isRunFromCommandLine = args[1]?.includes('node_modules/.bin/mikrodb'); | |
const force = (process.argv[2] || '') === '--force'; | |
if (isRunFromCommandLine || force) { | |
console.log('\u{1F5C2}\uFE0F Welcome to MikroDB! \u2728'); | |
try { | |
const defaults3 = configDefaults(); | |
const config = new MikroConf({ | |
configFilePath: 'mikrodb.config.json', | |
args: process.argv, | |
options: [ | |
// DB settings | |
{ | |
flag: '--db', | |
path: 'db.dbName', | |
defaultValue: defaults3.db.dbName | |
}, | |
{ | |
flag: '--dir', | |
path: 'db.databaseDirectory', | |
defaultValue: defaults3.db.databaseDirectory | |
}, | |
{ | |
flag: '--wal', | |
path: 'db.walFileName', | |
defaultValue: defaults3.db.walFileName | |
}, | |
{ | |
flag: '--interval', | |
path: 'db.walInterval', | |
defaultValue: defaults3.db.walInterval | |
}, | |
{ | |
flag: '--encryptionKey', | |
path: 'db.encryptionKey', | |
defaultValue: defaults3.db.encryptionKey | |
}, | |
{ | |
flag: '--maxWrites', | |
path: 'db.maxWriteOpsBeforeFlush', | |
defaultValue: defaults3.db.maxWriteOpsBeforeFlush | |
}, | |
{ | |
flag: '--debug', | |
path: 'db.debug', | |
isFlag: true, | |
defaultValue: defaults3.db.debug | |
}, | |
// Event settings | |
{ | |
path: 'events', | |
defaultValue: defaults3.events | |
}, | |
// Server settings | |
{ | |
flag: '--port', | |
path: 'server.port', | |
defaultValue: defaults3.server.port | |
}, | |
{ | |
flag: '--host', | |
path: 'server.host', | |
defaultValue: defaults3.server.host | |
}, | |
{ | |
flag: '--https', | |
path: 'server.useHttps', | |
isFlag: true, | |
defaultValue: defaults3.server.useHttps | |
}, | |
{ | |
flag: '--http2', | |
path: 'server.useHttp2', | |
isFlag: true, | |
defaultValue: defaults3.server.useHttp2 | |
}, | |
{ | |
flag: '--cert', | |
path: 'server.sslCert', | |
defaultValue: defaults3.server.sslCert | |
}, | |
{ | |
flag: '--key', | |
path: 'server.sslKey', | |
defaultValue: defaults3.server.sslKey | |
}, | |
{ | |
flag: '--ca', | |
path: 'server.sslCa', | |
defaultValue: defaults3.server.sslCa | |
}, | |
{ | |
flag: '--debug', | |
path: 'server.debug', | |
isFlag: true, | |
defaultValue: defaults3.server.debug | |
} | |
] | |
}).get(); | |
startServer(config); | |
} catch (error) { | |
console.error(error); | |
} | |
} | |
} | |
main(); | |
// src/MikroChat.ts | |
import { EventEmitter as EventEmitter3 } from 'node:events'; | |
// node_modules/mikroid/lib/chunk-MM76MZMU.mjs | |
import { getRandomValues } from 'node:crypto'; | |
var MikroID = class { | |
options; | |
defaultLength = 16; | |
defaultOnlyLowerCase = false; | |
defaultStyle = 'extended'; | |
defaultUrlSafe = true; | |
constructor(options) { | |
this.options = {}; | |
if (options) { | |
for (const [name, config] of Object.entries(options)) { | |
this.options[name] = this.generateConfig(config); | |
} | |
} | |
} | |
/** | |
* @description Adds a new ID configuration to the options collection. | |
* This allows for reusable ID configurations with custom settings. | |
*/ | |
add(config) { | |
if (!config?.name) throw new Error('Missing name for the ID configuration'); | |
const cleanConfig = this.generateConfig(config); | |
this.options[config.name] = cleanConfig; | |
} | |
/** | |
* @description Removes an existing ID configuration from the options collection. | |
* Use this to clean up configurations that are no longer needed. | |
*/ | |
remove(config) { | |
if (!config?.name) throw new Error('Missing name for the ID configuration'); | |
delete this.options[config.name]; | |
} | |
/** | |
* @description Creates a new ID. | |
* | |
* You may set an explicit `length` for the ID, or use the defaults provided. | |
* | |
* Optionally, `style` may be passed to specify the ID style (default is 'extended'). | |
* - 'extended': includes safe special characters for more unique combinations | |
* - 'alphanumeric': uses only letters and numbers | |
* - 'hex': uses only 0-9 and a-f (A-F) | |
* | |
* Optionally, `onlyLowerCase` may be passed which for alphanumeric | |
* IDs will return a string that only uses numbers and lower-case letters. | |
* | |
* Optionally, `urlSafe` may be passed which determines whether to use | |
* URL-safe characters only. | |
* | |
* @example | |
* const mikroid = new MikroID(); | |
* const id = mikroid.create(16, 'alphanumeric'); // 16-character alphanumeric ID | |
* const shortId = mikroid.create(8); // 8-character extended ID | |
* const longId = mikroid.create(12); // 12-character extended ID | |
*/ | |
create(length, style, onlyLowerCase, urlSafe) { | |
const config = this.generateConfig({ | |
length, | |
style, | |
onlyLowerCase, | |
urlSafe | |
}); | |
return this.generateId(config); | |
} | |
/** | |
* @description Creates a new ID using your custom ID configuration. | |
* | |
* @example | |
* const id = new MikroID().custom('my-custom-id'); // ID created using 'my-custom-id' configuration | |
*/ | |
custom(name) { | |
if (this.options[name]) return this.generateId(this.options[name]); | |
throw new Error(`No configuration found with name: ${name}`); | |
} | |
/** | |
* @description Generate a cleaned, ready-to-use configuration. | |
*/ | |
generateConfig(options) { | |
return { | |
name: options?.name || '', | |
length: options?.length || this.defaultLength, | |
onlyLowerCase: options?.onlyLowerCase ?? this.defaultOnlyLowerCase, | |
style: options?.style || this.defaultStyle, | |
urlSafe: options?.urlSafe ?? this.defaultUrlSafe | |
}; | |
} | |
/** | |
* @description Gets the appropriate character set based on style and options | |
*/ | |
getCharacterSet(style, onlyLowerCase, urlSafe) { | |
if (style === 'hex') | |
return onlyLowerCase ? '0123456789abcdef' : '0123456789ABCDEFabcdef'; | |
if (style === 'alphanumeric') | |
return onlyLowerCase | |
? 'abcdefghijklmnopqrstuvwxyz0123456789' | |
: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; | |
if (style === 'extended') { | |
if (urlSafe) | |
return onlyLowerCase | |
? 'abcdefghijklmnopqrstuvwxyz0123456789-._~' | |
: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; | |
return onlyLowerCase | |
? 'abcdefghijklmnopqrstuvwxyz0123456789-._~!$()*+,;=:' | |
: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$()*+,;=:'; | |
} | |
throw new Error( | |
`Unknown ID style "${style} provided. Must be one of "extended" (default), "alphanumeric", or "hex".` | |
); | |
} | |
/** | |
* @description Generates an ID using the specified configuration. | |
* Uses cryptographically secure random generation. | |
*/ | |
generateId(options) { | |
const { length, onlyLowerCase, style, urlSafe } = options; | |
if (length < 0 || length === 0) | |
throw new Error('ID length cannot be negative'); | |
const characters = this.getCharacterSet(style, onlyLowerCase, urlSafe); | |
const mask = (2 << (Math.log(characters.length - 1) / Math.LN2)) - 1; | |
const step = Math.ceil((1.6 * mask * length) / characters.length); | |
let id = ''; | |
while (id.length < length) { | |
const bytes = new Uint8Array(step); | |
getRandomValues(bytes); | |
for (let i = 0; i < step; i++) { | |
const byte = bytes[i] & mask; | |
if (byte < characters.length) { | |
id += characters[byte]; | |
if (id.length === length) break; | |
} | |
} | |
} | |
return id; | |
} | |
}; | |
// src/providers/GeneralStorageProvider.ts | |
var GeneralStorageProvider = class { | |
////////////////// | |
// User methods // | |
////////////////// | |
async getUserById(id) { | |
return this.db.get(`user:${id}`); | |
} | |
async getUserByUsername(userName) { | |
const users = await this.db.list('user:'); | |
return users.find((user) => user.userName === userName) || null; | |
} | |
async createUser(user) { | |
await this.db.set(`user:${user.id}`, user); | |
} | |
async deleteUser(id) { | |
await this.db.delete(`user:${id}`); | |
} | |
async listUsers() { | |
return this.db.list('user:'); | |
} | |
async getUserByEmail(email) { | |
const users = await this.db.list('user:'); | |
return users.find((user) => user.email === email) || null; | |
} | |
///////////////////// | |
// Channel methods // | |
///////////////////// | |
async getChannelById(id) { | |
return this.db.get(`channel:${id}`); | |
} | |
async getChannelByName(name) { | |
const channels = await this.db.list('channel:'); | |
return channels.find((channel) => channel.name === name) || null; | |
} | |
async createChannel(channel) { | |
await this.db.set(`channel:${channel.id}`, channel); | |
} | |
async updateChannel(channel) { | |
await this.db.set(`channel:${channel.id}`, channel); | |
} | |
async deleteChannel(id) { | |
await this.db.delete(`channel:${id}`); | |
} | |
async listChannels() { | |
return this.db.list('channel:'); | |
} | |
///////////////////// | |
// Message methods // | |
///////////////////// | |
async getMessageById(id) { | |
return await this.db.get(`message:${id}`); | |
} | |
async listMessagesByChannel(channelId) { | |
const messages = await this.db.list('message:'); | |
return messages | |
.filter((message) => message.channelId === channelId) | |
.sort((a, b) => a.createdAt - b.createdAt); | |
} | |
async createMessage(message) { | |
await this.db.set(`message:${message.id}`, message); | |
} | |
async updateMessage(message) { | |
await this.db.set(`message:${message.id}`, message); | |
} | |
async deleteMessage(id) { | |
await this.db.delete(`message:${id}`); | |
} | |
////////////////////// | |
// Reaction methods // | |
////////////////////// | |
async addReaction(messageId, userId, reaction) { | |
const message = await this.getMessageById(messageId); | |
if (!message) return null; | |
if (!message.reactions) message.reactions = {}; | |
const userReactions = message.reactions[userId] || []; | |
if (!userReactions.includes(reaction)) { | |
message.reactions[userId] = [...userReactions, reaction]; | |
await this.updateMessage(message); | |
} | |
return message; | |
} | |
async removeReaction(messageId, userId, reaction) { | |
const message = await this.getMessageById(messageId); | |
if (!message) return null; | |
if (!message.reactions || !message.reactions[userId]) return message; | |
message.reactions[userId] = message.reactions[userId].filter( | |
(r) => r !== reaction | |
); | |
await this.updateMessage(message); | |
return message; | |
} | |
//////////////////// | |
// Server methods // | |
//////////////////// | |
async getServerSettings() { | |
return this.db.get('server:settings'); | |
} | |
async updateServerSettings(settings) { | |
await this.db.set('server:settings', settings); | |
} | |
}; | |
// src/providers/InMemoryProvider.ts | |
var InMemoryProvider = class extends GeneralStorageProvider { | |
db; | |
constructor() { | |
super(); | |
this.db = new MockDatabase(); | |
} | |
}; | |
var MockDatabase = class { | |
data = {}; | |
async get(key) { | |
return this.data[key] || null; | |
} | |
async set(key, value) { | |
this.data[key] = value; | |
} | |
async delete(key) { | |
delete this.data[key]; | |
} | |
async list(prefix) { | |
const result = []; | |
for (const key in this.data) { | |
if (key.startsWith(prefix)) result.push(this.data[key]); | |
} | |
return result; | |
} | |
}; | |
// src/config/configDefaults.ts | |
var idName = 'mikrochat_id'; | |
var idConfig = { | |
name: idName, | |
length: 12, | |
urlSafe: true | |
}; | |
var configDefaults3 = () => { | |
const debug = getTruthyValue3(process.env.DEBUG) || false; | |
return { | |
devMode: false, | |
auth: { | |
jwtSecret: process.env.AUTH_JWT_SECRET || 'your-jwt-secret', | |
magicLinkExpirySeconds: 15 * 60, | |
// 15 minutes | |
jwtExpirySeconds: 15 * 60, | |
// 15 minutes | |
refreshTokenExpirySeconds: 7 * 24 * 60 * 60, | |
// 7 days | |
maxActiveSessions: 3, | |
appUrl: process.env.APP_URL || 'http://127.0.0.1:3000', | |
isInviteRequired: true, | |
debug | |
}, | |
email: { | |
emailSubject: 'Your Secure Login Link', | |
user: process.env.EMAIL_USER || '', | |
host: process.env.EMAIL_HOST || '', | |
password: process.env.EMAIL_PASSWORD || '', | |
port: 465, | |
secure: true, | |
maxRetries: 2, | |
debug | |
}, | |
storage: { | |
databaseDirectory: 'mikrochat_db', | |
encryptionKey: process.env.STORAGE_KEY || '', | |
debug | |
}, | |
server: { | |
port: Number(process.env.PORT) || 3e3, | |
host: process.env.HOST || 'localhost', | |
useHttps: false, | |
useHttp2: false, | |
sslCert: '', | |
sslKey: '', | |
sslCa: '', | |
rateLimit: { | |
enabled: true, | |
requestsPerMinute: 100 | |
}, | |
allowedDomains: ['http://127.0.0.1:8080'], | |
debug | |
}, | |
chat: { | |
initialUser: { | |
id: process.env.INITIAL_USER_ID || new MikroID().add(idConfig), | |
userName: process.env.INITIAL_USER_NAME || '', | |
email: process.env.INITIAL_USER_EMAIL || '' | |
}, | |
messageRetentionDays: 30, | |
maxMessagesPerChannel: 100 | |
} | |
}; | |
}; | |
function getTruthyValue3(value) { | |
if (value === 'true' || value === true) return true; | |
return false; | |
} | |
// src/MikroChat.ts | |
var MikroChat = class { | |
config; | |
db; | |
id; | |
eventEmitter; | |
generalChannelName = 'General'; | |
constructor(config, db) { | |
this.config = config; | |
this.db = db || new InMemoryProvider(); | |
this.id = new MikroID(); | |
this.eventEmitter = new EventEmitter3(); | |
this.eventEmitter.setMaxListeners(0); | |
this.initialize(); | |
} | |
/** | |
* @description Initialize the server with a general channel | |
* and everything else needed to get started. | |
*/ | |
async initialize() { | |
const id = this.config.initialUser.id; | |
const userName = this.config.initialUser.userName; | |
const email = this.config.initialUser.email; | |
if (!userName || !email) | |
throw new Error( | |
'Missing required data to start a new MikroChat server. Required arguments are "initialUser.userName" and "initialUser.email".' | |
); | |
this.id.add(idConfig); | |
const adminUser = await this.getUserByEmail(email); | |
if (!adminUser) { | |
await this.createUser({ | |
id: id || this.id.custom(idName), | |
userName: userName || email.split('@')[0], | |
email, | |
isAdmin: true, | |
createdAt: Date.now() | |
}); | |
} | |
const generalChannel = await this.db.getChannelByName( | |
this.generalChannelName | |
); | |
if (!generalChannel) { | |
await this.db.createChannel({ | |
id: this.id.custom(idName), | |
name: this.generalChannelName, | |
createdAt: Date.now(), | |
createdBy: this.config.initialUser.id | |
}); | |
} | |
this.scheduleMessageCleanup(); | |
} | |
/** | |
* @description Run a job to clean up messages that are | |
* out of date. | |
*/ | |
scheduleMessageCleanup() { | |
const runEveryNrMinutes = 60; | |
const cleanupInterval = 1e3 * 60 * runEveryNrMinutes; | |
setInterval(async () => { | |
const channels = await this.db.listChannels(); | |
const millisecondsPerDay = 24 * 60 * 60 * 1e3; | |
const cutoffTimestamp = | |
Date.now() - this.config.messageRetentionDays * millisecondsPerDay; | |
for (const channel of channels) { | |
const messages = await this.db.listMessagesByChannel(channel.id); | |
for (const message of messages) { | |
if (message.createdAt < cutoffTimestamp) { | |
await this.db.deleteMessage(message.id); | |
this.emitEvent({ | |
type: 'DELETE_MESSAGE', | |
payload: { id: message.id, channelId: channel.id } | |
}); | |
} | |
} | |
} | |
}, cleanupInterval); | |
} | |
//////////////////////////// | |
// Database proxy methods // | |
//////////////////////////// | |
async getServerSettings() { | |
return await this.db.getServerSettings(); | |
} | |
async updateServerSettings(settings) { | |
return await this.db.updateServerSettings(settings); | |
} | |
async getMessageById(id) { | |
return await this.db.getMessageById(id); | |
} | |
async listUsers() { | |
return await this.db.listUsers(); | |
} | |
async getUserById(id) { | |
return await this.db.getUserById(id); | |
} | |
async getUserByEmail(email) { | |
return await this.db.getUserByEmail(email); | |
} | |
async createUser(user) { | |
return await this.db.createUser(user); | |
} | |
async deleteUser(id) { | |
await this.db.deleteUser(id); | |
} | |
///////////////////// | |
// User management // | |
///////////////////// | |
/** | |
* @description Adds a new user to the server. | |
* | |
* The `force` option is used when creating users out-of-context | |
* of the web application, such as directly on the server. | |
*/ | |
async addUser(email, addedBy = '', isAdmin = false, force = false) { | |
const adminUser = await this.getUserById(addedBy); | |
if (!force && !adminUser) throw new Error('User not found'); | |
if (isAdmin && !adminUser?.isAdmin) | |
throw new Error('Only administrators can add admin users'); | |
const existingUser = await this.getUserByEmail(email); | |
if (existingUser) throw new Error('User with this email already exists'); | |
const id = this.id.custom(idName); | |
const user = { | |
id, | |
userName: email.split('@')[0], | |
email, | |
isAdmin, | |
createdAt: Date.now(), | |
addedBy: addedBy ? addedBy : id | |
// Will be own ID when a user is created without prior invitation ("self-invite") | |
}; | |
await this.createUser(user); | |
this.emitEvent({ | |
type: 'NEW_USER', | |
payload: { id: user.id, userName: user.userName, email: user.email } | |
}); | |
return user; | |
} | |
/** | |
* @description Removes a user from the server. | |
*/ | |
async removeUser(userId, requestedBy) { | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
const requester = await this.getUserById(requestedBy); | |
if (!requester) throw new Error('Requester not found'); | |
if (!requester.isAdmin) | |
throw new Error('Only administrators can remove users'); | |
if (user.isAdmin) { | |
const admins = (await this.listUsers()).filter((u) => u.isAdmin); | |
if (admins.length <= 1) | |
throw new Error('Cannot remove the last administrator'); | |
} | |
await this.deleteUser(userId); | |
this.emitEvent({ | |
type: 'REMOVE_USER', | |
payload: { id: userId, email: user.email } | |
}); | |
} | |
/** | |
* @description Handles a user exiting the server (self-removal). | |
*/ | |
async exitUser(userId) { | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
await this.deleteUser(userId); | |
this.emitEvent({ | |
type: 'USER_EXIT', | |
payload: { id: userId, userName: user.userName } | |
}); | |
} | |
///////////////////// | |
// Channel methods // | |
///////////////////// | |
/** | |
* @description Create a new channel on the server. | |
*/ | |
async createChannel(name, createdBy) { | |
const existingChannel = await this.db.getChannelByName(name); | |
if (existingChannel) | |
throw new Error(`Channel with name "${name}" already exists`); | |
const channel = { | |
id: this.id.custom(idName), | |
name, | |
createdAt: Date.now(), | |
createdBy | |
}; | |
await this.db.createChannel(channel); | |
this.emitEvent({ | |
type: 'NEW_CHANNEL', | |
payload: channel | |
}); | |
return channel; | |
} | |
/** | |
* @description Update a channel on the server. | |
*/ | |
async updateChannel(id, name, userId) { | |
const channel = await this.db.getChannelById(id); | |
if (!channel) throw new Error('Channel not found'); | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
if (channel.createdBy !== userId && !user.isAdmin) | |
throw new Error('You can only edit channels you created'); | |
const existingChannel = await this.db.getChannelById(id); | |
if (existingChannel && existingChannel?.name === this.generalChannelName) | |
throw new Error( | |
`The ${this.generalChannelName} channel cannot be renamed` | |
); | |
if (existingChannel && existingChannel?.id !== id) | |
throw new Error(`Channel with name "${name}" already exists`); | |
channel.name = name; | |
channel.updatedAt = Date.now(); | |
await this.db.updateChannel(channel); | |
this.emitEvent({ | |
type: 'UPDATE_CHANNEL', | |
payload: channel | |
}); | |
return channel; | |
} | |
/** | |
* @description Delete a channel on the server. | |
*/ | |
async deleteChannel(id, userId) { | |
const channel = await this.db.getChannelById(id); | |
if (!channel) throw new Error('Channel not found'); | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
if (channel.createdBy !== userId && !user.isAdmin) | |
throw new Error('You can only delete channels you created'); | |
if (channel.name.toLowerCase() === this.generalChannelName.toLowerCase()) | |
throw new Error('The General channel cannot be deleted'); | |
const messages = await this.db.listMessagesByChannel(id); | |
for (const message of messages) { | |
await this.db.deleteMessage(message.id); | |
} | |
await this.db.deleteChannel(id); | |
this.emitEvent({ | |
type: 'DELETE_CHANNEL', | |
payload: { id, name: channel.name } | |
}); | |
} | |
/** | |
* @description List all channels on the server. | |
*/ | |
async listChannels() { | |
return await this.db.listChannels(); | |
} | |
///////////////////// | |
// Message methods // | |
///////////////////// | |
/** | |
* @description Create a new message. | |
*/ | |
async createMessage(content, authorId, channelId, images = []) { | |
const user = await this.getUserById(authorId); | |
if (!user) throw new Error('Author not found'); | |
const channel = await this.db.getChannelById(channelId); | |
if (!channel) throw new Error('Channel not found'); | |
const now = Date.now(); | |
const message = { | |
id: this.id.custom(idName), | |
author: { | |
id: authorId, | |
userName: user.userName | |
}, | |
images, | |
content, | |
channelId, | |
createdAt: now, | |
updatedAt: now, | |
reactions: {} | |
}; | |
await this.db.createMessage(message); | |
const messages = await this.db.listMessagesByChannel(channelId); | |
if (messages.length > this.config.maxMessagesPerChannel) { | |
const oldestMessage = messages[0]; | |
await this.db.deleteMessage(oldestMessage.id); | |
this.emitEvent({ | |
type: 'DELETE_MESSAGE', | |
payload: { id: oldestMessage.id, channelId } | |
}); | |
} | |
this.emitEvent({ | |
type: 'NEW_MESSAGE', | |
payload: message | |
}); | |
return message; | |
} | |
/** | |
* @description Update an existing message. | |
*/ | |
async updateMessage(id, userId, content, images) { | |
const message = await this.getMessageById(id); | |
if (!message) throw new Error('Message not found'); | |
if (message.author.id !== userId) | |
throw new Error('You can only edit your own messages'); | |
let removedImages = []; | |
if (content) message.content = content; | |
if (images) { | |
const currentImages = message.images || []; | |
removedImages = currentImages.filter((img) => !images.includes(img)); | |
message.images = images; | |
} | |
message.updatedAt = Date.now(); | |
await this.db.updateMessage(message); | |
this.emitEvent({ | |
type: 'UPDATE_MESSAGE', | |
payload: message | |
}); | |
return { message, removedImages }; | |
} | |
/** | |
* @description Delete an existing message. | |
*/ | |
async deleteMessage(id, userId) { | |
const message = await this.getMessageById(id); | |
if (!message) throw new Error('Message not found'); | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
if (message.author.id !== userId && !user.isAdmin) | |
throw new Error('You can only delete your own messages'); | |
await this.db.deleteMessage(id); | |
this.emitEvent({ | |
type: 'DELETE_MESSAGE', | |
payload: { id, channelId: message.channelId } | |
}); | |
} | |
/** | |
* @description Get all message in a given channel. | |
*/ | |
async getMessagesByChannel(channelId) { | |
return await this.db.listMessagesByChannel(channelId); | |
} | |
////////////////////// | |
// Reaction methods // | |
////////////////////// | |
/** | |
* @description Add a reaction to a message. | |
*/ | |
async addReaction(messageId, userId, reaction) { | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
const message = await this.getMessageById(messageId); | |
if (!message) throw new Error('Message not found'); | |
const updatedMessage = await this.db.addReaction( | |
messageId, | |
userId, | |
reaction | |
); | |
if (!updatedMessage) throw new Error('Failed to add reaction'); | |
this.emitEvent({ | |
type: 'NEW_REACTION', | |
payload: { messageId, userId, reaction } | |
}); | |
return updatedMessage; | |
} | |
/** | |
* @description Remove a reaction from a message. | |
*/ | |
async removeReaction(messageId, userId, reaction) { | |
const user = await this.getUserById(userId); | |
if (!user) throw new Error('User not found'); | |
const message = await this.getMessageById(messageId); | |
if (!message) throw new Error('Message not found'); | |
const updatedMessage = await this.db.removeReaction( | |
messageId, | |
userId, | |
reaction | |
); | |
if (!updatedMessage) throw new Error('Failed to remove reaction'); | |
this.emitEvent({ | |
type: 'DELETE_REACTION', | |
payload: { messageId, userId, reaction } | |
}); | |
return updatedMessage; | |
} | |
//////////////////// | |
// Event handling // | |
//////////////////// | |
/** | |
* @description Emit a Server Sent Event. | |
* @emits | |
*/ | |
emitEvent(event) { | |
console.log(`Emitting event ${event.type}`, event.payload); | |
this.eventEmitter.emit('sse', event); | |
} | |
/** | |
* @description Subscribe to Server Sent Events. | |
* @subscribes | |
*/ | |
subscribeToEvents(callback) { | |
const listener = (event) => callback(event); | |
this.eventEmitter.on('sse', listener); | |
return () => this.eventEmitter.off('sse', listener); | |
} | |
}; | |
// src/Server.ts | |
import { randomBytes as randomBytes2 } from 'node:crypto'; | |
import { | |
existsSync as existsSync6, | |
mkdirSync as mkdirSync2, | |
readFileSync as readFileSync4, | |
unlinkSync, | |
writeFileSync as writeFileSync2 | |
} from 'node:fs'; | |
import { join as join3 } from 'node:path'; | |
var MAX_IMAGE_SIZE_IN_MB = 2; | |
var VALID_FILE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'svg']; | |
var MAX_CONNECTIONS_PER_USER = 3; | |
var CONNECTION_TIMEOUT_MS = 60 * 1e3; | |
var activeConnections = /* @__PURE__ */ new Map(); | |
var connectionTimeouts = /* @__PURE__ */ new Map(); | |
async function startServer2(settings) { | |
const { config, auth, chat, devMode, isInviteRequired } = settings; | |
const server = new MikroServe(config); | |
console.log('Dev mode?', devMode); | |
if (devMode) { | |
server.post('/auth/dev-login', async (c) => { | |
if (!c.body.email) return c.json({ error: 'Email is required' }, 400); | |
const { email } = c.body; | |
let user; | |
if (isInviteRequired) { | |
user = await chat.getUserByEmail(email); | |
if (!user) | |
return c.json({ success: false, message: 'Unauthorized' }, 401); | |
} else { | |
user = await chat.addUser(email, email, false, true); | |
} | |
const token = auth.generateJsonWebToken({ | |
id: user.id, | |
username: user.userName, | |
email: user.email, | |
role: user.isAdmin ? 'admin' : 'user' | |
}); | |
return c.json({ user, token }, 200); | |
}); | |
} | |
server.post('/auth/login', async (c) => { | |
if (!c.body.email) return c.json({ error: 'Email is required' }, 400); | |
const { email } = c.body; | |
console.log(111); | |
let message = | |
'If a matching account was found, a magic link has been sent.'; | |
console.log(222); | |
const user = await chat.getUserByEmail(email); | |
console.log('user', user); | |
console.log(333); | |
if (user) { | |
const result = await auth.createMagicLink({ | |
}); | |
console.log('result', result); | |
console.log(444); | |
if (!result) return c.json({ error: 'Failed to create magic link' }, 400); | |
console.log(555); | |
message = result.message; | |
} | |
console.log(666); | |
return c.json( | |
{ | |
success: true, | |
message | |
}, | |
200 | |
); | |
}); | |
server.post('/auth/logout', authenticate, async (c) => { | |
const body = c.body; | |
const refreshToken = body.refreshToken; | |
if (!refreshToken) return c.json({ error: 'Missing refresh token' }, 400); | |
const result = await auth.logout(refreshToken); | |
return c.json(result, 200); | |
}); | |
server.get('/auth/me', authenticate, async (c) => { | |
return c.json({ user: c.state.user }, 200); | |
}); | |
server.post('/auth/verify', async (c) => { | |
const body = c.body; | |
const authHeader = c.headers.authorization || ''; | |
const token = authHeader.split(' ')[1]; | |
if (!token) return c.json({ error: 'Token is required' }, 400); | |
let result; | |
try { | |
result = await auth.verifyToken({ | |
email: body.email, | |
token | |
}); | |
if (!result) return c.json({ error: 'Invalid token' }, 400); | |
} catch (error) { | |
return c.json({ error: 'Invalid token' }, 400); | |
} | |
return c.json(result, 200); | |
}); | |
server.post('/auth/refresh', async (c) => { | |
const body = c.body; | |
const token = body.refreshToken; | |
const result = await auth.refreshAccessToken(token); | |
return c.json(result, 200); | |
}); | |
server.get('/auth/sessions', authenticate, async (c) => { | |
const authHeader = c.headers.authorization; | |
if (!authHeader || !authHeader.startsWith('Bearer ')) | |
return c.json(null, 401); | |
const body = c.body; | |
const token = authHeader.split(' ')[1]; | |
const payload = auth.verify(token); | |
const user = { email: payload.sub }; | |
const result = await auth.getSessions({ body, user }); | |
return c.json(result, 200); | |
}); | |
server.delete('/auth/sessions', authenticate, async (c) => { | |
const authHeader = c.headers.authorization; | |
if (!authHeader || !authHeader.startsWith('Bearer ')) | |
return c.json(null, 401); | |
const body = c.body; | |
const token = authHeader.split(' ')[1]; | |
const payload = auth.verify(token); | |
const user = { email: payload.sub }; | |
const result = await auth.revokeSessions({ body, user }); | |
return c.json(result, 200); | |
}); | |
server.post('/users/add', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const { email, role } = c.body; | |
if (!email) return c.json({ error: 'Email is required' }, 400); | |
try { | |
if (role === 'admin' && !user.isAdmin) | |
return c.json( | |
{ error: 'Only administrators can add admin users' }, | |
403 | |
); | |
const existingUser = await chat.getUserByEmail(email); | |
if (existingUser) | |
return c.json({ success: false, message: 'User already exists' }); | |
const userId = await chat.addUser(email, user.id, role === 'admin'); | |
return c.json({ success: true, userId }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.get('/users', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
try { | |
const users = await chat.listUsers(); | |
return c.json({ users }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.delete('/users/:id', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const userId = c.params.id; | |
if (userId === user.id) | |
return c.json({ error: 'You cannot remove your own account' }, 400); | |
try { | |
await chat.removeUser(userId, user.id); | |
return c.json({ success: true }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.post('/users/exit', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
try { | |
if (user.isAdmin) { | |
const admins = (await chat.listUsers()).filter((u) => u.isAdmin); | |
if (admins.length <= 1) { | |
return c.json( | |
{ error: 'Cannot exit as the last administrator' }, | |
400 | |
); | |
} | |
} | |
await chat.exitUser(user.id); | |
return c.json( | |
{ success: true, message: 'You have exited the server' }, | |
200 | |
); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.get('/channels', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const channels = await chat.listChannels(); | |
return c.json({ channels }, 200); | |
}); | |
server.post('/channels', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const { name } = c.body; | |
if (!name) return c.json({ error: 'Channel name is required' }, 400); | |
try { | |
const channel = await chat.createChannel(name, user.id); | |
return c.json({ channel }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.get('/channels/:channelId/messages', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const channelId = c.params.channelId; | |
try { | |
const messages = await chat.getMessagesByChannel(channelId); | |
const enhancedMessages = await Promise.all( | |
messages.map(async (message) => { | |
const author = await chat.getUserById(message.author.id); | |
return { | |
...message, | |
author: { | |
id: author?.id, | |
userName: author?.userName || 'Unknown User' | |
} | |
}; | |
}) | |
); | |
return c.json({ messages: enhancedMessages }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.post('/channels/:channelId/messages', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const channelId = c.params.channelId; | |
const content = c.body?.content; | |
const images = c.body?.images; | |
if (!content && !images) | |
return c.json({ error: 'Message content is required' }, 400); | |
try { | |
const message = await chat.createMessage(content, user.id, channelId); | |
return c.json({ message }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.put('/channels/:channelId', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const channelId = c.params.channelId; | |
const { name } = c.body; | |
if (!name) return c.json({ error: 'Channel name is required' }, 400); | |
try { | |
const channel = await chat.updateChannel(channelId, name, user.id); | |
return c.json({ channel }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.delete('/channels/:channelId', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const channelId = c.params.channelId; | |
try { | |
await chat.deleteChannel(channelId, user.id); | |
return c.json({ success: true }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.put('/messages/:messageId', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const messageId = c.params.messageId; | |
const content = c.body?.content; | |
const images = c.body?.images; | |
if (!content && !images) | |
return c.json({ error: 'Message content is required' }, 400); | |
try { | |
const { message, removedImages } = await chat.updateMessage( | |
messageId, | |
user.id, | |
content, | |
images | |
); | |
deleteImages(removedImages); | |
return c.json({ message }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.delete('/messages/:messageId', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const messageId = c.params.messageId; | |
try { | |
const message = await chat.getMessageById(messageId); | |
const images = message?.images || []; | |
await chat.deleteMessage(messageId, user.id); | |
deleteImages(images); | |
return c.json({ success: true }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.post('/messages/:messageId/reactions', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const messageId = c.params.messageId; | |
const { reaction } = c.body; | |
if (!reaction) return c.json({ error: 'Reaction is required' }, 400); | |
try { | |
const message = await chat.addReaction(messageId, user.id, reaction); | |
if (!message) { | |
return c.json({ error: `Message with ID ${messageId} not found` }, 401); | |
} | |
return c.json({ message }, 200); | |
} catch (error) { | |
console.error(`Error adding reaction to message ${messageId}:`, error); | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.delete('/messages/:messageId/reactions', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const messageId = c.params.messageId; | |
const { reaction } = c.body; | |
if (!reaction) return c.json({ error: 'Reaction is required' }, 400); | |
try { | |
const message = await chat.removeReaction(messageId, user.id, reaction); | |
if (!message) { | |
return c.json({ error: `Message with ID ${messageId} not found` }, 404); | |
} | |
return c.json({ message }, 200); | |
} catch (error) { | |
console.error( | |
`Error removing reaction from message ${messageId}:`, | |
error | |
); | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.post( | |
'/channels/:channelId/messages/image', | |
authenticate, | |
async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
try { | |
const { filename, image } = c.body; | |
if (!image) return c.json({ error: 'No image provided' }, 400); | |
const fileExtension = filename.split('.').pop(); | |
if (!fileExtension) | |
return c.json({ error: 'Missing file extension' }, 400); | |
if (!VALID_FILE_FORMATS.includes(fileExtension)) | |
return c.json({ error: 'Unsupported file format' }, 400); | |
const imageBuffer = Buffer.from(image, 'base64'); | |
const maxImageSize = MAX_IMAGE_SIZE_IN_MB * 1024 * 1024; | |
if (imageBuffer.length > maxImageSize) | |
return c.json({ error: 'Image too large' }, 400); | |
const uploadDirectory = `${process.cwd()}/uploads`; | |
if (!existsSync6(uploadDirectory)) mkdirSync2(uploadDirectory); | |
const savedFileName = `${Date.now()}-${randomBytes2(1).toString('hex')}.${fileExtension}`; | |
const uploadPath = join3(uploadDirectory, savedFileName); | |
writeFileSync2(uploadPath, imageBuffer); | |
return c.json({ | |
success: true, | |
//channelId, | |
filename: savedFileName | |
}); | |
} catch (error) { | |
console.error('Image upload error:', error); | |
return c.json( | |
{ | |
success: false, | |
error: error instanceof Error ? error.message : 'Upload failed' | |
}, | |
500 | |
); | |
} | |
} | |
); | |
server.get( | |
'/channels/:channelId/messages/image/:filename', | |
authenticate, | |
async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
try { | |
const { filename } = c.params; | |
const uploadDirectory = `${process.cwd()}/uploads`; | |
const filePath = join3(uploadDirectory, filename); | |
if (!existsSync6(filePath)) | |
return c.json({ error: 'Image not found' }, 404); | |
const imageBuffer = readFileSync4(filePath); | |
const fileExtension = filename.split('.').pop()?.toLowerCase(); | |
let contentType = 'application/octet-stream'; | |
if (fileExtension === 'jpg' || fileExtension === 'jpeg') | |
contentType = 'image/jpeg'; | |
else if (fileExtension === 'png') contentType = 'image/png'; | |
else if (fileExtension === 'webp') contentType = 'image/webp'; | |
else if (fileExtension === 'svg') contentType = 'image/svg+xml'; | |
return c.binary(imageBuffer, contentType); | |
} catch (error) { | |
return c.json( | |
{ | |
success: false, | |
error: error instanceof Error ? error.message : 'Image fetch failed' | |
}, | |
500 | |
); | |
} | |
} | |
); | |
server.get('/server/settings', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
try { | |
const settings2 = await chat.getServerSettings(); | |
return c.json(settings2 || { name: 'MikroChat' }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.put('/server/settings', authenticate, async (c) => { | |
const user = c.state.user; | |
if (!user) return c.json({ error: 'Unauthorized' }, 401); | |
const { name } = c.body; | |
if (!name) return c.json({ error: 'Server name is required' }, 400); | |
try { | |
await chat.updateServerSettings({ name }); | |
return c.json({ name }, 200); | |
} catch (error) { | |
return c.json({ error: error.message }, 400); | |
} | |
}); | |
server.get('/events', async (c) => { | |
let user = null; | |
const token = c.query.token; | |
if (token) { | |
try { | |
const payload = auth.verify(token); | |
user = await chat.getUserByEmail(payload.email || payload.sub); | |
} catch (error) { | |
console.error('SSE token validation error:', error); | |
} | |
} | |
if (!user && c.headers.authorization?.startsWith('Bearer ')) { | |
const headerToken = c.headers.authorization.substring(7); | |
try { | |
const payload = auth.verify(headerToken); | |
user = await chat.getUserByEmail(payload.email || payload.sub); | |
} catch (error) { | |
console.error('SSE header validation error:', error); | |
} | |
} | |
if (!user) { | |
console.log('SSE unauthorized access attempt'); | |
return { | |
statusCode: 401, | |
body: { error: 'Unauthorized' }, | |
headers: { 'Content-Type': 'application/json' } | |
}; | |
} | |
const connectionId = randomBytes2(8).toString('hex'); | |
if (activeConnections.has(user.id)) { | |
const now = Date.now(); | |
const userConnections2 = activeConnections.get(user.id); | |
const staleConnectionIds = []; | |
userConnections2.forEach((connectionId2) => { | |
const lastActivity = connectionTimeouts.get(connectionId2) || 0; | |
if (now - lastActivity > CONNECTION_TIMEOUT_MS) { | |
staleConnectionIds.push(connectionId2); | |
} | |
}); | |
staleConnectionIds.forEach((connectionId2) => { | |
userConnections2.delete(connectionId2); | |
connectionTimeouts.delete(connectionId2); | |
console.log( | |
`Cleaned up stale connection ${connectionId2} for user ${user.id}` | |
); | |
}); | |
if (userConnections2.size >= MAX_CONNECTIONS_PER_USER) { | |
let oldestConnectionId = null; | |
let oldestTime = Number.POSITIVE_INFINITY; | |
userConnections2.forEach((connectionId2) => { | |
const lastActivity = connectionTimeouts.get(connectionId2) || 0; | |
if (lastActivity < oldestTime) { | |
oldestTime = lastActivity; | |
oldestConnectionId = connectionId2; | |
} | |
}); | |
if (oldestConnectionId) { | |
userConnections2.delete(oldestConnectionId); | |
connectionTimeouts.delete(oldestConnectionId); | |
console.log( | |
`Removed oldest connection ${oldestConnectionId} for user ${user.id} to make room` | |
); | |
} | |
} | |
} else { | |
activeConnections.set(user.id, /* @__PURE__ */ new Set()); | |
} | |
const userConnections = activeConnections.get(user.id); | |
userConnections.add(connectionId); | |
connectionTimeouts.set(connectionId, Date.now()); | |
console.log( | |
`SSE connection established for user ${user.id} (${connectionId}). Total connections: ${userConnections.size}` | |
); | |
c.res.writeHead(200, { | |
'Content-Type': 'text/event-stream', | |
'Cache-Control': 'no-cache', | |
Connection: 'keep-alive', | |
'X-Accel-Buffering': 'no' | |
}); | |
const updateActivity = () => { | |
connectionTimeouts.set(connectionId, Date.now()); | |
}; | |
const keepAlive = setInterval(() => { | |
if (!c.res.writable) { | |
clearInterval(keepAlive); | |
return; | |
} | |
updateActivity(); | |
c.res.write(': ping\n\n'); | |
}, 3e4); | |
c.res.write(':\n\n'); | |
c.res.write( | |
`data: ${JSON.stringify({ | |
type: 'CONNECTED', | |
payload: { | |
message: 'SSE connection established', | |
timestamp: /* @__PURE__ */ new Date().toISOString(), | |
userId: user.id, | |
connectionId | |
} | |
})} | |
` | |
); | |
updateActivity(); | |
const unsubscribe = chat.subscribeToEvents((event) => { | |
if (!c.res.writable) { | |
unsubscribe(); | |
return; | |
} | |
try { | |
updateActivity(); | |
c.res.write(`data: ${JSON.stringify(event)} | |
`); | |
} catch (error) { | |
console.error('Error sending SSE event:', error); | |
cleanupConnection(); | |
} | |
}); | |
const cleanupConnection = () => { | |
const userConnections2 = activeConnections.get(user.id); | |
if (userConnections2) { | |
userConnections2.delete(connectionId); | |
connectionTimeouts.delete(connectionId); | |
console.log( | |
`Connection ${connectionId} for user ${user.id} cleaned up. Remaining: ${userConnections2.size}` | |
); | |
if (userConnections2.size === 0) activeConnections.delete(user.id); | |
} | |
unsubscribe(); | |
clearInterval(keepAlive); | |
if (c.res.writable) c.res.end(); | |
}; | |
c.req.on('close', cleanupConnection); | |
c.req.on('error', (error) => { | |
console.error(`SSE connection error for user ${user.id}:`, error); | |
cleanupConnection(); | |
}); | |
c.res.on('error', (error) => { | |
console.error(`SSE response error for user ${user.id}:`, error); | |
cleanupConnection(); | |
}); | |
return { | |
statusCode: 200, | |
_handled: true, | |
body: null | |
}; | |
}); | |
async function authenticate(c, next) { | |
const unauthorized = { | |
error: 'Unauthorized', | |
message: 'Authentication required' | |
}; | |
const authHeader = c.headers.authorization; | |
if (!authHeader || !authHeader.startsWith('Bearer ')) | |
return c.status(401).json(unauthorized); | |
const token = authHeader.split(' ')[1]; | |
if (!token) return c.status(401).json(unauthorized); | |
const payload = auth.verify(token); | |
const user = await chat.getUserByEmail(payload.email || payload.sub); | |
c.state.user = user; | |
return next(); | |
} | |
server.start(); | |
} | |
function deleteImages(images) { | |
for (const image of images) { | |
const uploadDirectory = `${process.cwd()}/uploads`; | |
const imagePath = join3(uploadDirectory, image); | |
unlinkSync(imagePath); | |
} | |
} | |
// src/providers/MikroDBProvider.ts | |
var MikroDBProvider = class extends GeneralStorageProvider { | |
db; | |
mikroDb; | |
constructor(mikroDb) { | |
super(); | |
this.mikroDb = mikroDb; | |
this.db = new MikroDbDatabase(mikroDb); | |
} | |
/** | |
* @description Start the MikroDB instance. | |
* This must be called before using any other methods. | |
*/ | |
async start() { | |
await this.mikroDb.start(); | |
} | |
/** | |
* @description Close the database connection and clean up resources. | |
* This should be called when shutting down the application. | |
*/ | |
async close() { | |
await this.mikroDb.close(); | |
} | |
}; | |
var MikroDbDatabase = class { | |
db; | |
tableName; | |
constructor(db, tableName = 'mikrochat_db') { | |
this.db = db; | |
this.tableName = tableName; | |
} | |
/** | |
* @description Get a value by key. | |
*/ | |
async get(key) { | |
try { | |
const result = await this.db.get({ | |
tableName: this.tableName, | |
key | |
}); | |
if (result === void 0 || result === null) return null; | |
return result; | |
} catch (error) { | |
console.error(`Error getting key ${key}:`, error); | |
return null; | |
} | |
} | |
/** | |
* @description Set a value with optional expiry. | |
*/ | |
async set(key, value, expirySeconds) { | |
const expiration = expirySeconds ? Date.now() + expirySeconds * 1e3 : 0; | |
await this.db.write({ | |
tableName: this.tableName, | |
key, | |
value, | |
expiration | |
}); | |
} | |
/** | |
* @description Delete a key. | |
*/ | |
async delete(key) { | |
await this.db.delete({ | |
tableName: this.tableName, | |
key | |
}); | |
} | |
/** | |
* @description List all values with keys that start with the prefix. | |
*/ | |
async list(prefix) { | |
try { | |
const allItems = | |
(await this.db.get({ | |
tableName: this.tableName | |
})) || []; | |
const filteredItems = allItems | |
.filter((item) => { | |
return ( | |
Array.isArray(item) && | |
typeof item[0] === 'string' && | |
item[0].startsWith(prefix) | |
); | |
}) | |
.map((item) => item[1].value); | |
return filteredItems; | |
} catch (error) { | |
console.error(`Error listing with prefix ${prefix}:`, error); | |
return []; | |
} | |
} | |
}; | |
// src/config/mikrochatOptions.ts | |
var defaults2 = configDefaults3(); | |
var mikrochatOptions = { | |
configFilePath: 'mikrochat.config.json', | |
args: process.argv, | |
options: [ | |
{ | |
flag: '--dev', | |
path: 'devMode', | |
defaultValue: defaults2.devMode, | |
isFlag: true | |
}, | |
// Auth configuration | |
{ | |
flag: '--jwtSecret', | |
path: 'auth.jwtSecret', | |
defaultValue: defaults2.auth.jwtSecret | |
}, | |
{ | |
flag: '--magicLinkExpirySeconds', | |
path: 'auth.magicLinkExpirySeconds', | |
defaultValue: defaults2.auth.magicLinkExpirySeconds | |
}, | |
{ | |
flag: '--jwtExpirySeconds', | |
path: 'auth.jwtExpirySeconds', | |
defaultValue: defaults2.auth.jwtExpirySeconds | |
}, | |
{ | |
flag: '--refreshTokenExpirySeconds', | |
path: 'auth.refreshTokenExpirySeconds', | |
defaultValue: defaults2.auth.refreshTokenExpirySeconds | |
}, | |
{ | |
flag: '--maxActiveSessions', | |
path: 'auth.maxActiveSessions', | |
defaultValue: defaults2.auth.maxActiveSessions | |
}, | |
{ | |
flag: '--appUrl', | |
path: 'auth.appUrl', | |
defaultValue: defaults2.auth.appUrl | |
}, | |
{ | |
flag: '--isInviteRequired', | |
path: 'auth.isInviteRequired', | |
isFlag: true, | |
defaultValue: defaults2.auth.isInviteRequired | |
}, | |
{ | |
flag: '--debug', | |
path: 'auth.debug', | |
isFlag: true, | |
defaultValue: defaults2.auth.debug | |
}, | |
// Email configuration | |
{ | |
flag: '--emailSubject', | |
path: 'email.emailSubject', | |
defaultValue: 'Your Secure Login Link' | |
}, | |
{ | |
flag: '--emailHost', | |
path: 'email.host', | |
defaultValue: defaults2.email.host | |
}, | |
{ | |
flag: '--emailUser', | |
path: 'email.user', | |
defaultValue: defaults2.email.user | |
}, | |
{ | |
flag: '--emailPassword', | |
path: 'email.password', | |
defaultValue: defaults2.email.password | |
}, | |
{ | |
flag: '--emailPort', | |
path: 'email.port', | |
defaultValue: defaults2.email.host | |
}, | |
{ | |
flag: '--emailSecure', | |
path: 'email.secure', | |
isFlag: true, | |
defaultValue: defaults2.email.secure | |
}, | |
{ | |
flag: '--emailMaxRetries', | |
path: 'email.maxRetries', | |
defaultValue: defaults2.email.maxRetries | |
}, | |
{ | |
flag: '--debug', | |
path: 'email.debug', | |
isFlag: true, | |
defaultValue: defaults2.email.debug | |
}, | |
// Storage configuration | |
{ | |
flag: '--db', | |
path: 'storage.databaseDirectory', | |
defaultValue: defaults2.storage.databaseDirectory | |
}, | |
{ | |
flag: '--encryptionKey', | |
path: 'storage.encryptionKey', | |
defaultValue: defaults2.storage.encryptionKey | |
}, | |
{ | |
flag: '--debug', | |
path: 'storage.debug', | |
defaultValue: defaults2.storage.debug | |
}, | |
// Server configuration | |
{ | |
flag: '--port', | |
path: 'server.port', | |
defaultValue: defaults2.server.port | |
}, | |
{ | |
flag: '--host', | |
path: 'server.host', | |
defaultValue: defaults2.server.host | |
}, | |
{ | |
flag: '--https', | |
path: 'server.useHttps', | |
isFlag: true, | |
defaultValue: defaults2.server.useHttps | |
}, | |
{ | |
flag: '--http2', | |
path: 'server.useHttp2', | |
isFlag: true, | |
defaultValue: defaults2.server.useHttp2 | |
}, | |
{ | |
flag: '--cert', | |
path: 'server.sslCert', | |
defaultValue: defaults2.server.sslCert | |
}, | |
{ | |
flag: '--key', | |
path: 'server.sslKey', | |
defaultValue: defaults2.server.sslKey | |
}, | |
{ | |
flag: '--ca', | |
path: 'server.sslCa', | |
defaultValue: defaults2.server.sslCa | |
}, | |
{ | |
flag: '--ratelimit', | |
path: 'server.rateLimit.enabled', | |
defaultValue: defaults2.server.rateLimit.enabled, | |
isFlag: true | |
}, | |
{ | |
flag: '--rps', | |
path: 'server.rateLimit.requestsPerMinute', | |
defaultValue: defaults2.server.rateLimit.requestsPerMinute | |
}, | |
{ | |
flag: '--allowed', | |
path: 'server.allowedDomains', | |
defaultValue: defaults2.server.allowedDomains, | |
parser: parsers.array | |
}, | |
{ | |
flag: '--debug', | |
path: 'server.debug', | |
isFlag: true, | |
defaultValue: defaults2.server.debug | |
}, | |
// Chat configuration | |
{ | |
flag: '--initialUserId', | |
path: 'chat.initialUser.id', | |
defaultValue: defaults2.chat.initialUser.id | |
}, | |
{ | |
flag: '--initialUserUsername', | |
path: 'chat.initialUser.userName', | |
defaultValue: defaults2.chat.initialUser.userName | |
}, | |
{ | |
flag: '--initialUserEmail', | |
path: 'chat.initialUser.email', | |
defaultValue: defaults2.chat.initialUser.email | |
}, | |
{ | |
flag: '--messageRetentionDays', | |
path: 'chat.messageRetentionDays', | |
defaultValue: defaults2.chat.messageRetentionDays | |
}, | |
{ | |
flag: '--maxMessagesPerChannel', | |
path: 'chat.maxMessagesPerChannel', | |
defaultValue: defaults2.chat.maxMessagesPerChannel | |
} | |
] | |
}; | |
// src/index.ts | |
async function start() { | |
const config = getConfig(); | |
const db = new MikroDB(config.storage); | |
await db.start(); | |
const authStorageProvider = new x(db); | |
const chatStorageProvider = new MikroDBProvider(db); | |
const emailProvider = new k(config.email); | |
const auth = new E(config, emailProvider, authStorageProvider); | |
const chat = new MikroChat(config.chat, chatStorageProvider); | |
await startServer2({ | |
config: config.server, | |
auth, | |
chat, | |
devMode: config.devMode, | |
isInviteRequired: config.auth.isInviteRequired | |
}); | |
} | |
function getConfig() { | |
const config = new MikroConf(mikrochatOptions).get(); | |
if (config.auth.debug) console.log('Using configuration:', config); | |
return config; | |
} | |
start(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment