diff --git a/README.md b/README.md index de546b5..4f9ee1f 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,11 @@ Standard Options: --httpTimeout - Maximum time in ms to wait for an HTTP HEAD/GET request, default 0 - which results in using the OS default + Maximum time in ms to wait for an HTTP HEAD/GET request, default 60000 + + --socketTimeout + + Maximum time in ms to wait for an Socket connection establishment, default 60000. -i, --interval @@ -123,7 +126,7 @@ Standard Options: --tcpTimeout - Maximum time in ms for tcp connect, default 300ms + Maximum time in ms for tcp connect, default 60000 -v, --verbose diff --git a/index.d.ts b/index.d.ts index 565554e..f2815b7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,19 +1,40 @@ /// -import { AgentOptions as HTTPAgentOptions } from "node:http"; -import { AgentOptions as HTTPSAgentOptions } from "node:https"; +import { ProxyAgent } from 'undici'; + +type WaitOnCallback = (err?: Error, result: boolean) => unknown; declare function WaitOn( options: WaitOnOptions, - cb?: WaitOnCallback -): Promise | void; + cb: WaitOnCallback +): Promise; -type WaitOnCallback = (err?: Error) => unknown; +declare function WaitOn(options: WaitOnOptions): Promise; -type WaitOnProxyConfig = { - host?: string; - protocol?: string; - auth?: WaitOnOptions["auth"]; -}; +type WaitOnProxyConfig = ProxyAgent.Options; + +/** + * @description Invoked when an unsuccessful response is received from resource + */ +type WaitOnEventHandler = ( + resource: WaitOnResourcesType, + response: string +) => void; +/** + * @description invoked when an invalid resource is encountered + * @note It won't be invoked if the 'throwOnInvalidResource' option is on + */ +type WaitOnInvalidResourceEventHandler = ( + resource: WaitOnResourcesType +) => void; +/** + * @description Invoked when the resource becomes available and stable + */ +type WaitOnDoneEventHandler = (resource: WaitOnResourcesType) => void; +/** + * @description Invoked when an unexpected error or a timed out waiting for the resource + * occurs + */ +type WaitOnErrorHandler = (resource: WaitOnResourcesType, error: Error) => void; type WaitOnResourcesType = | `file:${string}` @@ -28,26 +49,37 @@ type WaitOnValidateStatusCallback = (status: number) => boolean; type WaitOnOptions = { resources: WaitOnResourcesType[]; + throwOnInvalidResource?: boolean; delay?: number; interval?: number; - log?: boolean; + timeout?: number; reverse?: boolean; simultaneous?: number; - timeout?: number; - tcpTimeout?: number; - verbose?: boolean; + http?: { + bodyTimeout?: number; + headersTimeout?: number; + maxRedirects?: number; + followRedirect?: boolean; + headers?: Record; + validateStatus?: WaitOnValidateStatusCallback + }; + socket?: { + timeout?: number; + }; + tcp?: { + timeout?: number; + }; window?: number; - passphrase?: string; - proxy?: boolean | WaitOnProxyConfig; - auth?: { - user: string; - pass: string; + proxy?: WaitOnProxyConfig; + events?: { + onInvalidResource?: WaitOnInvalidResourceEventHandler; + onResourceTimeout?: WaitOnErrorHandler; + onResourceError?: WaitOnErrorHandler; + onResourceResponse?: WaitOnEventHandler; + onResourceDone?: WaitOnDoneEventHandler; }; - headers?: Record; validateStatus?: WaitOnValidateStatusCallback; - strictSSL?: boolean; -} & HTTPAgentOptions & - HTTPSAgentOptions; +}; export default WaitOn; export { diff --git a/index.js b/index.js index 33034e6..f0014db 100644 --- a/index.js +++ b/index.js @@ -7,8 +7,8 @@ const { join, isAbsolute } = require('node:path') const { AsyncPool } = require('@metcoder95/tiny-pool') const { - validateHooks, validateOptions, + validateHooks, parseAjvError, parseAjvErrors } = require('./lib/validate') @@ -17,41 +17,12 @@ const { createTCPResource } = require('./lib/tcp') const { createSocketResource } = require('./lib/socket') const { createFileResource } = require('./lib/file') -/** - Waits for resources to become available before calling callback - - Polls file, http(s), tcp ports, sockets for availability. - - Resource types are distinquished by their prefix with default being `file:` - - file:/path/to/file - waits for file to be available and size to stabilize - - http://foo.com:8000/bar verifies HTTP HEAD request returns 2XX - - https://my.bar.com/cat verifies HTTPS HEAD request returns 2XX - - http-get: - HTTP GET returns 2XX response. ex: http://m.com:90/foo - - https-get: - HTTPS GET returns 2XX response. ex: https://my/bar - - tcp:my.server.com:3000 verifies a service is listening on port - - socket:/path/sock verifies a service is listening on (UDS) socket - For http over socket, use http://unix:SOCK_PATH:URL_PATH - like http://unix:/path/to/sock:/foo/bar or - http-get://unix:/path/to/sock:/foo/bar - - @param opts object configuring waitOn - @param opts.resources array of string resources to wait for. prefix determines the type of resource with the default type of `file:` - @param opts.delay integer - optional initial delay in ms, default 0 - @param opts.httpTimeout integer - optional http HEAD/GET timeout to wait for request, default 0 - @param opts.interval integer - optional poll resource interval in ms, default 250ms - @param opts.log boolean - optional flag to turn on logging to stdout - @param opts.reverse boolean - optional flag which reverses the mode, succeeds when resources are not available - @param opts.simultaneous integer - optional limit of concurrent connections to a resource, default Infinity - @param opts.tcpTimeout - Maximum time in ms for tcp connect, default 300ms - @param opts.timeout integer - optional timeout in ms, default Infinity. Aborts with error. - @param opts.verbose boolean - optional flag to turn on debug log - @param opts.window integer - optional stabilization time in ms, default 750ms. Waits this amount of time for file sizes to stabilize or other resource availability to remain unchanged. If less than interval then will be reset to interval - @param cb optional callback function with signature cb(err) - if err is provided then, resource checks did not succeed - if not specified, wait-on will return a promise that will be rejected if resource checks did not succeed or resolved otherwise - */ +// Main function function WaitOn (opts, cb) { if (cb != null && cb.constructor.name === 'Function') { - waitOnImpl(opts).then(cb, cb) + waitOnImpl(opts).then(result => { + cb(null, result) + }, cb) } else { return waitOnImpl(opts) } @@ -82,6 +53,7 @@ async function waitOnImpl (opts) { const { resources: incomingResources, + throwOnInvalidResource, timeout, simultaneous, events @@ -99,6 +71,10 @@ async function waitOnImpl (opts) { } if (invalidResources.length > 0 && events?.onInvalidResource != null) { + if (throwOnInvalidResource) { + throw new Error(`Invalid resources: ${invalidResources.join(', ')}`) + } + for (const resource of invalidResources) { events.onInvalidResource(resource) } @@ -223,7 +199,8 @@ function handleResponse ({ resource, pool, signal, waitOnOptions, state }) { return timerPromise .then(() => pool.run(resource.exec.bind(null, signal))) - .then(onResponse, onError).catch(onError) + .then(onResponse, onError) + .catch(onError) } function onError (err) { diff --git a/lib/file.js b/lib/file.js index 2ae46cd..bf6fdfd 100644 --- a/lib/file.js +++ b/lib/file.js @@ -13,7 +13,7 @@ function createFileResource (_, resource) { name: href } - async function exec (signal) { + async function exec () { const operation = { successfull: false, reason: 'unknown' diff --git a/lib/http.js b/lib/http.js index 78981a6..1a2cb81 100644 --- a/lib/http.js +++ b/lib/http.js @@ -4,21 +4,24 @@ const { Agent, ProxyAgent, request } = require('undici') const HTTP_GET_RE = /^https?-get:/ const HTTP_UNIX_RE = /^http:\/\/unix:([^:]+):([^:]+)$/ -function getHTTPAgent (config, resource) { +function getHTTPAgent (config, href) { const { - followRedirect, - maxRedirections, timeout, - http: { bodyTimeout, headersTimeout } = {}, - proxy, - strictSSL: rejectUnauthorized + http: { + bodyTimeout, + headersTimeout, + followRedirect, + maxRedirections, + rejectUnauthorized + } = {}, + proxy } = config const isProxied = proxy != null - const url = resource.replace('-get:', ':') // http://unix:/sock:/url - const matchHttpUnixSocket = HTTP_UNIX_RE.exec(url) + const matchHttpUnixSocket = HTTP_UNIX_RE.exec(href) const socketPath = matchHttpUnixSocket != null ? matchHttpUnixSocket[1] : null + /** @type {import('undici').Agent.Options} */ const httpOptions = { maxRedirections: followRedirect != null ? maxRedirections : 0, bodyTimeout, @@ -38,39 +41,102 @@ function getHTTPAgent (config, resource) { } function createHTTPResource (config, resource) { + const source = new URL(resource) const dispatcher = getHTTPAgent(config, resource) - const method = HTTP_GET_RE.test(resource) ? 'get' : 'head' - const url = resource.replace('-get:', ':') + /** @type { import('..').WaitOnOptions } */ + const { http: httpConfig } = config + const method = HTTP_GET_RE.test(resource) ? 'GET' : 'HEAD' + const href = source.href.replace('-get:', ':') + const isStatusValid = httpConfig?.validateStatus + // TODO: this will last as long as happy-eyeballs is not implemented + // within node core + /** @type {{ options: import('undici').Dispatcher.RequestOptions, url: URL }} */ + const primary = { + options: null, + url: null + } + /** @type {{ options?: import('undici').Dispatcher.RequestOptions, url?: URL }} */ + const secondary = { + options: null, + url: null + } + + if (href.includes('localhost')) { + primary.url = new URL(href.replace('localhost', '127.0.0.1')) + + secondary.url = new URL(href.replace('localhost', '[::1]')) + secondary.options = { + path: secondary.url.pathname, + origin: secondary.url.origin, + query: secondary.url.search, + method, + dispatcher, + signal: null, + headers: httpConfig?.headers + } + } else { + primary.url = new URL(href) + } + + primary.options = { + path: primary.url.pathname, + origin: primary.url.origin, + query: primary.url.search, + method, + dispatcher, + signal: null, + headers: httpConfig?.headers + } return { exec, name: resource } - async function exec (signal) { + async function exec ( + signal, + handler = primary, + handlerSecondary = secondary, + isSecondary = false + ) { const start = Date.now() const operation = { successfull: false, reason: 'unknown' } + handler.options.signal = signal + + if (handlerSecondary.options != null) { + handlerSecondary.options.signal = signal + } + try { - // TODO: implement small happy eyeballs algorithm for IPv4/IPv6 on localhost - // TODO: implement window tolerance feature - const { statusCode, body } = await request(url, { - method, - dispatcher, - signal - }) + const options = isSecondary ? handlerSecondary.options : handler.options + const { statusCode, body } = await request(options) const duration = Date.now() - start // We allow data to flow without worrying about it body.resume() - // TODO: add support allow range of status codes - operation.successfull = statusCode >= 200 && statusCode < 500 + operation.successfull = + isStatusValid != null + ? isStatusValid(statusCode) + : statusCode >= 200 && statusCode < 500 + + if ( + !operation.successfull && + !isSecondary && + handlerSecondary.url != null + ) { + return await exec(signal, handler, handlerSecondary, true) + } operation.reason = `HTTP(s) request for ${method}-${resource} replied with code ${statusCode} - duration ${duration}ms` } catch (e) { + if (!isSecondary && handlerSecondary.url != null) { + return await exec(signal, handler, handlerSecondary, true) + } + operation.reason = `HTTP(s) request for ${method}-${resource} errored: ${ e.message } - duration ${Date.now() - start}` diff --git a/lib/socket.js b/lib/socket.js index 5c19299..7968c82 100644 --- a/lib/socket.js +++ b/lib/socket.js @@ -1,11 +1,18 @@ 'use strict' -const net = require('node:net') +const { Socket } = require('node:net') const { join } = require('node:path') -// TODO: add config for socke timeout function createSocketResource (_, resource) { - const { href, host, pathname } = new URL(resource) + const { + href, + host, + pathname, + // Default to half of the overall timeout + socket: { timeout = 30000 } = { timeout: 30000 } + } = new URL(resource) const path = join(host, pathname) + /** @type {import('node:net').Socket} */ + let socket return { exec, @@ -22,17 +29,25 @@ function createSocketResource (_, resource) { res = resolve }) const start = Date.now() - const socket = net.connect({ - path, - signal - }) + + if (socket == null) { + socket = new Socket({ + signal + }) + + socket.setTimeout(timeout) + socket.setKeepAlive(false) + } socket.once('connect', () => { const duration = Date.now() - start operation.successfull = true operation.reason = `Socket connection established for ${href} - duration ${duration}ms` - socket.destroy() + if (!socket.destroyed) { + socket.destroy() + } + res(operation) }) @@ -40,7 +55,9 @@ function createSocketResource (_, resource) { const duration = Date.now() - start operation.reason = `Socket connection failed for ${href} - duration ${duration}ms - ${err.message}` - socket.destroy() + if (!socket.destroyed) { + socket.destroy() + } res(operation) }) @@ -48,10 +65,14 @@ function createSocketResource (_, resource) { const duration = Date.now() - start operation.reason = `Socket connection timed out for ${href} - duration ${duration}ms` - socket.destroy() + if (!socket.destroyed) { + socket.destroy() + } res(operation) }) + socket.connect(path) + return promise } } diff --git a/lib/tcp.js b/lib/tcp.js index 2e4e90f..5baa90b 100644 --- a/lib/tcp.js +++ b/lib/tcp.js @@ -2,7 +2,7 @@ const net = require('node:net') function createTCPResource (config, resource) { - const { tcpTimeout } = config + const { tcp: { timeout = 30000 } = { timeout: 30000 } } = config const { port, href, hostname: host } = new URL(resource) /** @type {import('node:net').Socket} */ let socket @@ -29,7 +29,7 @@ function createTCPResource (config, resource) { }) socket.setKeepAlive(false) - socket.setTimeout(tcpTimeout) + socket.setTimeout(timeout) } socket.on('error', err => { diff --git a/lib/validate.js b/lib/validate.js index fcd97a1..9074cf9 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -106,6 +106,36 @@ module.exports = { headersTimeout: { type: 'integer', minimum: 0 + }, + maxRedirects: { + type: 'integer', + minimum: 0, + default: 5 + }, + followRedirect: { + type: 'boolean', + default: true + }, + headers: { + type: 'object' + } + } + }, + socket: { + type: 'object', + properties: { + timeout: { + type: 'integer', + minimum: 0 + } + } + }, + tcp: { + type: 'object', + properties: { + timeout: { + type: 'integer', + minimum: 0 } } }, @@ -123,46 +153,10 @@ module.exports = { minimum: 1, defaults: 10 }, - tcpTimeout: { - type: 'integer', - minimum: 0, - default: 300 // 300ms - }, window: { type: 'integer', minimum: 0, - default: 0 - }, - passphrase: { - type: 'string' - }, - strictSSL: { - type: 'boolean', - default: false - }, - maxRedirects: { - type: 'integer', - minimum: 0, - default: 5 - }, - followRedirect: { - type: 'boolean', - default: true - }, - headers: { - type: 'object' - }, - auth: { - type: 'object', - required: ['username', 'password'], - properties: { - username: { - type: 'string' - }, - password: { - type: 'string' - } - } + default: 250 }, proxy: { type: 'object', @@ -173,6 +167,15 @@ module.exports = { }, token: { type: 'string' + }, + requestTLS: { + type: 'object' + }, + proxyTLs: { + type: 'object' + }, + requestTLs: { + type: 'object' } } }, diff --git a/package.json b/package.json index 36ef40f..d4b5bd5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "bin": { "wait-on": "./wait-on" }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, "author": "Jeff Barczewski ", "contributors": [ { diff --git a/test/http.test.js b/test/http.test.js index cbda1a5..c92cc2a 100644 --- a/test/http.test.js +++ b/test/http.test.js @@ -6,16 +6,18 @@ const { test } = require('tap') const waitOn = require('..') -test('Wait-On#HTTP', context => { - context.plan(4) +test('Wait-On#HTTP', { only: true }, context => { + context.plan(7) - context.test('Basic HTTP', t => { - const server = createServer((req, res) => { + context.test('Basic HTTP', async t => { + let called = false + const server = createServer((_req, res) => { + called = true res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Hello World') }) - t.plan(1) + t.plan(2) t.teardown(server.close.bind(server)) @@ -23,25 +25,31 @@ test('Wait-On#HTTP', context => { resources: ['http://localhost:3001'] }) - setTimeout(1500).then(() => { - server.listen(3001, async e => { - if (e != null) t.fail(e.message) + await setTimeout(1500) - const result = await waiting + await new Promise((resolve, reject) => { + server.listen(3001, async e => { + if (e != null) reject(e) - t.equal(result, true) + resolve() }) }) + + const result = await waiting + + t.ok(called) + t.equal(result, true) }) - context.test('Basic HTTP - with initial delay', t => { - const server = createServer((req, res) => { + context.test('Basic HTTP - with initial delay', async t => { + let called = false + const server = createServer((_req, res) => { + called = true res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Hello World') }) - t.plan(1) - + t.plan(2) t.teardown(server.close.bind(server)) const waiting = waitOn({ @@ -49,38 +57,173 @@ test('Wait-On#HTTP', context => { delay: 1000 }) - setTimeout(0).then(() => { + await new Promise((resolve, reject) => { server.listen(3002, async e => { - if (e != null) t.fail(e.message) + if (e != null) reject(e) - const result = await waiting - - t.equal(result, true) + resolve() }) }) + + const result = await waiting + + t.ok(called) + t.equal(result, true) }) - context.test('Basic HTTP - immediate connect', t => { - const server = createServer((req, res) => { + context.test( + 'Basic HTTP - with initial delay - with custom status code check', + async t => { + let called = false + let callbackCalled = 0 + const server = createServer((req, res) => { + if (!called) { + called = true + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + } else { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('Hello World') + // Called twice because happy-eyeballs + t.ok(called) + } + }) + + t.plan(4) + t.teardown(server.close.bind(server)) + + const waiting = waitOn({ + resources: ['http://localhost:3010'], + delay: 2000, + http: { + validateStatus: code => { + callbackCalled++ + return code === 200 + } + } + }) + + await setTimeout(500) + await new Promise((resolve, reject) => { + server.listen(3010, e => { + if (e != null) reject(e) + resolve() + }) + }) + + const result = await waiting + + t.equal(result, true) + t.equal(callbackCalled, 3) + } + ) + + context.test('Basic HTTP - immediate connect', async t => { + let called = false + const server = createServer((_req, res) => { + called = true res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Hello World') }) - t.plan(1) - + t.plan(2) t.teardown(server.close.bind(server)) - server.listen(3003, async e => { - if (e != null) t.fail(e.message) + await new Promise((resolve, reject) => { + server.listen(3003, async e => { + if (e != null) reject(e) - const result = await waitOn({ - resources: ['http://localhost:3003'] + resolve() }) + }) - t.equal(result, true) + const result = await waitOn({ + resources: ['http://localhost:3003'] }) + + t.ok(called) + t.equal(result, true) }) + context.test( + 'Basic HTTP - fallback to ipv6 if ipv4 not available on localhost', + async t => { + let ipv4Called = false + let ipv6Called = false + + const server4 = createServer((req, res) => { + ipv4Called = true + res.writeHead(500, { 'Content-Type': 'text/plain' }) + res.end('oops!') + }) + + const server6 = createServer((req, res) => { + ipv6Called = true + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('Hello World') + }) + + t.plan(3) + + t.teardown(server4.close.bind(server4)) + t.teardown(server6.close.bind(server6)) + + await new Promise((resolve, reject) => { + server4.listen({ host: '127.0.0.1', port: 3006 }, e => { + if (e != null) reject(e) + else resolve() + }) + }) + + await new Promise((resolve, reject) => { + server6.listen({ host: '::1', port: 3006 }, e => { + if (e != null) reject(e) + else resolve() + }) + }) + + const result = await waitOn({ + resources: ['http://localhost:3006'], + window: 0, + interval: 0 + }) + + t.equal(result, true) + t.ok(ipv4Called) + t.ok(ipv6Called) + } + ) + + context.test( + 'Basic HTTP - fallback to ipv4 if ipv6 not available on localhost', + async t => { + const server = createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('Hello World') + }) + + t.plan(1) + + t.teardown(server.close.bind(server)) + + const promise = waitOn({ + resources: ['http://localhost:3007'] + }) + + await setTimeout(1000) + + await new Promise((resolve, reject) => { + server.listen({ host: '::1', port: 3007 }, e => { + if (e != null) reject(e) + else resolve() + }) + }) + + const result = await promise + t.equal(result, true) + } + ) + context.test('Basic HTTP with timeout', async t => { t.plan(1) diff --git a/test/socket.test.js b/test/socket.test.js index a85e511..be83872 100644 --- a/test/socket.test.js +++ b/test/socket.test.js @@ -103,6 +103,9 @@ test('Wait-On#Socket', context => { const result = await waitOn({ resources: [`socket:${socketPath}`], + socket: { + timeout: 500 + }, timeout: 1000 }) diff --git a/test/tcp.test.js b/test/tcp.test.js index 780ea93..e895dc5 100644 --- a/test/tcp.test.js +++ b/test/tcp.test.js @@ -93,8 +93,10 @@ test('Wait-On#TCP', context => { const promise = waitOn({ resources: ['tcp://127.0.0.1:5030'], - timeout: 500, - interval: 1000 + tcp: { + timeout: 500 + }, + timeout: 1000 }) const result = await promise diff --git a/wait-on b/wait-on index 8fd9d67..94897f9 100755 --- a/wait-on +++ b/wait-on @@ -11,7 +11,7 @@ const { Signale } = require('signale') const waitOn = require('.') const minimistOpts = { - string: ['c', 'httpTimeout', 'tcpTimeout'], + string: ['c', 'config'], boolean: ['h', 'l', 'r', 'v'], alias: { c: 'config', @@ -29,6 +29,7 @@ const minimistOpts = { const waitOnOpts = [ 'delay', 'httpTimeout', + 'socketTimeout', 'interval', 'log', 'reverse', @@ -38,6 +39,11 @@ const waitOnOpts = [ 'verbose', 'window' ] +const waitOnOptsMap = { + httpTimeout: ['http.bodyTimeout', 'http.headersTimeout'], + socketTimeout: ['socket.timeout'], + tcpTimeout: ['tcp.timeout'] +} const logger = new Signale({ scope: 'wait-on' }) @@ -60,8 +66,24 @@ const logger = new Signale({ scope: 'wait-on' }) } for (const optName of waitOnOpts) { - if (argv[optName] != null) { - configOpts[optName] = argv[optName] + const optValue = argv[optName] + const hasMapping = waitOnOptsMap[optName] != null + + if (optValue != null && !hasMapping) { + configOpts[optName] = optValue + } else if (hasMapping && optValue != null) { + const mappings = waitOnOptsMap[optName] + + for (const mapping of mappings) { + // http.bodyTimeout => [http, bodyTimeout] + const [section, name] = mapping.split('.') + + if (configOpts[section] == null) { + configOpts[section] = { [name]: optValue } + } else { + configOpts[section][name] = optValue + } + } } }