@jsenv/core
Version:
Tool to develop, test and build js projects
718 lines (696 loc) • 24.4 kB
JavaScript
import {
assertAndNormalizeDirectoryUrl,
bufferToEtag,
lookupPackageDirectory,
readPackageAtOrNull,
} from "@jsenv/filesystem";
import { createLogger, createTaskLog, formatError } from "@jsenv/humanize";
import {
composeTwoResponses,
jsenvAccessControlAllowedHeaders,
jsenvServiceCORS,
jsenvServiceErrorHandler,
serveDirectory,
startServer,
} from "@jsenv/server";
import { convertFileSystemErrorToResponseProperties } from "@jsenv/server/src/internal/convertFileSystemErrorToResponseProperties.js";
import { URL_META } from "@jsenv/url-meta";
import { urlIsOrIsInsideOf, urlToRelativeUrl } from "@jsenv/urls";
import { existsSync, readFileSync } from "node:fs";
import { defaultRuntimeCompat } from "../build/build_params.js";
import { createEventEmitter } from "../helpers/event_emitter.js";
import { watchSourceFiles } from "../helpers/watch_source_files.js";
import { WEB_URL_CONVERTER } from "../helpers/web_url_converter.js";
import { jsenvCoreDirectoryUrl } from "../jsenv_core_directory_url.js";
import { createKitchen } from "../kitchen/kitchen.js";
import {
createPluginController,
createPluginStore,
} from "../plugins/plugin_controller.js";
import { getCorePlugins } from "../plugins/plugins.js";
import { jsenvPluginServerEvents } from "../plugins/server_events/jsenv_plugin_server_events.js";
import { parseUserAgentHeader } from "./user_agent.js";
const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
/**
* Starts the development server.
*
* @param {Object} [params={}] - Configuration params for the dev server.
* @param {number} [params.port=3456] - Port number the server should listen on.
* @param {string} [params.hostname="localhost"] - Hostname to bind the server to.
* @param {boolean} [params.https=false] - Whether to use HTTPS.
*
* @returns {Promise<Object>} A promise that resolves to the server instance.
* @throws {Error} Will throw an error if the server fails to start or is called with unexpected params.
*
* @example
* // Start a basic dev server
* const server = await startDevServer();
* console.log(`Server started at ${server.origin}`);
*
* @example
* // Start a server with custom params
* const server = await startDevServer({
* port: 8080,
* });
*/
export const startDevServer = async ({
sourceDirectoryUrl,
sourceMainFilePath = "./index.html",
ignore,
port = 3456,
hostname,
acceptAnyIp,
https,
// it's better to use http1 by default because it allows to get statusText in devtools
// which gives valuable information when there is errors
http2 = false,
logLevel = EXECUTED_BY_TEST_PLAN ? "warn" : "info",
serverLogLevel = "warn",
services = [],
signal = new AbortController().signal,
handleSIGINT = true,
keepProcessAlive = true,
onStop = () => {},
sourceFilesConfig = {},
clientAutoreload = true,
clientAutoreloadOnServerRestart = true,
// runtimeCompat is the runtimeCompat for the build
// when specified, dev server use it to warn in case
// code would be supported during dev but not after build
runtimeCompat = defaultRuntimeCompat,
plugins = [],
referenceAnalysis = {},
nodeEsmResolution,
supervisor = true,
magicExtensions,
magicDirectoryIndex,
directoryListing,
injections,
transpilation,
cacheControl = true,
ribbon = true,
// toolbar = false,
onKitchenCreated = () => {},
spa,
sourcemaps = "inline",
sourcemapsSourcesContent,
outDirectoryUrl,
...rest
}) => {
// params type checking
{
const unexpectedParamNames = Object.keys(rest);
if (unexpectedParamNames.length > 0) {
throw new TypeError(
`${unexpectedParamNames.join(",")}: there is no such param`,
);
}
sourceDirectoryUrl = assertAndNormalizeDirectoryUrl(
sourceDirectoryUrl,
"sourceDirectoryUrl",
);
if (!existsSync(new URL(sourceDirectoryUrl))) {
throw new Error(`ENOENT on sourceDirectoryUrl at ${sourceDirectoryUrl}`);
}
if (typeof sourceMainFilePath !== "string") {
throw new TypeError(
`sourceMainFilePath must be a string, got ${sourceMainFilePath}`,
);
}
sourceMainFilePath = urlToRelativeUrl(
new URL(sourceMainFilePath, sourceDirectoryUrl),
sourceDirectoryUrl,
);
if (outDirectoryUrl === undefined) {
if (
process.env.CAPTURING_SIDE_EFFECTS ||
(!import.meta.build &&
urlIsOrIsInsideOf(sourceDirectoryUrl, jsenvCoreDirectoryUrl))
) {
outDirectoryUrl = new URL("../.jsenv/", sourceDirectoryUrl);
} else {
const packageDirectoryUrl = lookupPackageDirectory(sourceDirectoryUrl);
if (packageDirectoryUrl) {
outDirectoryUrl = `${packageDirectoryUrl}.jsenv/`;
}
}
} else if (outDirectoryUrl !== null && outDirectoryUrl !== false) {
outDirectoryUrl = assertAndNormalizeDirectoryUrl(
outDirectoryUrl,
"outDirectoryUrl",
);
}
}
// params normalization
{
if (clientAutoreload === true) {
clientAutoreload = {};
}
if (clientAutoreload === false) {
clientAutoreload = { enabled: false };
}
}
const logger = createLogger({ logLevel });
const startDevServerTask = createTaskLog("start dev server", {
disabled: !logger.levels.info,
});
const serverStopCallbackSet = new Set();
const serverStopAbortController = new AbortController();
serverStopCallbackSet.add(() => {
serverStopAbortController.abort();
});
const serverStopAbortSignal = serverStopAbortController.signal;
const kitchenCache = new Map();
const finalServices = [];
// x-server-inspect service
{
finalServices.push({
name: "jsenv:server_header",
routes: [
{
endpoint: "GET /.internal/server.json",
description: "Get information about jsenv dev server",
availableMediaTypes: ["application/json"],
declarationSource: import.meta.url,
fetch: () =>
Response.json({
server: "jsenv_dev_server/1",
sourceDirectoryUrl,
}),
},
],
injectResponseProperties: () => {
return {
headers: {
server: "jsenv_dev_server/1",
},
};
},
});
}
// cors service
{
finalServices.push(
jsenvServiceCORS({
accessControlAllowRequestOrigin: true,
accessControlAllowRequestMethod: true,
accessControlAllowRequestHeaders: true,
accessControlAllowedRequestHeaders: [
...jsenvAccessControlAllowedHeaders,
"x-jsenv-execution-id",
],
accessControlAllowCredentials: true,
timingAllowOrigin: true,
}),
);
}
// custom services
{
finalServices.push(...services);
}
// file_service
{
const clientFileChangeEventEmitter = createEventEmitter();
const clientFileDereferencedEventEmitter = createEventEmitter();
clientAutoreload = {
enabled: true,
clientServerEventsConfig: {},
clientFileChangeEventEmitter,
clientFileDereferencedEventEmitter,
...clientAutoreload,
};
const stopWatchingSourceFiles = watchSourceFiles(
sourceDirectoryUrl,
(fileInfo) => {
clientFileChangeEventEmitter.emit(fileInfo);
},
{
sourceFilesConfig,
keepProcessAlive: false,
cooldownBetweenFileEvents: clientAutoreload.cooldownBetweenFileEvents,
},
);
serverStopCallbackSet.add(stopWatchingSourceFiles);
const packageDirectory = {
url: lookupPackageDirectory(sourceDirectoryUrl),
find: lookupPackageDirectory,
read: readPackageAtOrNull,
};
const devServerPluginStore = await createPluginStore([
jsenvPluginServerEvents({ clientAutoreload }),
...plugins,
...getCorePlugins({
packageDirectory,
rootDirectoryUrl: sourceDirectoryUrl,
mainFilePath: sourceMainFilePath,
runtimeCompat,
sourceFilesConfig,
referenceAnalysis,
nodeEsmResolution,
magicExtensions,
magicDirectoryIndex,
directoryListing,
supervisor,
injections,
transpilation,
spa,
clientAutoreload,
clientAutoreloadOnServerRestart,
cacheControl,
ribbon,
}),
]);
const getOrCreateKitchen = async (request) => {
const { runtimeName, runtimeVersion } = parseUserAgentHeader(
request.headers["user-agent"] || "",
);
const runtimeId = `${runtimeName} {runtimeVersion}`;
const existing = kitchenCache.get(runtimeId);
if (existing) {
return existing;
}
const watchAssociations = URL_META.resolveAssociations(
{ watch: stopWatchingSourceFiles.watchPatterns },
sourceDirectoryUrl,
);
let kitchen;
clientFileChangeEventEmitter.on(({ url, event }) => {
const urlInfo = kitchen.graph.getUrlInfo(url);
if (urlInfo) {
if (event === "removed") {
urlInfo.onRemoved();
} else {
urlInfo.onModified();
}
}
});
const clientRuntimeCompat = { [runtimeName]: runtimeVersion };
kitchen = createKitchen({
name: runtimeId,
signal: serverStopAbortSignal,
logLevel,
rootDirectoryUrl: sourceDirectoryUrl,
mainFilePath: sourceMainFilePath,
ignore,
dev: true,
runtimeCompat,
clientRuntimeCompat,
supervisor,
minification: false,
sourcemaps,
sourcemapsSourcesContent,
outDirectoryUrl: outDirectoryUrl
? new URL(`${runtimeName} {runtimeVersion}/`, outDirectoryUrl)
: undefined,
packageDirectory,
});
kitchen.graph.urlInfoCreatedEventEmitter.on((urlInfoCreated) => {
const { watch } = URL_META.applyAssociations({
url: urlInfoCreated.url,
associations: watchAssociations,
});
urlInfoCreated.isWatched = watch;
// when an url depends on many others, we check all these (like package.json)
urlInfoCreated.isValid = () => {
if (!urlInfoCreated.url.startsWith("file:")) {
return false;
}
if (urlInfoCreated.content === undefined) {
// urlInfo content is undefined when:
// - url info content never fetched
// - it is considered as modified because undelying file is watched and got saved
// - it is considered as modified because underlying file content
// was compared using etag and it has changed
return false;
}
if (!watch) {
// file is not watched, check the filesystem
let fileContentAsBuffer;
try {
fileContentAsBuffer = readFileSync(new URL(urlInfoCreated.url));
} catch (e) {
if (e.code === "ENOENT") {
urlInfoCreated.onModified();
return false;
}
return false;
}
const fileContentEtag = bufferToEtag(fileContentAsBuffer);
if (fileContentEtag !== urlInfoCreated.originalContentEtag) {
urlInfoCreated.onModified();
// restore content to be able to compare it again later
urlInfoCreated.kitchen.urlInfoTransformer.setContent(
urlInfoCreated,
String(fileContentAsBuffer),
{
contentEtag: fileContentEtag,
},
);
return false;
}
}
for (const implicitUrl of urlInfoCreated.implicitUrlSet) {
const implicitUrlInfo =
urlInfoCreated.graph.getUrlInfo(implicitUrl);
if (!implicitUrlInfo) {
continue;
}
if (implicitUrlInfo.content === undefined) {
// happens when we explicitely load an url with a search param
// - it creates an implicit url info to the url without params
// - we never explicitely request the url without search param so it has no content
// in that case the underlying urlInfo cannot be invalidate by the implicit
// we use modifiedTimestamp to detect if the url was loaded once
// or is just here to be used later
if (implicitUrlInfo.modifiedTimestamp) {
return false;
}
continue;
}
if (!implicitUrlInfo.isValid()) {
return false;
}
}
return true;
};
});
kitchen.graph.urlInfoDereferencedEventEmitter.on(
(urlInfoDereferenced, lastReferenceFromOther) => {
clientFileDereferencedEventEmitter.emit(
urlInfoDereferenced,
lastReferenceFromOther,
);
},
);
const devServerPluginController = await createPluginController(
devServerPluginStore,
kitchen,
);
kitchen.setPluginController(devServerPluginController);
serverStopCallbackSet.add(() => {
devServerPluginController.callHooks("destroy", kitchen.context);
});
kitchenCache.set(runtimeId, kitchen);
onKitchenCreated(kitchen);
return kitchen;
};
finalServices.push({
name: "jsenv:dev_server_routes",
augmentRouteFetchSecondArg: async (request) => {
const kitchen = await getOrCreateKitchen(request);
return { kitchen };
},
routes: [
...devServerPluginStore.allDevServerRoutes,
{
endpoint: "GET *",
description: "Serve project files.",
declarationSource: import.meta.url,
fetch: async (request, { kitchen }) => {
const { rootDirectoryUrl, mainFilePath } = kitchen.context;
let requestResource = request.resource;
let requestedUrl;
if (requestResource.startsWith("/@fs/")) {
const fsRootRelativeUrl = requestResource.slice("/@fs/".length);
requestedUrl = `file:///${fsRootRelativeUrl}`;
} else {
const requestedUrlObject = new URL(
requestResource === "/"
? mainFilePath
: requestResource.slice(1),
rootDirectoryUrl,
);
requestedUrlObject.searchParams.delete("hot");
requestedUrl = requestedUrlObject.href;
}
const { referer } = request.headers;
const parentUrl = referer
? WEB_URL_CONVERTER.asFileUrl(referer, {
origin: request.origin,
rootDirectoryUrl: sourceDirectoryUrl,
})
: sourceDirectoryUrl;
let reference = kitchen.graph.inferReference(
request.resource,
parentUrl,
);
if (reference) {
reference.urlInfo.context.request = request;
reference.urlInfo.context.requestedUrl = requestedUrl;
} else {
const rootUrlInfo = kitchen.graph.rootUrlInfo;
rootUrlInfo.context.request = request;
rootUrlInfo.context.requestedUrl = requestedUrl;
reference = rootUrlInfo.dependencies.createResolveAndFinalize({
trace: { message: parentUrl },
type: "http_request",
specifier: request.resource,
});
rootUrlInfo.context.request = null;
rootUrlInfo.context.requestedUrl = null;
}
const urlInfo = reference.urlInfo;
const ifNoneMatch = request.headers["if-none-match"];
const urlInfoTargetedByCache =
urlInfo.findParentIfInline() || urlInfo;
try {
if (!urlInfo.error && ifNoneMatch) {
const [clientOriginalContentEtag, clientContentEtag] =
ifNoneMatch.split("_");
if (
urlInfoTargetedByCache.originalContentEtag ===
clientOriginalContentEtag &&
urlInfoTargetedByCache.contentEtag === clientContentEtag &&
urlInfoTargetedByCache.isValid()
) {
const headers = {
"cache-control": `private,max-age=0,must-revalidate`,
};
Object.keys(urlInfo.headers).forEach((key) => {
if (key !== "content-length") {
headers[key] = urlInfo.headers[key];
}
});
return {
status: 304,
headers,
};
}
}
await urlInfo.cook({ request, reference });
let { response } = urlInfo;
if (response) {
return response;
}
response = {
url: reference.url,
status: 200,
headers: {
// when we send eTag to the client the next request to the server
// will send etag in request headers.
// If they match jsenv bypass cooking and returns 304
// This must not happen when a plugin uses "no-store" or "no-cache" as it means
// plugin logic wants to happens for every request to this url
...(cacheIsDisabledInResponseHeader(urlInfoTargetedByCache)
? {
"cache-control": "no-store", // for inline file we force no-store when parent is no-store
}
: {
"cache-control": `private,max-age=0,must-revalidate`,
// it's safe to use "_" separator because etag is encoded with base64 (see https://stackoverflow.com/a/13195197)
"eTag": `${urlInfoTargetedByCache.originalContentEtag}_${urlInfoTargetedByCache.contentEtag}`,
}),
...urlInfo.headers,
"content-type": urlInfo.contentType,
"content-length": urlInfo.contentLength,
},
body: urlInfo.content,
timing: urlInfo.timing, // TODO: use something else
};
const augmentResponseInfo = {
...kitchen.context,
reference,
urlInfo,
};
kitchen.pluginController.callHooks(
"augmentResponse",
augmentResponseInfo,
(returnValue) => {
response = composeTwoResponses(response, returnValue);
},
);
return response;
} catch (error) {
const originalError = error ? error.cause || error : error;
if (originalError.asResponse) {
return originalError.asResponse();
}
const code = originalError.code;
if (code === "PARSE_ERROR") {
// when possible let browser re-throw the syntax error
// it's not possible to do that when url info content is not available
// (happens for js_module_fallback for instance)
if (urlInfo.content !== undefined) {
kitchen.context.logger
.error(`Error while handling ${request.url}:
${originalError.reasonCode || originalError.code}
${error.trace?.message}`);
return {
url: reference.url,
status: 200,
// reason becomes the http response statusText, it must not contain invalid chars
// https://github.com/nodejs/node/blob/0c27ca4bc9782d658afeaebcec85ec7b28f1cc35/lib/_http_common.js#L221
statusText: error.reason,
statusMessage: originalError.message,
headers: {
"content-type": urlInfo.contentType,
"content-length": urlInfo.contentLength,
"cache-control": "no-store",
},
body: urlInfo.content,
};
}
return {
url: reference.url,
status: 500,
statusText: error.reason,
statusMessage: originalError.message,
headers: {
"cache-control": "no-store",
},
body: urlInfo.content,
};
}
if (code === "DIRECTORY_REFERENCE_NOT_ALLOWED") {
return serveDirectory(reference.url, {
headers: {
accept: "text/html",
},
canReadDirectory: true,
rootDirectoryUrl: sourceDirectoryUrl,
});
}
if (code === "NOT_ALLOWED") {
return {
url: reference.url,
status: 403,
statusText: originalError.reason,
};
}
if (code === "NOT_FOUND") {
return {
url: reference.url,
status: 404,
statusText: originalError.reason,
statusMessage: originalError.message,
};
}
return {
url: reference.url,
status: 500,
statusText: error.reason,
statusMessage: formatError(error),
headers: {
"cache-control": "no-store",
},
};
}
},
},
],
});
finalServices.push(...devServerPluginStore.allDevServerServices);
}
// jsenv error handler service
{
finalServices.push({
name: "jsenv:omega_error_handler",
handleError: (error) => {
const getResponseForError = () => {
if (error && error.asResponse) {
return error.asResponse();
}
if (error && error.statusText === "Unexpected directory operation") {
return {
status: 403,
};
}
return convertFileSystemErrorToResponseProperties(error);
};
const response = getResponseForError();
if (!response) {
return null;
}
const body = JSON.stringify({
status: response.status,
statusText: response.statusText,
headers: response.headers,
body: response.body,
});
return {
status: response.status,
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
},
body,
};
},
});
}
// default error handler
{
finalServices.push(
jsenvServiceErrorHandler({
sendErrorDetails: true,
}),
);
}
const server = await startServer({
signal,
stopOnExit: false,
stopOnSIGINT: handleSIGINT,
stopOnInternalError: false,
keepProcessAlive,
logLevel: serverLogLevel,
startLog: false,
https,
http2,
acceptAnyIp,
hostname,
port,
requestWaitingMs: 60_000,
services: finalServices,
});
server.stoppedPromise.then((reason) => {
onStop();
for (const serverStopCallback of serverStopCallbackSet) {
serverStopCallback(reason);
}
serverStopCallbackSet.clear();
});
startDevServerTask.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,
sourceDirectoryUrl,
stop: () => {
server.stop();
},
kitchenCache,
};
};
const cacheIsDisabledInResponseHeader = (urlInfo) => {
return (
urlInfo.headers["cache-control"] === "no-store" ||
urlInfo.headers["cache-control"] === "no-cache"
);
};