UNPKG

scandium

Version:

> Easily deploy any Node.js web server to AWS Lambda.

262 lines (204 loc) 6.8 kB
import net from 'node:net' import url from 'node:url' import http from 'node:http' import stream from 'node:stream' const kChunks = Symbol('chunks') const kCallback = Symbol('callback') const kFromLoadBalancer = Symbol('loadBalancer') function shouldBase64Encode (headers) { /* Every other content-encoding than "identity" is higlhy likely to be binary */ if ((headers['content-encoding'] || 'identity') !== 'identity') { return true } /* Common text based formats doesn't need to be encoded */ if ( /^text\//.exec(headers['content-type']) || /^application\/(dart|(java|ecma|post)script)/.exec(headers['content-type']) || /^application\/(.+\+)?(json|xml)/.exec(headers['content-type']) ) { return false } /* Fallback to do the encoding */ return true } function createMultipleHeaderValues (headers) { const result = Object.create(null) for (const key in headers) { if (headers[key] instanceof Array) { result[key] = headers[key].map(value => String(value)) } else { result[key] = [String(headers[key])] } } return result } function extractMultipleHeaderValues (headers) { const result = Object.create(null) for (const key in headers) { if (headers[key] instanceof Array) { result[key] = headers[key] delete headers[key] } } return result } function extractQueryParameters (event) { const result = Object.create(null) if (event.multiValueQueryStringParameters) { for (const key in event.multiValueQueryStringParameters) { // Application Load Balancer doesn't decode query parameters result[key] = event.multiValueQueryStringParameters[key].map(value => decodeURIComponent(value)) } } else if (event.queryStringParameters) { for (const key in event.queryStringParameters) { // API Gateway does decode query parameters result[key] = [event.queryStringParameters[key]] } } return result } class LambdaSocket extends stream.Duplex { constructor (event) { super() this[kFromLoadBalancer] = (event.requestContext.elb != null) const sourceIp = event.requestContext.identity?.sourceIp ?? '127.0.0.1' const sourceFamily = net.isIPv6(sourceIp) ? 'IPv6' : 'IPv4' this.bufferSize = 0 this.bytesRead = 0 this.bytesWritten = 0 this.connecting = false this.destroyed = false this.encrypted = true this.localAddress = '127.0.0.1' this.localPort = 80 this.readable = false this.remoteAddress = sourceIp this.remoteFamily = sourceFamily this.remotePort = 80 } address () { return { port: this.localPort, family: 'IPv4', address: this.localAddress } } connect () { throw new Error('Cannot connect this socket') } // Nothing to destroy destroy () {} // Discard all data, it's handled elsewhere _write (chunk, encoding, next) { next() } // This stream will start ended _read () {} } class LambdaRequest extends http.IncomingMessage { constructor (socket, event) { super(socket) const rawHeaders = [] if (event.multiValueHeaders) { for (const key of Object.keys(event.multiValueHeaders)) { for (const value of event.multiValueHeaders[key]) { rawHeaders.push(key) rawHeaders.push(value) } } } else if (event.headers) { for (const key of Object.keys(event.headers)) { rawHeaders.push(key) rawHeaders.push(event.headers[key]) } } this._addHeaderLines(rawHeaders, rawHeaders.length) this.httpVersionMajor = '1' this.httpVersionMinor = '1' this.httpVersion = '1.1' this.complete = true this.url = event.rawPath ? event.rawPath + (event.rawQueryString ? '?' + event.rawQueryString : '') : url.format({ pathname: event.path, query: extractQueryParameters(event) }) this.method = event.httpMethod ?? event.requestContext?.http?.method const body = event.body ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8') : '' if (body) { if (!('content-length' in this.headers)) { this.headers['content-length'] = String(body.byteLength) } this.push(body) } this.push(null) } } class LambdaResponse extends http.ServerResponse { constructor (socket, request, cb) { super(request) this[kChunks] = [] this[kCallback] = cb this.assignSocket(socket) this.write = LambdaResponse.prototype.write this.end = LambdaResponse.prototype.end } write (chunk) { if (typeof chunk === 'string') { chunk = Buffer.from(chunk) } if (!Buffer.isBuffer(chunk)) { throw new TypeError('Invalid non-string/buffer chunk') } this[kChunks].push(chunk) } end (chunk) { if (chunk) this.write(chunk) const headers = this.getHeaders() const base64Encode = shouldBase64Encode(headers) const body = Buffer.concat(this[kChunks]).toString(base64Encode ? 'base64' : 'utf8') let result if (this.socket[kFromLoadBalancer]) { const multiValueHeaders = createMultipleHeaderValues(headers) result = { isBase64Encoded: base64Encode, statusCode: this.statusCode, statusDescription: `${this.statusCode} ${this.statusMessage}`, multiValueHeaders, body } } else { const multiValueHeaders = extractMultipleHeaderValues(headers) result = { isBase64Encoded: base64Encode, statusCode: this.statusCode, headers, multiValueHeaders, body } } this.emit('finish') this[kCallback](null, result) } } const serverPromise = new Promise((resolve) => { let listenCalled = false http.Server.prototype.listen = function listen () { if (listenCalled) { throw new Error('`.listen()` can only be called once when running on Lambda') } listenCalled = true resolve(this) } }) export function handler (event, context, cb) { // Lambda will try and wait for the event loop to exhaust. In a web server // that typically won't happen because of connection pools to the database, // open connections to external services, etc. Tell Lambda to complete the // function anyways. context.callbackWaitsForEmptyEventLoop = false if (event.scandiumInvokeHook) { Promise.resolve() .then(() => import('./' + event.scandiumInvokeHook.file)) .then((hooks) => hooks[event.scandiumInvokeHook.hook]()) .then(() => cb(null), (err) => cb(err)) return } const socket = new LambdaSocket(event) const request = new LambdaRequest(socket, event) const response = new LambdaResponse(socket, request, cb) serverPromise.then((server) => server.emit('request', request, response)) } // Start the actual app await import('{{MAIN_FILE}}')