@jsenv/core
Version:
Tool to develop, test and build js projects
206 lines (195 loc) • 5.98 kB
JavaScript
/*
* startBuildServer is mean to interact with the build files;
* files that will be deployed to production server(s).
* We want to be as close as possible from the production in order to:
* - run lighthouse
* - run an automated test tool such as cypress, playwright
* - see exactly how build file behaves (debug, measure perf, etc)
* For these reasons "startBuildServer" must be as close as possible from a static file server.
* It is not meant to provide a nice developper experience: this is the role "startDevServer".
*
* Conclusion:
* "startBuildServer" must be as close as possible from a static file server because
* we want to be in the user shoes and we should not alter build files.
*/
import { Abort, raceProcessTeardownEvents } from "@jsenv/abort";
import { assertAndNormalizeDirectoryUrl } from "@jsenv/filesystem";
import { createLogger, createTaskLog } from "@jsenv/humanize";
import {
createFileSystemFetch,
jsenvAccessControlAllowedHeaders,
jsenvServiceCORS,
jsenvServiceErrorHandler,
startServer,
} from "@jsenv/server";
import { urlToExtension, urlToPathname } from "@jsenv/urls";
import { existsSync } from "node:fs";
/**
* Start a server for build files.
* @param {Object} buildServerParameters
* @param {string|url} buildServerParameters.buildDirectoryUrl Directory where build files are written
* @return {Object} A build server object
*/
export const startBuildServer = async ({
buildDirectoryUrl,
buildMainFilePath = "index.html",
port = 9779,
routes,
services = [],
acceptAnyIp,
hostname,
https,
http2,
logLevel,
serverLogLevel = "warn",
signal = new AbortController().signal,
handleSIGINT = true,
keepProcessAlive = true,
...rest
}) => {
// params validation
{
const unexpectedParamNames = Object.keys(rest);
if (unexpectedParamNames.length > 0) {
throw new TypeError(
`${unexpectedParamNames.join(",")}: there is no such param`,
);
}
buildDirectoryUrl = assertAndNormalizeDirectoryUrl(
buildDirectoryUrl,
"buildDirectoryUrl",
);
if (buildMainFilePath) {
if (typeof buildMainFilePath !== "string") {
throw new TypeError(
`buildMainFilePath must be a string, got ${buildMainFilePath}`,
);
}
if (buildMainFilePath[0] === "/") {
buildMainFilePath = buildMainFilePath.slice(1);
} else {
const buildMainFileUrl = new URL(buildMainFilePath, buildDirectoryUrl)
.href;
if (!buildMainFileUrl.startsWith(buildDirectoryUrl)) {
throw new Error(
`buildMainFilePath must be relative, got ${buildMainFilePath}`,
);
}
buildMainFilePath = buildMainFileUrl.slice(buildDirectoryUrl.length);
}
if (!existsSync(new URL(buildMainFilePath, buildDirectoryUrl))) {
buildMainFilePath = null;
}
}
}
const logger = createLogger({ logLevel });
const operation = Abort.startOperation();
operation.addAbortSignal(signal);
if (handleSIGINT) {
operation.addAbortSource((abort) => {
return raceProcessTeardownEvents(
{
SIGINT: true,
},
abort,
);
});
}
const startBuildServerTask = createTaskLog("start build server", {
disabled: !logger.levels.info,
});
const server = await startServer({
signal,
stopOnExit: false,
stopOnSIGINT: false,
stopOnInternalError: false,
keepProcessAlive,
logLevel: serverLogLevel,
startLog: false,
https,
http2,
acceptAnyIp,
hostname,
port,
serverTiming: true,
requestWaitingMs: 60_000,
routes,
services: [
jsenvServiceCORS({
accessControlAllowRequestOrigin: true,
accessControlAllowRequestMethod: true,
accessControlAllowRequestHeaders: true,
accessControlAllowedRequestHeaders: jsenvAccessControlAllowedHeaders,
accessControlAllowCredentials: true,
timingAllowOrigin: true,
}),
...services,
jsenvBuildFileService({
buildDirectoryUrl,
buildMainFilePath,
}),
jsenvServiceErrorHandler({
sendErrorDetails: true,
}),
],
});
startBuildServerTask.done();
if (hostname) {
delete server.origins.localip;
delete server.origins.externalip;
}
logger.info(``);
Object.keys(server.origins).forEach((key) => {
logger.info(`- ${server.origins[key]}`);
});
logger.info(``);
return {
origin: server.origin,
stop: () => {
server.stop();
},
};
};
const jsenvBuildFileService = ({ buildDirectoryUrl, buildMainFilePath }) => {
return {
name: "jsenv:build_files",
routes: [
{
endpoint: "GET *",
description: "Serve static files.",
fetch: (request, helpers) => {
const urlIsVersioned = new URL(request.url).searchParams.has("v");
if (buildMainFilePath && request.resource === "/") {
request = {
...request,
resource: `/${buildMainFilePath}`,
};
}
const urlObject = new URL(
request.resource.slice(1),
buildDirectoryUrl,
);
return createFileSystemFetch(buildDirectoryUrl, {
cacheControl: urlIsVersioned
? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
: "private,max-age=0,must-revalidate",
etagEnabled: true,
compressionEnabled: true,
rootDirectoryUrl: buildDirectoryUrl,
canReadDirectory: true,
ENOENTFallback: () => {
if (
!urlToExtension(urlObject) &&
!urlToPathname(urlObject).endsWith("/")
) {
return new URL(buildMainFilePath, buildDirectoryUrl);
}
return null;
},
})(request, helpers);
},
},
],
};
};
const SECONDS_IN_30_DAYS = 60 * 60 * 24 * 30;