Browse Source

These changes moved a lot of the startup async to run in a streamlined

async init function. This brings more logic "to light", so it should
probably have unit tests added to check the edge cases. As a bonus, no
async runs as a result of route initialization.

Speaking of routes, it might be nice to trim down the route calls
themselves with async functions, if possible. The upload routes in
particular use a lot of async. Just a note for the future.
trunk
Kevin Hoerr 1 year ago
parent
commit
ddecabba54
Signed by: kjhoerr GPG Key ID: 78E4BD33ACC22C86
8 changed files with 308 additions and 152 deletions
  1. +5
    -0
      CHANGELOG.md
  2. +1
    -2
      package.json
  3. +5
    -5
      src/formats.ts
  4. +31
    -51
      src/index.ts
  5. +171
    -46
      src/routes.test.ts
  6. +16
    -42
      src/routes.ts
  7. +5
    -5
      src/util/config.test.ts
  8. +74
    -1
      src/util/config.ts

+ 5
- 0
CHANGELOG.md View File

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Moved startup processes to async handleStartup function
- Fixed MongoErrors not reporting on startup
## [0.4.1]
### Changed


+ 1
- 2
package.json View File

@ -57,8 +57,7 @@
"src/**/*.ts",
"!src/**/*.test.ts",
"!src/**/__tests__/**/*.ts",
"!src/index.ts",
"!src/init.ts"
"!src/index.ts"
],
"roots": [
"src/"


+ 5
- 5
src/formats.ts View File

@ -32,12 +32,12 @@ export const defaultColorMatches = (
coverage >= style.stage1
? 76
: coverage >= style.stage2
? Math.floor(
? Math.floor(
((style.stage1 - coverage) / (style.stage1 - style.stage2)) * 10
) *
16 +
16 +
76
: 225 + Math.floor(coverage / (style.stage2 / 11));
: 225 + Math.floor(coverage / (style.stage2 / 11));
const result = gradient.toString(16);
return (result.length === 1 ? "0" : "") + result + "1";
};
@ -75,11 +75,11 @@ const FormatsObj: FormatObj = {
}
},
listFormats: function () {
listFormats: function() {
return Object.keys(this.formats);
},
getFormat: function (format: string) {
getFormat: function(format: string) {
return this.formats[format];
}
};


+ 31
- 51
src/index.ts View File

@ -1,9 +1,5 @@
import dotenv from "dotenv";
import express from "express";
import { MongoClient } from "mongodb";
import path from "path";
import fs from "fs";
import winston from "winston";
import expressWinston from "express-winston";
@ -12,57 +8,41 @@ dotenv.config();
import routes from "./routes";
import Metadata from "./metadata";
import loggerConfig from "./util/logger";
import { configOrError, handleShutdown } from "./util/config";
import { handleStartup, handleShutdown } from "./util/config";
// Start-up configuration
const BIND_ADDRESS = process.env.BIND_ADDRESS ?? "localhost";
const MONGO_DB = process.env.MONGO_DB ?? "ao-coverage";
const PORT = Number(process.env.PORT ?? 3000);
const logger = winston.createLogger(loggerConfig("ROOT"));
const MONGO_URI = configOrError("MONGO_URI");
const MONGO_DB = process.env.MONGO_DB ?? "ao-coverage";
const HOST_DIR = configOrError("HOST_DIR");
fs.accessSync(HOST_DIR, fs.constants.R_OK | fs.constants.W_OK);
if (!path.isAbsolute(HOST_DIR)) {
logger.error("HOST_DIR must be an absolute path");
process.exit(1);
}
new MongoClient(MONGO_URI, { useUnifiedTopology: true }).connect(
(err, mongo) => {
if (err !== null) {
logger.error(err ?? "Unable to connect to database");
process.exit(1);
}
const app: express.Application = express();
const metadata = new Metadata(mongo.db(MONGO_DB));
app.use(
expressWinston.logger({
...loggerConfig("HTTP"),
colorize: true,
// filter out token query param from URL
msg:
'{{req.method}} {{req.url.replace(/token=[-\\w.~]*(&*)/, "token=$1")}} - {{res.statusCode}} {{res.responseTime}}ms'
})
);
// actual app routes
app.use(routes(metadata));
app.use(expressWinston.errorLogger(loggerConfig("_ERR")));
const server = app.listen(PORT, BIND_ADDRESS, () => {
logger.info("Express has started: http://%s:%d/", BIND_ADDRESS, PORT);
});
// application exit handling
const signalCodes: NodeJS.Signals[] = ["SIGTERM", "SIGHUP", "SIGINT"];
signalCodes.map((code: NodeJS.Signals) => {
process.on(code, handleShutdown(mongo, server));
});
}
);
handleStartup().then(mongo => {
const app: express.Application = express();
const metadata = new Metadata(mongo.db(MONGO_DB));
app.use(
expressWinston.logger({
...loggerConfig("HTTP"),
colorize: true,
// filter out token query param from URL
msg:
'{{req.method}} {{req.url.replace(/token=[-\\w.~]*(&*)/, "token=$1")}} - {{res.statusCode}} {{res.responseTime}}ms'
})
);
// actual app routes
app.use(routes(metadata));
app.use(expressWinston.errorLogger(loggerConfig("_ERR")));
const server = app.listen(PORT, BIND_ADDRESS, () => {
logger.info("Express has started: http://%s:%d/", BIND_ADDRESS, PORT);
});
// application exit handling
const signalCodes: NodeJS.Signals[] = ["SIGTERM", "SIGHUP", "SIGINT"];
signalCodes.map((code: NodeJS.Signals) => {
process.on(code, handleShutdown(mongo, server));
});
});

+ 171
- 46
src/routes.test.ts View File

@ -1,4 +1,4 @@
import _request from "supertest";
import _request, { SuperTest, Test } from "supertest";
import express from "express";
import dotenv from "dotenv";
import fs from "fs";
@ -8,9 +8,10 @@ dotenv.config();
process.env.UPLOAD_LIMIT = "40000";
import { configOrError } from "./util/config";
import { configOrError, persistTemplate } from "./util/config";
import routes from "./routes";
import Metadata from "./metadata";
import { Template } from "./templates";
import { Db } from "mongodb";
import { badgen } from "badgen";
import { BranchNotFoundError } from "./errors";
@ -22,28 +23,44 @@ type MetadataMockType = {
createRepository: jest.Mock;
};
const mock = (headCommit: jest.Mock = jest.fn(() => new Promise(solv => solv("testcommit"))), updateBranch: jest.Mock = jest.fn(() => new Promise(solv => solv(true)))): MetadataMockType => ({
const mock = (
headCommit: jest.Mock = jest.fn(
() => new Promise(solv => solv("testcommit"))
),
updateBranch: jest.Mock = jest.fn(() => new Promise(solv => solv(true)))
): MetadataMockType => ({
database: {} as Db,
getHeadCommit: headCommit,
updateBranch: updateBranch,
createRepository: jest.fn()
});
const request = (mockMeta: MetadataMockType = mock()) => {
const request = (mockMeta: MetadataMockType = mock()): SuperTest<Test> => {
const app = express();
app.use(routes(mockMeta as Metadata));
return _request(app);
}
};
const HOST_DIR = configOrError("HOST_DIR");
const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000";
const TOKEN = process.env.TOKEN ?? "";
describe("templates", () => {
describe("GET /bash", () => {
it("should return the bash file containing tbe curl command", async () => {
it("should return the bash file containing the curl command", async () => {
await persistTemplate({
inputFile: path.join(
__dirname,
"..",
"public",
"templates",
"bash.template"
),
outputFile: path.join(HOST_DIR, "bash"),
context: { TARGET_URL }
} as Template);
const res = await request()
.get("/bash")
.expect(200);
@ -54,20 +71,69 @@ describe("templates", () => {
describe("GET /", () => {
it("should return the index HTML file containing the bash command", async () => {
await persistTemplate({
inputFile: path.join(
__dirname,
"..",
"public",
"templates",
"index.html.template"
),
outputFile: path.join(HOST_DIR, "index.html"),
context: { TARGET_URL }
} as Template);
const res = await request()
.get("/")
.expect("Content-Type", /html/)
.expect(200);
expect(res.text).toMatch(`bash &lt;(curl -s ${TARGET_URL}/bash)`);
})
});
});
});
describe("Badges and reports", () => {
describe("Static files", () => {
const staticRoot = path.join(__dirname, "..", "public", "static");
it("should return favicon.ico at GET /favicon.ico", async () => {
const buffer = await fs.promises.readFile(
path.join(staticRoot, "favicon.ico")
);
await request()
.get("/favicon.ico")
.expect("Content-Type", /icon/)
.expect(buffer)
.expect(200);
});
it("should return index.css at GET /static/index.css", async () => {
const buffer = await fs.promises.readFile(
path.join(staticRoot, "index.css")
);
const res = await request()
.get("/static/index.css")
.expect(200);
expect(res.text).toEqual(buffer.toString("utf-8"));
});
});
const report_path = path.join(HOST_DIR, "testorg", "testrepo", "testbranch", "testcommit");
const actual_report = path.join(__dirname, "..", "example_reports", "tarpaulin-report.html");
const fake_badge = badgen({
describe("Badges and reports", () => {
const reportPath = path.join(
HOST_DIR,
"testorg",
"testrepo",
"testbranch",
"testcommit"
);
const actualReport = path.join(
__dirname,
"..",
"example_reports",
"tarpaulin-report.html"
);
const fakeBadge = badgen({
label: "coverage",
status: "120%",
color: "#E1C"
@ -75,9 +141,12 @@ describe("Badges and reports", () => {
beforeAll(async () => {
// place test files on HOST_DIR
await fs.promises.mkdir(report_path, { recursive: true });
await fs.promises.copyFile(actual_report, path.join(report_path, "index.html"));
await fs.promises.writeFile(path.join(report_path, "badge.svg"), fake_badge);
await fs.promises.mkdir(reportPath, { recursive: true });
await fs.promises.copyFile(
actualReport,
path.join(reportPath, "index.html")
);
await fs.promises.writeFile(path.join(reportPath, "badge.svg"), fakeBadge);
});
describe("GET /v1/:org/:repo/:branch/:commit.html", () => {
@ -86,13 +155,15 @@ describe("Badges and reports", () => {
.get("/v1/testorg/testrepo/testbranch/testcommit.html")
.expect("Content-Type", /html/)
.expect(200);
const buffer = await fs.promises.readFile(actual_report);
const buffer = await fs.promises.readFile(actualReport);
expect(res.text).toEqual(buffer.toString("utf-8"));
});
it("should return 404 if file does not exist", async () => {
await request().get("/v1/neorg/nerepo/nebranch/necommit.html").expect(404);
await request()
.get("/v1/neorg/nerepo/nebranch/necommit.html")
.expect(404);
});
});
@ -103,24 +174,32 @@ describe("Badges and reports", () => {
.get("/v1/testorg/testrepo/testbranch.html")
.expect("Content-Type", /html/)
.expect(200);
const buffer = await fs.promises.readFile(actual_report);
const buffer = await fs.promises.readFile(actualReport);
expect(mockMeta.getHeadCommit).toHaveBeenCalledTimes(1);
expect(res.text).toEqual(buffer.toString("utf-8"));
});
it("should return 404 if file does not exist", async () => {
await request().get("/v1/neorg/nerepo/nebranch.html").expect(404);
await request()
.get("/v1/neorg/nerepo/nebranch.html")
.expect(404);
});
it("should return 404 if head commit not found", async () => {
const head = jest.fn(() => new Promise(solv => solv(new BranchNotFoundError())));
await request(mock(head)).get("/v1/testorg/testrepo/testbranch.html").expect(404);
const head = jest.fn(
() => new Promise(solv => solv(new BranchNotFoundError()))
);
await request(mock(head))
.get("/v1/testorg/testrepo/testbranch.html")
.expect(404);
});
it("should return 500 if promise is rejected", async () => {
const head = jest.fn(() => new Promise((_, rej) => rej("fooey")));
await request(mock(head)).get("/v1/testorg/testrepo/testbranch.html").expect(500);
await request(mock(head))
.get("/v1/testorg/testrepo/testbranch.html")
.expect(500);
});
});
@ -131,11 +210,13 @@ describe("Badges and reports", () => {
.expect("Content-Type", /svg/)
.expect(200);
expect(res.body.toString("utf-8")).toEqual(fake_badge);
expect(res.body.toString("utf-8")).toEqual(fakeBadge);
});
it("should return 404 if file does not exist", async () => {
await request().get("/v1/neorg/nerepo/nebranch/necommit.svg").expect(404);
await request()
.get("/v1/neorg/nerepo/nebranch/necommit.svg")
.expect(404);
});
});
@ -148,82 +229,124 @@ describe("Badges and reports", () => {
.expect(200);
expect(mockMeta.getHeadCommit).toHaveBeenCalledTimes(1);
expect(res.body.toString("utf-8")).toEqual(fake_badge);
expect(res.body.toString("utf-8")).toEqual(fakeBadge);
});
it("should return 404 if file does not exist", async () => {
await request().get("/v1/neorg/nerepo/nebranch.svg").expect(404);
await request()
.get("/v1/neorg/nerepo/nebranch.svg")
.expect(404);
});
it("should return 404 if head commit not found", async () => {
const head = jest.fn(() => new Promise(solv => solv(new BranchNotFoundError())));
await request(mock(head)).get("/v1/testorg/testrepo/testbranch.svg").expect(404);
const head = jest.fn(
() => new Promise(solv => solv(new BranchNotFoundError()))
);
await request(mock(head))
.get("/v1/testorg/testrepo/testbranch.svg")
.expect(404);
});
it("should return 500 if promise is rejected", async () => {
const head = jest.fn(() => new Promise((_, rej) => rej("fooey")));
await request(mock(head)).get("/v1/testorg/testrepo/testbranch.svg").expect(500);
await request(mock(head))
.get("/v1/testorg/testrepo/testbranch.svg")
.expect(500);
});
});
});
describe("Uploads", () => {
const report_path = path.join(HOST_DIR, "testorg", "testrepo", "newthis", "newthat");
const data = fs.promises.readFile(path.join(__dirname, "..", "example_reports", "tarpaulin-report.html"));
const reportPath = path.join(
HOST_DIR,
"testorg",
"testrepo",
"newthis",
"newthat"
);
const data = fs.promises.readFile(
path.join(__dirname, "..", "example_reports", "tarpaulin-report.html")
);
beforeEach(async () => {
await fs.promises.rmdir(report_path).catch(() => { });
try {
await fs.promises.rmdir(reportPath);
} catch (err) {
// ignore failures for rmdir
}
});
describe("POST /v1/:org/:repo/:branch/:commit.html", () => {
it("should upload the report and generate a badge", async () => {
const mockMeta = mock();
await request(mockMeta)
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`
)
.send(await data)
.expect(200);
expect(mockMeta.updateBranch).toBeCalledWith({ organization: "testorg", repository: "testrepo", branch: "newthis", head: "newthat" });
expect(mockMeta.updateBranch).toBeCalledWith({
organization: "testorg",
repository: "testrepo",
branch: "newthis",
head: "newthat"
});
expect(mockMeta.updateBranch).toHaveBeenCalledTimes(1);
await fs.promises.access(path.join(report_path, "index.html"), fs.constants.R_OK);
await fs.promises.access(path.join(report_path, "badge.svg"), fs.constants.R_OK);
await fs.promises.access(
path.join(reportPath, "index.html"),
fs.constants.R_OK
);
await fs.promises.access(
path.join(reportPath, "badge.svg"),
fs.constants.R_OK
);
});
it("should return 401 when token is not correct", async () => {
await request()
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=wrong&format=tarpaulin`)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=wrong&format=tarpaulin`
)
.send(await data)
.expect(401);
});
it("should return 406 with an invalid format", async () => {
await request()
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=pepperoni`)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=pepperoni`
)
.send(await data)
.expect(406);
});
it("should return 400 when request body is not the appropriate format", async () => {
await request()
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`
)
.send("This is not a file")
.expect(400);
});
it("should return 413 when request body is not the appropriate format", async () => {
const file = await data;
const big_data = Buffer.concat([file, file]);
const bigData = Buffer.concat([file, file]);
await request()
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`)
.send(big_data)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`
)
.send(bigData)
.expect(413);
});
it("should return 500 when Metadata does not create branch", async () => {
const update = jest.fn(() => new Promise(solv => solv(false)));
await request(mock(jest.fn(), update))
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`
)
.send(await data)
.expect(500);
});
@ -231,9 +354,11 @@ describe("Uploads", () => {
it("should return 500 when promise chain is rejected", async () => {
const update = jest.fn(() => new Promise((_, rej) => rej("fooey 2")));
await request(mock(jest.fn(), update))
.post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`)
.post(
`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`
)
.send(await data)
.expect(500);
});
});
});
});

+ 16
- 42
src/routes.ts View File

@ -5,7 +5,6 @@ import winston from "winston";
import path from "path";
import fs from "fs";
import processTemplate, { Template } from "./templates";
import formats, { GradientStyle } from "./formats";
import Metadata, { HeadIdentity } from "./metadata";
import { configOrError } from "./util/config";
@ -15,48 +14,15 @@ import { Messages } from "./errors";
const TOKEN = process.env.TOKEN ?? "";
const UPLOAD_LIMIT = Number(process.env.UPLOAD_LIMIT ?? 4194304);
const HOST_DIR = configOrError("HOST_DIR");
const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000";
const logger = winston.createLogger(loggerConfig("HTTP"));
export default (metadata: Metadata): Router => {
const router = Router();
const bashTemplate = {
inputFile: path.join(__dirname, "..", "public", "templates", "bash.template"),
outputFile: path.join(HOST_DIR, "bash"),
context: { TARGET_URL }
} as Template;
const indexTemplate = {
inputFile: path.join(__dirname, "..", "public", "templates", "index.html.template"),
outputFile: path.join(HOST_DIR, "index.html"),
context: { TARGET_URL }
} as Template;
processTemplate(bashTemplate)
.then(template => {
logger.debug("Generated '%s' from template file", template.outputFile);
})
.then(() => processTemplate(indexTemplate))
.then(template => {
logger.debug("Generated '%s' from template file", template.outputFile);
})
.catch(err => {
logger.error("Unable to process template file: %s", err);
// if the output file exists, then we are fine with continuing without
return fs.promises.access(bashTemplate.outputFile, fs.constants.R_OK);
})
.then(() => fs.promises.access(indexTemplate.outputFile, fs.constants.R_OK))
.catch(err => {
logger.error("Cannot proceed: %s", err);
process.exit(1);
});
// serve landing page
router.get("/", (_, res) => {
res.sendFile(path.join(HOST_DIR, "index.html"))
res.sendFile(path.join(HOST_DIR, "index.html"));
});
// serve script for posting coverage report
@ -73,7 +39,8 @@ export default (metadata: Metadata): Router => {
res.sendFile(path.join(__dirname, "..", "public", "static", "favicon.ico"));
});
router.use(
"/static", express.static(path.join(__dirname, "..", "public", "static"))
"/static",
express.static(path.join(__dirname, "..", "public", "static"))
);
// Upload HTML file
@ -86,7 +53,7 @@ export default (metadata: Metadata): Router => {
return res.status(401).send(Messages.InvalidToken);
}
if (typeof format !== 'string' || !formats.listFormats().includes(format)) {
if (typeof format !== "string" || !formats.listFormats().includes(format)) {
return res.status(406).send(Messages.InvalidFormat);
}
@ -149,9 +116,12 @@ export default (metadata: Metadata): Router => {
result
? res.status(200).send()
: res.status(500).send(Messages.UnknownError)
).catch(err => {
logger.error(err ?? "Unknown error occurred while processing POST request");
return res.status(500).send(Messages.UnknownError)
)
.catch(err => {
logger.error(
err ?? "Unknown error occurred while processing POST request"
);
return res.status(500).send(Messages.UnknownError);
});
});
});
@ -189,7 +159,9 @@ export default (metadata: Metadata): Router => {
}
},
err => {
logger.error(err ?? "Error occurred while fetching commit for GET request");
logger.error(
err ?? "Error occurred while fetching commit for GET request"
);
res.status(500).send(Messages.UnknownError);
}
);
@ -213,7 +185,9 @@ export default (metadata: Metadata): Router => {
}
},
err => {
logger.error(err ?? "Error occurred while fetching commit for GET request");
logger.error(
err ?? "Error occurred while fetching commit for GET request"
);
res.status(500).send(Messages.UnknownError);
}
);


