import assert from "node:assert/strict"; import fs from "node:fs"; import http from "node:http"; import process from "node:process"; import * as runner from "../runner.mjs"; import * as u from "../../src/utils.mjs"; import { loggerDefaults, loggerGlobals, configLogger, logit, makeLogger, isValidMethod, isValidUpgrade, isValidKey, isValidVersion, validateUpgrade, computeHash, interceptorsFn, interceptors, defaultInterceptors, chainInterceptors, wrapHandler, normalizeSegments, pathToSegments, hasPathParams, isValidLabel, comboForLabel, addRoute, findStaticHandler, firstParamMatch, findDynamicHandler, findHandler, extractQueryParams, renderStatus, renderHeaders, buildHeader, writeHead, handle404, make404Handler, handleRequest, makeUpgradeListener, makeRequestListener, actionsFn, lineHandlerFn, rmIf, mkfifo, makeLineEmitter, makeReopeningPipeReader, makePipeReaderFn, buildRoutes, buildTable, promisifyServer, buildServer, } from "../../src/hero.mjs"; const test_configLogger = async t => { t.start("configLogger()"); await t.test("globals starts empty", () => { assert.deepEqual(loggerGlobals, {}); }); await t.test("is gets becomes we assign it", () => { const globals = { app: "my-app", color: "green", version: "deadbeef", }; configLogger(globals); assert.deepEqual(loggerGlobals, globals); }); await t.test("we can reset it", () => { configLogger({}); assert.deepEqual(loggerGlobals, {}); }); }; const test_logit = async t => { t.start("logit()"); await t.test("we can log data", () => { configLogger({ app: "hero-based app" }); const contents = []; const writerFn = x => contents.push(x); let i = 0; const timestampFn = () => `${i++}`; logit(writerFn, timestampFn, "my level", { a: 1, type: "log-test" }); assert.deepEqual(contents.map(JSON.parse), [{ ...loggerDefaults, app: "hero-based app", level: "my level", timestamp: "0", a: 1, type: "log-test", }]); /// reset the logger out of the values specific to this test configLogger({}); }); await t.test("the object can change previous fallback values", () => { const contents = []; const writerFn = x => contents.push(x); let i = 0; const timestampFn = () => `${i++}`; configLogger({ level: "unseen", a: "unseen", }); logit(writerFn, timestampFn, "overwritten by level", { a: "overwritten by o" }); configLogger({ pid: "overwritten by loggerGlobals", }); logit(writerFn, timestampFn, "unseen", { level: "overwritten by o" }); /// reset the logger out of the values specific to this test configLogger({}); assert.deepEqual(contents.map(JSON.parse), [ { ...loggerDefaults, pid: process.pid, level: "overwritten by level", timestamp: "0", a: "overwritten by o", }, { ...loggerDefaults, pid: "overwritten by loggerGlobals", level: "overwritten by o", timestamp: "1", }, ]); }); await t.test("we can't log unserializable things", () => { const obj = { self: null }; obj.self = obj; assert.throws(() => logit(obj), TypeError); }); }; const test_makeLogger = async t => { t.start("makeLogger()"); await t.test("various log levels", () => { const contents = []; const writerFn = x => contents.push(x); let i = 0; const timestampFn = () => `${i++}`; const log = makeLogger({ writerFn, timestampFn }); log.info ({ type: "expected" }); log.warn ({ type: "worrysome" }); log.error({ type: "bad" }); assert.deepEqual(contents.map(JSON.parse), [ { ...loggerDefaults, level: "INFO", timestamp: "0", type: "expected", }, { ...loggerDefaults, level: "WARN", timestamp: "1", type: "expected", type: "worrysome", }, { ...loggerDefaults, level: "ERROR", timestamp: "2", type: "expected", type: "bad", }, ]); }); await t.test("debug only works when $DEBUG is set", () => { const contents = []; const writerFn = x => contents.push(x); let i = 0; const timestampFn = () => `${i++}`; const log = makeLogger({ writerFn, timestampFn }); const previous = process.env.DEBUG; delete process.env.DEBUG; log.debug({ x: "ignored" }); process.env.DEBUG = "1"; log.debug({ x: "seen" }); delete process.env.DEBUG; /// call the function that toggles log.debug({ x: "ignored" }); assert.deepEqual(contents.map(JSON.parse), [{ ...loggerDefaults, level: "DEBUG", timestamp: "0", x: "seen", }]); process.env.DEBUG = previous; }); }; const test_isValidMethod = async t => { t.start("isValidMethod()"); await t.test("we only accept a single value", () => { assert.ok(isValidMethod("GET")); assert.ok(!isValidMethod("get")); assert.ok(!isValidMethod("PUT")); }) }; const test_isValidUpgrade = async t => { t.start("isValidUpgrade()"); await t.test("we ignore the case", () => { assert.ok(isValidUpgrade("Websocket")); assert.ok(isValidUpgrade("WebSocket")); assert.ok(isValidUpgrade("websocket")); assert.ok(!isValidUpgrade("web socket")); }); }; const test_isValidKey = async t => { t.start("isValidKey()"); await t.test("RFC example value", () => { const key = "dGhlIHNhbXBsZSBub25jZQ=="; assert.ok(isValidKey(key)); }); await t.test("wrong example values", () => { const key1 = "_GhlIHNhbXBsZSBub25jZQ=="; const key2 = "dGhlIHNhbXBsZSBub25jZQ="; assert.ok(!isValidKey(key1)); assert.ok(!isValidKey(key2)); }); }; const test_isValidVersion = async t => { t.start("isValidVersion()"); await t.test("we only accept a single value", () => { assert.ok(isValidVersion(13)); assert.ok(!isValidVersion(9)); assert.ok(!isValidVersion(10)); assert.ok(!isValidVersion(11)); assert.ok(!isValidVersion(12)); }); }; const test_validateUpgrade = async t => { t.start("validateUpgrade()"); await t.test("invalid method", () => { assert.deepEqual(validateUpgrade("POST", {}), { isValid: false, response: { status: 405, }, }); }); await t.test("missing upgrade", () => { assert.deepEqual(validateUpgrade("GET", {}), { isValid: false, response: { status: 400, body: 'Missing "Upgrade" header\n', }, }); }); await t.test("invalid upgrade", () => { assert.deepEqual(validateUpgrade("GET", { "upgrade": "web socket", }), { isValid: false, response: { status: 400, body: 'Invalid "Upgrade" value\n', }, }); }); await t.test("missing sec-websocket-key", () => { assert.deepEqual(validateUpgrade("GET", { "upgrade": "websocket", }), { isValid: false, response: { status: 400, body: 'Missing "Sec-WebSocket-Key" header\n', }, }); }); await t.test("invalid sec-websocket-key", () => { assert.deepEqual(validateUpgrade("GET", { "upgrade": "websocket", "sec-websocket-key": "bad value", }), { isValid: false, response: { status: 400, body: 'Invalid "Sec-WebSocket-Key" value\n', }, }); }); await t.test("missing sec-websocket-version", () => { assert.deepEqual(validateUpgrade("GET", { "upgrade": "websocket", "sec-websocket-key": "aaaaabbbbbcccccdddddee==", }), { isValid: false, response: { status: 400, body: 'Missing "Sec-WebSocket-Version" header\n', }, }); }); await t.test("invalid sec-websocket-version", () => { assert.deepEqual(validateUpgrade("GET", { "upgrade": "websocket", "sec-websocket-key": "aaaaabbbbbcccccdddddee==", "sec-websocket-version": "12", }), { isValid: false, response: { status: 400, body: 'Invalid "Sec-WebSocket-Version" value\n', }, }); }); await t.test("valid upgrade", () => { assert.deepEqual(validateUpgrade("GET", { "upgrade": "websocket", "sec-websocket-key": "aaaaabbbbbcccccdddddee==", "sec-websocket-version": "13", }), { isValid: true, }); }); }; const test_computeHash = async t => { t.start("computeHash()"); await t.test("RFC example value", () => { const key = "dGhlIHNhbXBsZSBub25jZQ=="; const hash = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; assert.equal(computeHash(key), hash); }); await t.test("a key used in other tests", () => { const key = "aaaaabbbbbcccccdddddee=="; const hash = "eHnDP9gUz224y002aFCe7swigxg="; assert.equal(computeHash(key), hash); }); }; const test_interceptorsFn = async t => { const next = x => ({ ...x, nextCalled: true }); { t.start("interceptorsFn().requestId()"); let i = 0; const uuidFn = () => `${i++}`; await t.test("we add an id to whatever we receive", () => { assert.deepEqual( interceptorsFn({uuidFn}).requestId({}, next), { id: "0", nextCalled: true, }, ); assert.deepEqual( interceptorsFn({uuidFn}).requestId({ a: "existing data" }, next), { a: "existing data", id: "1", nextCalled: true, }, ); }); await t.test(`we overwrite the "id" if it already exists`, async () => { assert.deepEqual( interceptorsFn({uuidFn}).requestId({ id: "before" }, next), { id: "2", nextCalled: true, }, ); }); }; { t.start("interceptorsFn().logged()"); await t.test("we log before and after next()", async () => { const contents = []; const logger = { info: x => contents.push(x) }; const status = 201; const req = { id: "an ID", url: "a URL", method: "a method", upgrade: true, }; assert.deepEqual( await interceptorsFn({logger}).logged(req, _ => ({ status })), { status }, ); assert.deepEqual( contents.map(o => u.dissoc(o, "timings")), [ { ...req, type: "in-request" }, { id: req.id, status, type: "in-response" }, ], ); assert.equal(typeof contents[1].timings.ms.before, "number"); assert.equal(typeof contents[1].timings.ms.after, "number"); assert.equal(typeof contents[1].timings.ms.duration, "number"); }); }; { t.start("interceptorsFn().contentType()"); await t.test("empty values", async () => { await assert.rejects( async () => await interceptorsFn().contentType({}, next), assert.AssertionError, ); assert.deepEqual( await interceptorsFn().contentType({ status: 202, }, next), { status: 202, body: "Accepted\n", headers: { "Content-Type": "text/plain", "Content-Length": 9, }, nextCalled: true, }, ); assert.deepEqual( await interceptorsFn().contentType({ status: 200, body: "", }, next), { status: 200, body: "", headers: { "Content-Type": "text/html", "Content-Length": 0, }, nextCalled: true, }, ); }); await t.test("body values", async () => { assert.deepEqual( await interceptorsFn().contentType({ status: 201, body: { a: 1 }, }, next), { status: 201, body: `{"a":1}`, headers: { "Content-Type": "application/json", "Content-Length": 7, }, nextCalled: true, }, ); assert.deepEqual( await interceptorsFn().contentType({ status: 200, body: "
", }, next), { status: 200, body: "
", headers: { "Content-Type": "text/html", "Content-Length": 6, }, nextCalled: true, }, ); }); await t.test("header values preference", async () => { assert.deepEqual( await interceptorsFn().contentType({ status: 503, body: "", headers: { "Content-Type": "we have preference", "Content-Length": "and so do we", }, }, next), { status: 503, body: "", headers: { "Content-Type": "we have preference", "Content-Length": "and so do we", }, nextCalled: true, }, ); }); await t.test("headers get propagated", async () => { assert.deepEqual( await interceptorsFn().contentType({ status: 500, body: "", headers: { "My": "Header", }, }, next), { status: 500, body: "", headers: { "My": "Header", "Content-Type": "text/html", "Content-Length": 0, }, nextCalled: true, }, ); }); }; { t.start("interceptorsFn().serverError()"); await t.test("no-op when no error occurs", async () => { assert.deepEqual( await interceptorsFn().serverError({ status: 1 }, next), { status: 1, nextCalled: true, }, ); }); await t.test(`an error is thrown if "status" is missing`, async () => { const contents = []; const logger = { error: x => contents.push(x) }; assert.deepEqual( await interceptorsFn({ logger }).serverError({ id: 123 }, next), { status: 500, body: "Internal Server Error\n", }, ); assert.deepEqual( contents.map(o => ({ ...o, stacktrace: typeof o.stacktrace })), [{ id: 123, type: "server-error-interceptor", message: `Missing "status"`, stacktrace: "string", }], ); }); await t.test("we turn a handler error into a 500 response", async () => { const contents = []; const logger = { error: x => contents.push(x) }; assert.deepEqual( await interceptorsFn({ logger }).serverError( { id: "some ID" }, _ => { throw new Error("My test error message"); }, ), { status: 500, body: "Internal Server Error\n", }, ); assert.deepEqual( contents.map(o => ({ ...o, stacktrace: typeof o.stacktrace })), [{ id: "some ID", type: "server-error-interceptor", message: "My test error message", stacktrace: "string", }], ); }); }; { t.start("interceptorsFn().websocketHandshake()"); await t.test("no-op when not an upgrade request", async () => { assert.deepEqual( await interceptorsFn().websocketHandshake({ upgrade: false, }, next), { upgrade: false, nextCalled: true, }, ); }); await t.test("when invalid we forward what validateUpgrade() says", async () => { assert.deepEqual( await interceptorsFn().websocketHandshake({ upgrade: true, method: "GET", headers: {}, }, next), { status: 400, body: 'Missing "Upgrade" header\n', }, ); }); await t.test("otherwise we upgrade the connection", async () => { assert.deepEqual( await interceptorsFn().websocketHandshake({ upgrade: true, method: "GET", headers: { "upgrade": "websocket", "sec-websocket-key": "aaaaabbbbbcccccdddddee==", "sec-websocket-version": "13", }, }, next), { status: 101, headers: { "Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Accept": "eHnDP9gUz224y002aFCe7swigxg=", }, }, ); }); }; }; const test_chainInterceptors = async t => { t.start("chainInterceptors()"); await t.test("empty values", () => { assert.equal(chainInterceptors([])("req"), "req"); }); await t.test("the order of interceptors matter", () => { const a = []; const i1 = (req, next) => { a.push("i1"); return next(req); }; const i2 = (req, next) => { a.push("i2"); return next(req); }; assert.equal(chainInterceptors([i1, i2])("req"), "req"); assert.deepEqual(a, ["i1", "i2"]); assert.equal(chainInterceptors([i2, i1])("req"), "req"); assert.deepEqual(a, ["i1", "i2", "i2", "i1"]); }); await t.test("with ordering, interceptors implicitly depend on each other", () => { const i1 = (req, next) => next({ ...req, id: 1 }); const i2 = (req, next) => next({ ...req, id: req.id + 1 }); assert.deepEqual( chainInterceptors([i1, i2])({}), { id: 2 }, ); assert.deepEqual( chainInterceptors([i2])({}), { id: NaN }, ); assert.deepEqual( chainInterceptors([i2, i1])({}), { id: 1 }, ); }); await t.test("we can chain async interceptors", async () => { const i1 = async (req, next) => await next({ ...req, i1: true }); const i2 = async (req, next) => await next({ ...req, i2: true }); assert.deepEqual( await chainInterceptors([i1, i2])({}), { i1: true, i2: true, }, ); }); }; const test_wrapHandler = async t => { t.start("wrapHandler()"); await t.test("noop when arr is empty", () => { const fn = () => {}; const wrappedFn1 = wrapHandler(fn, []); assert.deepEqual(fn, wrappedFn1); const wrappedFn2 = wrapHandler(fn, [ interceptors.requestId ]); assert.notDeepEqual(fn, wrappedFn2); }); await t.test("a handler with chained interceptors change its behaviour", async () => { let i = 0; const uuidFn = () => `${i++}`; const contents = []; const logger = { info: x => contents.push(x) }; const interceptors = interceptorsFn({uuidFn, logger}); const fn = async _ => await { status: 1, body: { a: 1 }}; const wrappedFn = wrapHandler(fn, [ interceptors.requestId, interceptors.logged, interceptors.contentType, ]); const req = { url: "URL", method: "METHOD", upgrade: false, }; assert.deepEqual( await fn(req), { status: 1, body: { a: 1 }}, ); assert.deepEqual(contents, []); assert.deepEqual( await wrappedFn(req), { status: 1, body: `{"a":1}`, headers: { "Content-Type": "application/json", "Content-Length": 7, }, }, ); assert.deepEqual( contents.map(o => u.dissoc(o, "timings")), [ { id: "0", url: "URL", method: "METHOD", type: "in-request", upgrade: false, }, { id: "0", status: 1, type: "in-response", }, ], ); }); }; const test_normalizeSegments = async t => { t.start("normalizeSegments()"); await t.test("unchanged when already normalized", () => { assert.deepEqual(normalizeSegments([""]), [""]); }); await t.test("empty terminator added when missing", () => { assert.deepEqual(normalizeSegments([]), [""]); assert.deepEqual(normalizeSegments([" "]), [" ", ""]); assert.deepEqual(normalizeSegments(["_"]), ["_", ""]); assert.deepEqual(normalizeSegments(["a"]), ["a", ""]); assert.deepEqual(normalizeSegments([":a"]), [":a", ""]); assert.deepEqual(normalizeSegments(["path"]), ["path", ""]); }); }; const test_pathToSegments = async t => { t.start("pathToSegments()"); await t.test("simple paths", () => { assert.deepEqual(pathToSegments("/"), [ "" ]); assert.deepEqual(pathToSegments("/simple"), ["simple", ""]); assert.deepEqual(pathToSegments("/simple/route"), ["simple", "route", ""]); assert.deepEqual(pathToSegments("/api/user/:id"), ["api", "user", ":id", ""]); assert.deepEqual(pathToSegments("/api/user/:id/info"), ["api", "user", ":id", "info", ""]); }); await t.test("extra separators", () => { assert.deepEqual(pathToSegments("/api/health/"), ["api", "health", ""]); assert.deepEqual(pathToSegments("/api///health"), ["api", "health", ""]); assert.deepEqual(pathToSegments("//api///health//"), ["api", "health", ""]); }); }; const test_hasPathParams = async t => { t.start("hasPathParam()"); await t.test("has it", () => { assert(hasPathParams(["some", ":path", ""])); assert(hasPathParams(["path", ":params", ""])); assert(hasPathParams(["api", "user", ":id", "info", ""])); }); await t.test("doesn't have it", () => { assert(!hasPathParams([])); assert(!hasPathParams([ "" ])); assert(!hasPathParams(["some", "path", ""])); assert(!hasPathParams(["s:o:m:e:", "p::ath:::"])); assert(!hasPathParams([" :"])); }); }; const test_isValidLabel = async t => { t.start("isValidLabel()"); await t.test("typo examples", () => { assert.ok(!isValidLabel("get")); assert.ok(!isValidLabel("WebSocket")); assert.ok(!isValidLabel("WEBSOCKETS")); assert.ok(!isValidLabel("ws")); }); await t.test("valid usages", () => { assert.ok(isValidLabel("GET")); assert.ok(isValidLabel("PUT")); assert.ok(isValidLabel("WEBSOCKET")); }); }; const test_comboForLabel = async t => { t.start("comboForLabel()"); await t.test("websocket gets its own combo", () => { assert.deepEqual( comboForLabel("WEBSOCKET", "IGNORED"), [ "websocket", "GET" ], ); }); await t.test("otherwise we get what pass", () => { assert.deepEqual( comboForLabel("not-websocket", "a-keyword"), [ "a-keyword", "not-websocket" ], ); }); }; const test_addRoute = async t => { t.start("addRoute()"); const fn1 = () => {}; await t.test("daily usage examples", () => { assert.deepEqual( addRoute({}, "GET", "/home", fn1), { static: { GET: { home: { "": fn1 }}}}, ); assert.deepEqual( addRoute({}, ["PUT", "POST", "PATCH"], "/api/user", fn1), { static: { PUT: { api: { user: { "": fn1 }}}, POST: { api: { user: { "": fn1 }}}, PATCH: { api: { user: { "": fn1 }}}, }, }, ); assert.deepEqual( addRoute({}, "*", "/settings", fn1), { static: { GET: { settings: { "": fn1 }}, HEAD: { settings: { "": fn1 }}, POST: { settings: { "": fn1 }}, PUT: { settings: { "": fn1 }}, PATCH: { settings: { "": fn1 }}, DELETE: { settings: { "": fn1 }}, OPTIONS: { settings: { "": fn1 }}, }, }, ); assert.deepEqual( addRoute({}, "GET", "/", fn1), { static: { GET: { "": fn1 }}}, ); assert.deepEqual( addRoute({}, "GET", "/api/user/:id", fn1), { dynamic: { GET: { api: { user: { ":id": { "": fn1 }}}}}}, ); assert.deepEqual( addRoute({}, "WEBSOCKET", "/socket", fn1), { websocket: { GET: { socket: { "": fn1 }}}}, ); assert.deepEqual( addRoute({}, ["PUT", "PATCH"], "/api/org/:orgid/member/:memberid", fn1), { dynamic: { PUT: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, PATCH: { api: { org: { ":orgid": { member: { ":memberid": { "": fn1 }}}}}}, }, }, ); }); await t.test("bad method", () => { assert.throws( () => addRoute({}, "VERB", "/path", fn1), assert.AssertionError, ); }); await t.test("empty methods array", () => { assert.deepEqual(addRoute({}, [], "", fn1), {}); }); await t.test("subpaths", () => { const fn1 = () => {}; const fn2 = () => {}; const t1 = addRoute({}, "GET", "/home", fn1); const t2 = addRoute(t1, "GET", "/home/details", fn2); assert.deepEqual( t2, { static: { GET: { home: { "": fn1, details: { "": fn2, }, }, }, }, }, ); }); }; const test_findStaticHandler = async t => { t.start("findStaticHandler()"); await t.test("multiple accesses to the same table", () => { const fn1 = () => {}; const fn2 = () => {}; const fn3 = () => {}; const fn4 = () => {}; const table = { static: { GET: { api: { home: { "": fn1 }, settings: { "": fn2 }, }, }, POST: { api: { settings: { "": fn3 }, }, }, }, websocket: { GET: { api: { socket: { "": fn4 }, }, }, } }; assert.deepEqual( findStaticHandler(table, "GET", [ "api", "home", "" ], "static"), { handlerFn: fn1, params: {} }, ); assert.deepEqual( findStaticHandler(table, "PUT", [ "api", "home", "" ], "static"), null, ); assert.deepEqual( findStaticHandler(table, "GET", [ "api", "settings", "" ], "static"), { handlerFn: fn2, params: {} }, ); assert.deepEqual( findStaticHandler(table, "POST", [ "api", "settings", "" ], "static"), { handlerFn: fn3, params: {} }, ); assert.deepEqual( findStaticHandler(table, "PUT", [ "api", "settings", "" ], "static"), null, ); assert.deepEqual( findStaticHandler(table, "GET", [ "api", "profile", "" ], "static"), null, ); assert.deepEqual( findStaticHandler({}, "GET", [ "api", "profile", "" ], "static"), null, ); assert.deepEqual( findStaticHandler(table, "GET", [ "api", "socket", "" ], "static"), null, ); assert.deepEqual( findStaticHandler(table, "GET", [ "api", "socket", "" ], "websocket"), { handlerFn: fn4, params: {} }, ); }); }; const test_firstParamMatch = async t => { t.start("firstParamMatch()"); const params = {}; const fn1 = () => {}; const fn2 = () => {}; await t.test("we BACKTRACK when searching down routes", () => { const segments = [ "path", "split", "match", "" ]; const tree = { path: { split: { MISMATCH: { "": fn1, }, }, ":param": { match: { "": fn2, }, }, }, }; assert.deepEqual( firstParamMatch(tree, segments, params), { handlerFn: fn2, params: { param: "split" }}, ); }); await t.test("ambiguous route prefers params at the end", () => { const segments = [ "path", "param1", "param2", "" ]; const tree1 = { path: { ":shallower": { param2: { "": fn2, }, }, }, }; const tree2 = u.assocIn(tree1, [ "path", "param1", ":deeper", "" ], fn1); assert.deepEqual( firstParamMatch(tree1, segments, params), { handlerFn: fn2, params: { shallower: "param1" }}, ); assert.deepEqual( firstParamMatch(tree2, segments, params), { handlerFn: fn1, params: { deeper: "param2" }}, ); }); await t.test("when 2 params are possible, we pick the first alphabetically", () => { const segments = [ "user", "someId", "" ]; const tree1 = { user: { ":bbb": { "": fn2, }, }, }; const tree2 = u.assocIn(tree1, ["user", ":aaa", ""], fn1); assert.deepEqual( firstParamMatch(tree1, segments, params), { handlerFn: fn2, params: { bbb: "someId" }}, ); assert.deepEqual( firstParamMatch(tree2, segments, params), { handlerFn: fn1, params: { aaa: "someId" }}, ); }); await t.test(`we deal with segments that start with ":"`, () => { const segments = [ "path", ":param", "" ]; const tree = { path: { ":arg": { "": fn1, }, }, }; assert.deepEqual( firstParamMatch(tree, segments, params), { handlerFn: fn1, params: { arg: ":param" }}, ); }); }; const test_findDynamicHandler = async t => { t.start("findDynamicHandler()"); await t.test("daily usage cases", () => { const fn1 = () => {}; const fn2 = () => {}; const table = { dynamic: { GET: { users: { "by-id": { ":id": { "": fn1 }, }, }, }, PUT: { user: { ":user-id": { info: { "": fn2 }, }, }, }, }, }; assert.deepEqual( findDynamicHandler(table, "GET", [ "users", "by-id", "123", "" ]), { handlerFn: fn1, params: { "id": "123" }}, ); assert.deepEqual( findDynamicHandler({}, "GET", [ "users", "by-id", "123", "" ]), null, ); assert.deepEqual( findDynamicHandler({ dynamic: { GET: { "": fn1 }}}, "GET", [ "users", "by-id", "123", "" ]), null, ); assert.deepEqual( findDynamicHandler(table, "PUT", [ "user", "deadbeef", "info", "" ]), { handlerFn: fn2, params: { "user-id": "deadbeef" }}, ); }); }; const test_findHandler = async t => { t.start("findHandler()"); await t.test("mix of static, dynamic and websocket routes", () => { const static1 = () => {}; const static2 = () => {}; const static3 = () => {}; const dynamic1 = () => {}; const dynamic2 = () => {}; const dynamic3 = () => {}; const dynamic4 = () => {}; const websocket1 = () => {}; const websocket2 = () => {}; const table = { static: { GET: { user: { "": static1, }, pages: { "": static2, home: { "": static3, }, }, }, }, dynamic: { GET: { user: { ":id": { "": dynamic1, }, }, }, PUT: { user: { ":id": { "": dynamic2, "info": { "": dynamic3, }, "preferences": { "": dynamic4, }, }, }, }, }, websocket: { GET: { user: { "": websocket1, socket: { "": websocket2, }, }, }, }, }; assert.deepEqual( findHandler(table, "GET", "/", false), null, ); assert.deepEqual( findHandler(table, "GET", "/user/", false), { handlerFn: static1, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/user/", true), { handlerFn: websocket1, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/pages", false), { handlerFn: static2, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/pages/home/", false), { handlerFn: static3, params: {} }, ); assert.deepEqual( findHandler(table, "GET", "/user/some-id", false), { handlerFn: dynamic1, params: { id: "some-id" }}, ); assert.deepEqual( findHandler(table, "GET", "/user/other-id/info", false), null, ); assert.deepEqual( findHandler(table, "PUT", "/user/other-id/info", false), { handlerFn: dynamic3, params: { id: "other-id" }}, ); assert.deepEqual( findHandler(table, "PUT", "/user/another-id/preferences", false), { handlerFn: dynamic4, params: { id: "another-id" }}, ); assert.deepEqual( findHandler(table, "POST", "/user/another-id/preferences", false), null, ); assert.deepEqual( findHandler(table, "GET", "/user/socket", true), { handlerFn: websocket2, params: {} }, ); }); }; const test_extractQueryParams = async t => { t.start("extractQueryParams()"); await t.test("empty values", () => { assert.deepEqual(extractQueryParams(), {}); assert.deepEqual(extractQueryParams(null), {}); assert.deepEqual(extractQueryParams(undefined), {}); }); await t.test("we get a flat key-value strings", () => { assert.deepEqual( extractQueryParams("a[]=1&b=text&c=___"), { "a[]": "1", b: "text", c: "___", }, ); }); await t.test("duplicate values are suppressed, deterministically", () => { assert.deepEqual(extractQueryParams("a=1&a=2&a=3"), { a: "3" }); assert.deepEqual(extractQueryParams("a=1&b=2&a=3"), { a: "3", b: "2" }); }); }; const test_renderStatus = async t => { t.start("renderStatus()"); await t.test("good statuses", () => { assert.equal(renderStatus(101), "HTTP/1.1 101 Switching Protocols"); assert.equal(renderStatus(202), "HTTP/1.1 202 Accepted"); assert.equal(renderStatus(409), "HTTP/1.1 409 Conflict"); }); }; const test_renderHeaders = async t => { t.start("renderHeaders()"); await t.test("empty values", () => { assert.deepEqual(renderHeaders({}), []); assert.deepEqual(renderHeaders(), []); }) await t.test("values are rendered and sorted", () => { assert.deepEqual(renderHeaders({ a: "one", Z: "two" }), [ "a: one", "Z: two", ]); }); await t.test("GIGO for newlines", () => { assert.deepEqual(renderHeaders({ a: "\nx: 1\r\n", }), [ "a: \nx: 1\r\n", ]); }); }; const test_buildHeader = async t => { t.start("buildHeader()"); await t.test("empty values", () => { assert.equal( buildHeader(200, {}), "HTTP/1.1 200 OK\r\n" + "\r\n", ); }); await t.test("we compose the status line and the headers", () => { assert.equal( buildHeader(201, { a: "1", b: "2" }), "HTTP/1.1 201 Created\r\n" + "a: 1\r\n" + "b: 2\r\n" + "\r\n", ); }); }; const test_writeHead = async t => { t.start("writeHead()"); await t.test("we simply write what buildHeader() gives us", () => { const contents = []; const socket = { write: x => contents.push(x) }; writeHead(socket, 202, { "Key": "Value" }); assert.deepEqual(contents, [ "HTTP/1.1 202 Accepted\r\n" + "Key: Value\r\n" + "\r\n", ]); }); }; const test_make404Handler = async t => { t.start("make404Handler"); await t.test("empty interceptors", () => { assert.deepEqual(make404Handler([]), { params: {}, handlerFn: handle404, }); }); }; const test_handleRequest = async t => { t.start("handleRequest()"); const fn = req => req; await t.test("request without params", async () => { const table = { static: { GET: { "": fn, }, }, }; const req = { method: "GET", url: "/?q=1", headers: { a: "1", b: "two", }, upgrade: false, socket: null, }; assert.deepEqual( await handleRequest(table, req), { params: { path: {}, query: { q: "1", }, }, method: "GET", path: "/", headers: { a: "1", b: "two", }, ref: req, handler: fn, upgrade: false, socket: null, }, ); }); await t.test("request with params", async () => { const table = { dynamic: { PUT: { api: { user: { ":userId": { "": fn, }, }, }, }, }, interceptors: [], }; const req = { method: "PUT", url: "/api/user/2222", headers: { h1: "H1", h2: "h2", }, upgrade: false, socket: null, }; assert.deepEqual( await handleRequest(table, req), { params: { path: { userId: "2222", }, query: {}, }, method: "PUT", path: "/api/user/2222", headers: { h1: "H1", h2: "h2", }, handler: fn, ref: req, upgrade: false, socket: null, }, ); }); await t.test("upgrade request", async () => { const socket = () => {}; const handler = req => { assert.equal(req.socket, socket); return "handler ret"; }; const table = { websocket: { GET: { api: { socket: { "": handler, }, }, }, }, interceptors: [], }; const req = { method: "GET", url: "/api/socket", upgrade: true, socket, }; assert.deepEqual( await handleRequest(table, req), "handler ret", ); }); await t.test("missing route", async () => { assert.deepEqual( await handleRequest({ interceptors: [] }, { method: "GET", url: "/", }), { status: 404, body: "Not Found\n", }, ); }); }; const test_makeUpgradeListener = async t => { t.start("makeUpgradeListener()"); await t.test("straightforward connection stablishing", async () => { const calls = []; const fn = x => calls.push([x.upgrade, x.socket]); const routes = [[ "WEBSOCKET", "/socket", fn ]]; const table = buildRoutes(routes, [ interceptors.contentType, interceptors.websocketHandshake, ]); const upgradeListener = makeUpgradeListener(table); const req = { method: "GET", url: "/socket", upgrade: true, headers: { "upgrade": "websocket", "sec-websocket-key": "aaaaabbbbbcccccdddddee==", "sec-websocket-version": "13", }, }; const contents = []; const socket = { end: () => assert.ok(false), write: x => contents.push(x), }; await upgradeListener(req, socket); assert.deepEqual(calls, [[true, socket]]); assert.deepEqual(contents, [ "HTTP/1.1 101 Switching Protocols\r\n" + "Connection: Upgrade\r\n" + "Content-Length: 20\r\n" + "Content-Type: text/plain\r\n" + "Sec-WebSocket-Accept: eHnDP9gUz224y002aFCe7swigxg=\r\n" + "Upgrade: websocket\r\n" + "\r\n", "Switching Protocols\n", ]); }); await t.test("early termination cases", async () => { const routes = [[ "WEBSOCKET", "/a-socket", null ]]; const table = buildRoutes(routes, [ interceptors.websocketHandshake, ]); const upgradeListener = makeUpgradeListener(table); const req = { method: "GET", url: "/a-socket", upgrade: true, headers: { "upgrade": "websocket", }, }; let ended = false; const contents = []; const socket = { end: () => ended = true, write: x => contents.push(x), }; await upgradeListener(req, socket); assert.ok(ended); assert.deepEqual(contents, [ "HTTP/1.1 400 Bad Request\r\n\r\n", 'Missing "Sec-WebSocket-Key" header\n', ]); }); }; const test_makeRequestListener = async t => { t.start("makeRequestListener()"); await t.test("straightforward body execution", async () => { const fn = _ => ({ status: "test status", body: "test body", headers: "test headers", }); const routes = [[ "GET", "/route1", fn ]]; const requestListener = makeRequestListener(buildRoutes(routes)); const req = { method: "GET", url: "/route1", }; const heads = []; const bodies = []; const res = { writeHead: (status, headers) => heads.push({ status, headers }), end: body => bodies.push(body), }; await requestListener(req, res); assert.deepEqual( heads, [{ status: "test status", headers: "test headers", }], ); assert.deepEqual( bodies, [ "test body" ], ); }); await t.test("we break if handleRequest() throws an error", async () => { const fn = _ => { throw new Error("handler error"); }; const routes = [[ "GET", "/route2", fn ]]; const requestListener = makeRequestListener(buildRoutes(routes)); const req = { method: "GET", url: "/route2", }; const heads = []; const bodies = []; const res = { writeHead: (status, headers) => heads.push({ status, headers }), end: body => bodies.push(body), }; await assert.rejects( async () => await requestListener(req, res), { message: "handler error" }, ); assert.deepEqual(heads, []); assert.deepEqual(bodies, []); }); }; const test_actionsFn = async t => { { t.start(`actionsFn()["toggle-debug-env"()]`); await t.test("we can toggle back and forth", () => { const contents = []; const logger = { info: x => contents.push(x) }; const actions = actionsFn({logger}); const previous = process.env.DEBUG; delete process.env.DEBUG; actions["toggle-debug-env"]("action-text-1"); assert.equal(process.env.DEBUG, "1"); actions["toggle-debug-env"]("action-text-2"); assert.equal(process.env.DEBUG, undefined); assert.deepEqual(contents, [ { type: "action-response", action: "action-text-1", message: "toggle process.env.DEBUG", before: null, after: "1", }, { type: "action-response", action: "action-text-2", message: "toggle process.env.DEBUG", before: "1", after: null, }, ]); process.env.DEBUG = previous; }); }; { t.start(`actionsFn()["config-dump"]()`); await t.test("we just dump data as a log entry", () => { const contents = []; const logger = { info: x => contents.push(x) }; const actions = actionsFn({logger}); configLogger({}); actions["config-dump"]("first-call"); configLogger({ some: "thing", }); actions["config-dump"]("second-call"); configLogger({}); assert.deepEqual(contents, [ { type: "action-response", action: "first-call", data: { pid: process.pid, ppid: process.ppid, tool: "hero", }, }, { type: "action-response", action: "second-call", data: { pid: process.pid, ppid: process.ppid, tool: "hero", some: "thing", }, }, ]); }); }; { t.start(`actionsFn()["ping"]()`); await t.test("simple pinging", () => { const contents = []; const logger = { info: x => contents.push(x) }; const actions = actionsFn({ logger }); configLogger({}); actions["ping"]("blah"); actions["ping"](null); assert.deepEqual(contents, [ { message: "pong" }, { message: "pong" }, ]); }); }; }; const test_lineHandlerFn = async t => { t.start("lineHandlerFn()"); await t.test("empty values", () => { const contents = []; const logger = { info: x => contents.push(x) }; const lineHandler = lineHandlerFn({logger, actionsMap: {}}); lineHandler(""); lineHandler("{}"); lineHandler(`{"action": "this-action-does-not-exist"}`); assert.deepEqual(contents.map(x => x.type), [ "invalid-cmd-input", "missing-key-action", "unsupported-action", ]); }); await t.test("calling an action", () => { const contents = []; const logger = { info: x => contents.push(x) }; const lineHandler = lineHandlerFn({ logger: null, actionsMap: { "an-action": (arg1, arg2, arg3) => [arg1, arg2, arg3], }}); const ret1 = lineHandler(`{"action": "an-action"}`); const ret2 = lineHandler(`{"action": "an-action", "args": [1, "text", 2]}`); assert.deepEqual(ret1, ["an-action", undefined, undefined]); assert.deepEqual(ret2, ["an-action", 1, "text"]); }); }; const test_rmIf = async t => { t.start("rmIf()"); const path = "tests/hero-0.txt"; await t.test("rm when exists", async () => { fs.writeFileSync(path, " ", { flush: true }); assert.ok(fs.existsSync(path)); rmIf(path); assert.ok(!fs.existsSync(path)); }); await t.test("noop otherwise", async () => { assert.ok(!fs.existsSync(path)); rmIf(path); assert.ok(!fs.existsSync(path)); }); }; const test_mkfifo = async t => { t.start("mkfifo()"); await t.test("invalid paths", () => { assert.throws( () => mkfifo("tests/this/dir/does/not/exist/file.fifo"), { status: 1 }, ); assert.throws( () => mkfifo(""), { status: 1 }, ); }); await t.test("error when path already exists", async () => { const path = "tests/hero-mkfifo-0.pipe" fs.writeFileSync(path, " ", { flush: true }); const stats = fs.statSync(path); assert.ok(!stats.isFIFO()); assert.throws( () => mkfifo(path), { status: 1 }, ); }); await t.test("new pipe file", async () => { const path = "tests/hero-mkfifo-1.pipe" rmIf(path); assert.ok(!fs.existsSync(path)); mkfifo(path); assert.ok(fs.existsSync(path)); const stats = fs.statSync(path); assert.ok(stats.isFIFO()); }); }; const test_makeLineEmitter = async t => { t.start("makeLineEmitter()"); await t.test("noop when we only get empty strings", async () => { const entries = []; const record = x => entries.push(x); const emitter = makeLineEmitter(record); emitter(""); emitter(""); emitter(""); emitter(""); emitter(""); assert.deepEqual(entries, []); }); await t.test("empty strings when we only get newlines", async () => { const entries = []; const record = x => entries.push(x); const emitter = makeLineEmitter(record); emitter("\n\n\n"); emitter("\n\n"); emitter("\n"); assert.deepEqual(entries, [ "", "", "", "", "", "" ]); }); await t.test("noop also if we never get a newline", async () => { const entries = []; const record = x => entries.push(x); const emitter = makeLineEmitter(record); emitter(" "); emitter("some string"); emitter(" "); emitter("a lot of text"); assert.deepEqual(entries, []); emitter("\n"); assert.deepEqual(entries, [ " some string a lot of text" ]); }); await t.test("if a newline always comes, we always emit", async () => { const entries = []; const record = x => entries.push(x); const emitter = makeLineEmitter(record); emitter("first\n"); emitter("second\n"); emitter("third\n"); assert.deepEqual(entries, [ "first", "second", "third" ]); }); await t.test("lines can acummulate accross multiple writes", async () => { const entries = []; const record = x => entries.push(x); const emitter = makeLineEmitter(record); emitter("fir"); emitter("s"); emitter("t\ns"); emitter("econd\nthir"); emitter("d"); emitter("\n"); emitter("fourth\nfifth\nsixth"); assert.deepEqual(entries, [ "first", "second", "third", "fourth", "fifth", ]); }); }; const test_makeReopeningPipeReader = async t => { t.start("makeReopeningPipeReader()"); await t.test("we can init to not reopen from the start", async () => { const path = "tests/hero-makeReopeningPipeReader-0.pipe"; const shouldReopenPipe = { ref: false }; const lines = [] const logs = []; const lineFn = x => lines.push(x); const logger = { debug: x => logs.push(x) }; const previous = process.env.DEBUG; delete process.env.DEBUG; rmIf(path); mkfifo(path); const pipe = {}; makeReopeningPipeReader( shouldReopenPipe, path, { lineFn, logger }, pipe, ); return new Promise((resolve, reject) => { fs.createWriteStream(path).end().close(); pipe.ref.on("close", () => { assert.deepEqual(lines, []); assert.deepEqual(logs, [{ message: "pipe closed, NOT reopening", }]); process.env.DEBUG = previous; resolve(); }); }); }); await t.test("we can reopen more than once", async () => { const path = "tests/hero-makeReopeningPipeReader-1.pipe"; const shouldReopenPipe = { ref: true }; const lines = []; const logs = []; const lineFn = x => lines.push(x); const logger = { debug: x => logs.push(x) }; const previous = process.env.DEBUG; delete process.env.DEBUG; rmIf(path); mkfifo(path); const pipe = {}; makeReopeningPipeReader( shouldReopenPipe, path, { lineFn, logger }, pipe, ); return new Promise((resolve, reject) => { fs.createWriteStream(path).end("first\n").close(); pipe.ref.on("close", () => { fs.createWriteStream(path).end("second\n").close(); pipe.ref.on("close", () => { shouldReopenPipe.ref = false; fs.createWriteStream(path).end("third\n").close(); pipe.ref.on("close", () => { assert.deepEqual(lines, [ "first", "second", "third", ]); assert.deepEqual(logs, [ { message: "pipe closed, reopening" }, { message: "pipe closed, reopening" }, { message: "pipe closed, NOT reopening" }, ]); process.env.DEBUG = previous; resolve(); }); }); }); }); }); }; const test_makePipeReaderFn = async t => { t.start("makePipeReaderFn()"); await t.test("we can close it directly on creation with no data", async () => { const path = "tests/hero-makePipeReader-0.pipe"; const lines = []; const logs = []; const lineFn = x => lines.push(x); const logger = { debug: x => logs.push(x) }; const makePipeReader = makePipeReaderFn({ lineFn, logger }); rmIf(path); await makePipeReader(path)(); assert.deepEqual(lines, []); assert.deepEqual(logs, [{ message: "pipe closed, NOT reopening" }]); }); }; const test_buildRoutes = async t => { t.start("buildRoutes()"); await t.test("empty values", () => { assert.deepEqual(buildRoutes([]), {}); }); await t.test("overwrites", () => { const fn1 = () => {}; const fn2 = () => {}; const r1 = [ [ "GET", "/", fn1 ] ]; const r2 = [ [ "GET", "/", fn2 ] ]; assert.deepEqual( buildRoutes(r1), { static: { GET: { "": fn1, }, }, }, ); assert.deepEqual( buildRoutes(r2), { static: { GET: { "": fn2, }, }, }, ); assert.deepEqual( buildRoutes(r1.concat(r2)), buildRoutes(r2), ); assert.deepEqual( buildRoutes(r2.concat(r1)), buildRoutes(r1), ); }); await t.test("wrapped handler functions", async () => { const handler = req => ({ ...req, handled: true }); const interceptor = (req, next) => next({ ...req, intercepted: true }); const routes = [ [ "GET", "/without", handler ], [ "GET", "/with", handler, [ interceptor ] ], ]; const table = buildRoutes(routes); { const { handled, intercepted } = await handleRequest(table, { method: "GET", url: "/without", }); assert.deepEqual( { handled, intercepted }, { handled: true, intercepted: undefined }, ); }; { const { handled, intercepted } = await handleRequest(table, { method: "GET", url: "/with", }); assert.deepEqual( { handled, intercepted }, { handled: true, intercepted: true }, ); }; }); await t.test("interceptors are combined", async () => { const handler = req => ({ ...req, handled: true }); const interceptor1 = (req, next) => next({ ...req, interceptor1: true }); const interceptor2 = (req, next) => next({ ...req, interceptor2: true }); const routes = [ [ "GET", "/global-only", handler ], [ "GET", "/global-and-local", handler, [ interceptor2 ] ], ]; const table = buildRoutes(routes, [ interceptor1 ]); { const { handled, interceptor1, interceptor2 } = await handleRequest(table, { method: "GET", url: "/global-only", }); assert.deepEqual( { handled, interceptor1, interceptor2 }, { handled: true, interceptor1: true, interceptor2: undefined }, ); }; { const { handled, interceptor1, interceptor2 } = await handleRequest(table, { method: "GET", url: "/global-and-local", }); assert.deepEqual( { handled, interceptor1, interceptor2 }, { handled: true, interceptor1: true, interceptor2: true }, ); }; }); await t.test("multiple routes built", () => { const fn1 = () => {}; const fn2 = () => {}; const fn3 = () => {}; const fn4 = () => {}; const fn5 = () => {}; const routes = [ [ "GET", "/", fn1 ], [ "GET", "/home", fn2 ], [ "GET", "/user", fn3 ], [ "POST", "/user", fn4 ], [ "GET", "/user/:id", fn5 ], ]; assert.deepEqual( buildRoutes(routes), { static: { GET: { "": fn1, home: { "": fn2, }, user: { "": fn3, }, }, POST: { user: { "": fn4, }, }, }, dynamic: { GET: { user: { ":id": { "": fn5, }, }, }, }, }, ); }); }; const test_buildTable = async t => { t.start("buildTable()"); await t.test('we just add the "interceptors" key to what buildRoutes() gives us', () => { assert.deepEqual( buildTable([], [ "i1", "i2" ]), { interceptors: [ "i1", "i2" ] }, ); }); }; const test_promisifyServer = async t => { t.start("promisifyServer()"); await t.test("we can access the underlying server ref", () => { const server = promisifyServer("app-name", http.createServer(() => {})); assert.ok(server.ref instanceof http.Server); }); }; const test_buildServer = async t => { t.start("buildServer()"); const socketRequest = (socketPath, path) => new Promise((resolve, reject) => { const callback = res => { let body = ""; res.on("data", chunk => body += chunk); res.on("error", reject); res.on("end", () => resolve({ body, status: res.statusCode, })); }; const request = http.request({ socketPath, path, }, callback); request.end(); }); await t.test("empty values", async () => { const socket = "tests/hero-buildServer-0.socket"; const pipe = "tests/hero-buildServer-0.pipe"; const name = "my-empty-app"; const server = buildServer({ name, socket, pipe }); await server.start(); const response = await socketRequest(socket, "/anything"); await server.stop(); assert.deepEqual(response, { status: 404, body: "Not Found\n" }); }); await t.test("integrated application server", async () => { const socket = "tests/hero-buildServer-1.socket"; const pipe = "tests/hero-buildServer-1.pipe"; const name = "the-app"; const pathHandler = req => ({ status: 200, body: "something" }); const routes = [ [ "GET", "/path", pathHandler ] ]; const server = buildServer({ name, routes, socket, pipe }); await server.start(); const response = await socketRequest(socket, "/path"); await server.stop(); assert.deepEqual(response, { status: 200, body: "something" }); }); }; await runner.runTests([ test_configLogger, test_logit, test_makeLogger, test_isValidMethod, test_isValidUpgrade, test_isValidKey, test_isValidVersion, test_validateUpgrade, test_computeHash, test_interceptorsFn, test_chainInterceptors, test_wrapHandler, test_normalizeSegments, test_pathToSegments, test_hasPathParams, test_isValidLabel, test_comboForLabel, test_addRoute, test_findStaticHandler, test_firstParamMatch, test_findDynamicHandler, test_findHandler, test_extractQueryParams, test_renderStatus, test_renderHeaders, test_buildHeader, test_writeHead, test_make404Handler, test_handleRequest, test_makeUpgradeListener, test_makeRequestListener, test_actionsFn, test_lineHandlerFn, test_buildRoutes, test_buildTable, test_rmIf, test_mkfifo, test_makeLineEmitter, test_makeReopeningPipeReader, test_makePipeReaderFn, test_promisifyServer, test_buildServer, ]);