+ 5
- 5
src/util/config.test.ts View File

@ -1,11 +1,11 @@
const exit = jest
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
import { configOrError, handleShutdown } from "./config";
import { MongoClient } from "mongodb";
import { Server } from "http";
const exit = jest.spyOn(process, "exit").mockImplementation(() => {
throw Error("");
});
const CommonMocks = {
connect: jest.fn(),
isConnected: jest.fn(),
@ -111,7 +111,7 @@ describe("handleShutdown", () => {
}
// Assert
expect(exit).toHaveBeenCalledWith(1);
expect(exit).toHaveBeenCalledWith(0);
});
it("should exit with error with Mongo error", async () => {


+ 74
- 1
src/util/config.ts View File

@ -1,8 +1,11 @@
import winston from "winston";
import { MongoClient } from "mongodb";
import { MongoClient, MongoError } from "mongodb";
import { Server } from "http";
import path from "path";
import fs from "fs";
import loggerConfig from "./logger";
import processTemplate, { Template } from "../templates";
const logger = winston.createLogger(loggerConfig("ROOT"));
@ -16,6 +19,76 @@ export const configOrError = (varName: string): string => {
}
};
export const persistTemplate = async (input: Template): Promise<void> => {
try {
const template = await processTemplate(input);
logger.debug("Generated '%s' from template file", template.outputFile);
} catch (err1) {
try {
await fs.promises.access(input.outputFile, fs.constants.R_OK);
} catch (err2) {
logger.error(
"Error while generating '%s' from template file: %s",
input.outputFile,
err1
);
logger.error("Cannot proceed due to error: %s", err2);
process.exit(1);
}
// if the output file exists, then we are fine with continuing without
logger.warning(
"Could not generate '%s' from template file, but file already exists: %s",
input.outputFile,
err1
);
}
};
const MONGO_URI = configOrError("MONGO_URI");
const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000";
const HOST_DIR = configOrError("HOST_DIR");
export const handleStartup = async (): Promise<MongoClient> => {
await fs.promises.access(HOST_DIR, fs.constants.R_OK | fs.constants.W_OK);
if (!path.isAbsolute(HOST_DIR)) {
logger.error("HOST_DIR must be an absolute path");
process.exit(1);
}
const mongo = await new MongoClient(MONGO_URI, { useUnifiedTopology: true })
.connect()
.catch((err: MongoError) => {
logger.error(err.message ?? "Unable to connect to database");
process.exit(1);
});
await persistTemplate({
inputFile: path.join(
__dirname,
"..",
"public",
"templates",
"bash.template"
),
outputFile: path.join(HOST_DIR, "bash"),
context: { TARGET_URL }
} as Template);
await persistTemplate({
inputFile: path.join(
__dirname,
"..",
"public",
"templates",
"index.html.template"
),
outputFile: path.join(HOST_DIR, "index.html"),
context: { TARGET_URL }
} as Template);
return mongo;
};
export const handleShutdown = (mongo: MongoClient, server: Server) => (
signal: NodeJS.Signals
): Promise<void> => {


Loading…
Cancel
Save