@jsenv/core 40.0.6 → 40.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,21 @@
1
- import { lookupPackageDirectory, registerDirectoryLifecycle, urlToRelativeUrl, createDetailedMessage, stringifyUrlSite, generateContentFrame, validateResponseIntegrity, urlIsInsideOf, ensureWindowsDriveLetter, setUrlFilename, moveUrl, getCallerPosition, urlToBasename, urlToExtension, asSpecifierWithoutSearch, asUrlWithoutSearch, injectQueryParamsIntoSpecifier, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, urlToFileSystemPath, writeFileSync, createLogger, URL_META, normalizeUrl, ANSI, CONTENT_TYPE, DATA_URL, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, readCustomConditionsFromProcessArgs, applyNodeEsmResolution, defaultLookupPackageScope, defaultReadPackageJson, readEntryStatSync, urlToFilename, ensurePathnameTrailingSlash, comparePathnames, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, jsenvPluginTranspilation } from "./jsenv_core_packages.js";
1
+ import { lookupPackageDirectory, registerDirectoryLifecycle, urlToRelativeUrl, moveUrl, urlIsInsideOf, ensureWindowsDriveLetter, createDetailedMessage, stringifyUrlSite, generateContentFrame, validateResponseIntegrity, setUrlFilename, getCallerPosition, urlToBasename, urlToExtension, asSpecifierWithoutSearch, asUrlWithoutSearch, injectQueryParamsIntoSpecifier, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, urlToFileSystemPath, writeFileSync, createLogger, URL_META, normalizeUrl, ANSI, CONTENT_TYPE, DATA_URL, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, readCustomConditionsFromProcessArgs, applyNodeEsmResolution, defaultLookupPackageScope, defaultReadPackageJson, readEntryStatSync, urlToFilename, ensurePathnameTrailingSlash, comparePathnames, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, jsenvPluginTranspilation, memoizeByFirstArgument, assertAndNormalizeDirectoryUrl, createTaskLog } from "../jsenv_core_packages.js";
2
+ import { WebSocketResponse, pickContentType, ServerEvents, jsenvServiceCORS, jsenvAccessControlAllowedHeaders, composeTwoResponses, serveDirectory, jsenvServiceErrorHandler, startServer } from "@jsenv/server";
3
+ import { convertFileSystemErrorToResponseProperties } from "@jsenv/server/src/internal/convertFileSystemErrorToResponseProperties.js";
4
+ import { readFileSync, existsSync, readdirSync, lstatSync, realpathSync } from "node:fs";
2
5
  import { RUNTIME_COMPAT } from "@jsenv/runtime-compat";
6
+ import { pathToFileURL } from "node:url";
3
7
  import { generateSourcemapFileUrl, createMagicSource, composeTwoSourcemaps, generateSourcemapDataUrl, SOURCEMAP } from "@jsenv/sourcemap";
4
8
  import { parseHtml, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, stringifyHtmlAst, applyBabelPlugins, generateUrlForInlineContent, parseJsWithAcorn, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, analyzeLinkNode, injectJsenvScript } from "@jsenv/ast";
5
- import { pathToFileURL } from "node:url";
6
9
  import { performance } from "node:perf_hooks";
7
- import { readFileSync, existsSync, readdirSync, lstatSync, realpathSync } from "node:fs";
8
10
  import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
9
- import { WebSocketResponse, pickContentType } from "@jsenv/server";
10
- import { replacePlaceholders as replacePlaceholders$1, INJECTIONS, jsenvPluginInjections } from "./main.js";
11
+ import { createRequire } from "node:module";
12
+ import "node:process";
13
+ import "node:os";
14
+ import "node:tty";
15
+ import "string-width";
16
+ import "node:path";
17
+ import "node:crypto";
18
+ import "@jsenv/js-module-fallback";
11
19
 
12
20
  // default runtimeCompat corresponds to
13
21
  // "we can keep <script type="module"> intact":
@@ -22,13 +30,6 @@ const defaultRuntimeCompat = {
22
30
  safari: "11.3",
23
31
  samsung: "9.2",
24
32
  };
25
- const logsDefault = {
26
- level: "info",
27
- disabled: false,
28
- animation: true,
29
- };
30
- const getDefaultBase = (runtimeCompat) =>
31
- runtimeCompat.node ? "./" : "/";
32
33
 
33
34
  const createEventEmitter = () => {
34
35
  const callbackSet = new Set();
@@ -162,7 +163,33 @@ const watchSourceFiles = (
162
163
  return watch();
163
164
  };
164
165
 
165
- const jsenvCoreDirectoryUrl = new URL("./", import.meta.url);
166
+ const WEB_URL_CONVERTER = {
167
+ asWebUrl: (fileUrl, webServer) => {
168
+ if (urlIsInsideOf(fileUrl, webServer.rootDirectoryUrl)) {
169
+ return moveUrl({
170
+ url: fileUrl,
171
+ from: webServer.rootDirectoryUrl,
172
+ to: `${webServer.origin}/`,
173
+ });
174
+ }
175
+ const fsRootUrl = ensureWindowsDriveLetter("file:///", fileUrl);
176
+ return `${webServer.origin}/@fs/${fileUrl.slice(fsRootUrl.length)}`;
177
+ },
178
+ asFileUrl: (webUrl, webServer) => {
179
+ const { pathname, search } = new URL(webUrl);
180
+ if (pathname.startsWith("/@fs/")) {
181
+ const fsRootRelativeUrl = pathname.slice("/@fs/".length);
182
+ return `file:///${fsRootRelativeUrl}${search}`;
183
+ }
184
+ return moveUrl({
185
+ url: webUrl,
186
+ from: `${webServer.origin}/`,
187
+ to: webServer.rootDirectoryUrl,
188
+ });
189
+ },
190
+ };
191
+
192
+ const jsenvCoreDirectoryUrl = new URL("../", import.meta.url);
166
193
 
167
194
  const createResolveUrlError = ({
168
195
  pluginController,
@@ -568,6 +595,7 @@ const determineSourcemapFileUrl = (urlInfo) => {
568
595
  generatedUrlObject.searchParams.delete("as_json_module");
569
596
  generatedUrlObject.searchParams.delete("as_text_module");
570
597
  generatedUrlObject.searchParams.delete("dynamic_import");
598
+ generatedUrlObject.searchParams.delete("dynamic_import_id");
571
599
  generatedUrlObject.searchParams.delete("cjs_as_js_module");
572
600
  const urlForSourcemap = generatedUrlObject.href;
573
601
  return generateSourcemapFileUrl(urlForSourcemap);
@@ -2637,7 +2665,7 @@ const shouldHandleSourcemap = (urlInfo) => {
2637
2665
  };
2638
2666
 
2639
2667
  const inlineContentClientFileUrl = new URL(
2640
- "./client/inline_content/inline_content.js",
2668
+ "../client/inline_content/inline_content.js",
2641
2669
  import.meta.url,
2642
2670
  ).href;
2643
2671
 
@@ -3339,7 +3367,7 @@ const inferUrlInfoType = (urlInfo) => {
3339
3367
 
3340
3368
  const jsenvPluginHtmlSyntaxErrorFallback = () => {
3341
3369
  const htmlSyntaxErrorFileUrl = import.meta.resolve(
3342
- "./client/html_syntax_error/html_syntax_error.html",
3370
+ "../client/html_syntax_error/html_syntax_error.html",
3343
3371
  );
3344
3372
 
3345
3373
  return {
@@ -3406,7 +3434,7 @@ const generateHtmlForSyntaxError = (
3406
3434
  errorLinkText: `${htmlRelativeUrl}:${line}:${column}`,
3407
3435
  syntaxError: escapeHtml(htmlErrorContentFrame),
3408
3436
  };
3409
- const html = replacePlaceholders(htmlForSyntaxError, replacers);
3437
+ const html = replacePlaceholders$1(htmlForSyntaxError, replacers);
3410
3438
  return html;
3411
3439
  };
3412
3440
  const escapeHtml = (string) => {
@@ -3417,7 +3445,7 @@ const escapeHtml = (string) => {
3417
3445
  .replace(/"/g, "&quot;")
3418
3446
  .replace(/'/g, "&#039;");
3419
3447
  };
3420
- const replacePlaceholders = (html, replacers) => {
3448
+ const replacePlaceholders$1 = (html, replacers) => {
3421
3449
  return html.replace(/\$\{(\w+)\}/g, (match, name) => {
3422
3450
  const replacer = replacers[name];
3423
3451
  if (replacer === undefined) {
@@ -4330,7 +4358,12 @@ const jsenvPluginHtmlReferenceAnalysis = ({
4330
4358
  });
4331
4359
  } else {
4332
4360
  setHtmlNodeText(node, inlineUrlInfo.content, {
4333
- indentation: false, // indentation would decrease stack trace precision
4361
+ indentation:
4362
+ inlineUrlInfo.type === "js_classic" ||
4363
+ inlineUrlInfo.type === "js_module"
4364
+ ? // indentation would mess with stack trace and sourcemap
4365
+ false
4366
+ : "auto",
4334
4367
  });
4335
4368
  setHtmlNodeAttributes(node, {
4336
4369
  "jsenv-cooked-by": "jsenv:html_inline_content_analysis",
@@ -5425,6 +5458,103 @@ const FILE_AND_SERVER_URLS_CONVERTER = {
5425
5458
  },
5426
5459
  };
5427
5460
 
5461
+ const jsenvPluginInjections = (rawAssociations) => {
5462
+ let resolvedAssociations;
5463
+
5464
+ return {
5465
+ name: "jsenv:injections",
5466
+ appliesDuring: "*",
5467
+ init: (context) => {
5468
+ resolvedAssociations = URL_META.resolveAssociations(
5469
+ { injectionsGetter: rawAssociations },
5470
+ context.rootDirectoryUrl,
5471
+ );
5472
+ },
5473
+ transformUrlContent: async (urlInfo) => {
5474
+ const { injectionsGetter } = URL_META.applyAssociations({
5475
+ url: asUrlWithoutSearch(urlInfo.url),
5476
+ associations: resolvedAssociations,
5477
+ });
5478
+ if (!injectionsGetter) {
5479
+ return null;
5480
+ }
5481
+ if (typeof injectionsGetter !== "function") {
5482
+ throw new TypeError("injectionsGetter must be a function");
5483
+ }
5484
+ const injections = await injectionsGetter(urlInfo);
5485
+ if (!injections) {
5486
+ return null;
5487
+ }
5488
+ const keys = Object.keys(injections);
5489
+ if (keys.length === 0) {
5490
+ return null;
5491
+ }
5492
+ return replacePlaceholders(urlInfo.content, injections, urlInfo);
5493
+ },
5494
+ };
5495
+ };
5496
+
5497
+ const injectionSymbol = Symbol.for("jsenv_injection");
5498
+ const INJECTIONS = {
5499
+ optional: (value) => {
5500
+ return { [injectionSymbol]: "optional", value };
5501
+ },
5502
+ };
5503
+
5504
+ // we export this because it is imported by jsenv_plugin_placeholder.js and unit test
5505
+ const replacePlaceholders = (content, replacements, urlInfo) => {
5506
+ const magicSource = createMagicSource(content);
5507
+ for (const key of Object.keys(replacements)) {
5508
+ let index = content.indexOf(key);
5509
+ const replacement = replacements[key];
5510
+ let isOptional;
5511
+ let value;
5512
+ if (replacement && replacement[injectionSymbol]) {
5513
+ const valueBehindSymbol = replacement[injectionSymbol];
5514
+ isOptional = valueBehindSymbol === "optional";
5515
+ value = replacement.value;
5516
+ } else {
5517
+ value = replacement;
5518
+ }
5519
+ if (index === -1) {
5520
+ if (!isOptional) {
5521
+ urlInfo.context.logger.warn(
5522
+ `placeholder "${key}" not found in ${urlInfo.url}.
5523
+ --- suggestion a ---
5524
+ Add "${key}" in that file.
5525
+ --- suggestion b ---
5526
+ Fix eventual typo in "${key}"?
5527
+ --- suggestion c ---
5528
+ Mark injection as optional using INJECTIONS.optional():
5529
+ import { INJECTIONS } from "@jsenv/core";
5530
+
5531
+ return {
5532
+ "${key}": INJECTIONS.optional(${JSON.stringify(value)}),
5533
+ };`,
5534
+ );
5535
+ }
5536
+ continue;
5537
+ }
5538
+
5539
+ while (index !== -1) {
5540
+ const start = index;
5541
+ const end = index + key.length;
5542
+ magicSource.replace({
5543
+ start,
5544
+ end,
5545
+ replacement:
5546
+ urlInfo.type === "js_classic" ||
5547
+ urlInfo.type === "js_module" ||
5548
+ urlInfo.type === "html"
5549
+ ? JSON.stringify(value, null, " ")
5550
+ : value,
5551
+ });
5552
+ index = content.indexOf(key, end);
5553
+ }
5554
+ }
5555
+ return magicSource.toContentAndSourcemap();
5556
+ };
5557
+
5428
5558
  /*
5429
5559
  * NICE TO HAVE:
5430
5560
  *
@@ -5451,7 +5581,7 @@ const FILE_AND_SERVER_URLS_CONVERTER = {
5451
5581
 
5452
5582
 
5453
5583
  const htmlFileUrlForDirectory = import.meta.resolve(
5454
- "./client/directory_listing/directory_listing.html",
5584
+ "../client/directory_listing/directory_listing.html",
5455
5585
  );
5456
5586
 
5457
5587
  const jsenvPluginDirectoryListing = ({
@@ -5533,7 +5663,7 @@ const jsenvPluginDirectoryListing = ({
5533
5663
  }
5534
5664
  const request = urlInfo.context.request;
5535
5665
  const { rootDirectoryUrl, mainFilePath } = urlInfo.context;
5536
- return replacePlaceholders$1(
5666
+ return replacePlaceholders(
5537
5667
  urlInfo.content,
5538
5668
  {
5539
5669
  ...generateDirectoryListingInjection(requestedUrl, {
@@ -6143,6 +6273,12 @@ const jsenvPluginProtocolHttp = ({ include }) => {
6143
6273
  // }
6144
6274
  // return null;
6145
6275
  // },
6276
+ init: (context) => {
6277
+ const outDirectoryUrl = context.outDirectoryUrl;
6278
+ if (!outDirectoryUrl) {
6279
+ throw new Error(`need outDirectoryUrl to write http files`);
6280
+ }
6281
+ },
6146
6282
  redirectReference: (reference) => {
6147
6283
  if (!reference.url.startsWith("http")) {
6148
6284
  return null;
@@ -6847,7 +6983,7 @@ const babelPluginMetadataImportMetaScenarios = () => {
6847
6983
 
6848
6984
  const jsenvPluginGlobalScenarios = () => {
6849
6985
  const transformIfNeeded = (urlInfo) => {
6850
- return replacePlaceholders$1(
6986
+ return replacePlaceholders(
6851
6987
  urlInfo.content,
6852
6988
  {
6853
6989
  __DEV__: INJECTIONS.optional(urlInfo.context.dev),
@@ -7128,7 +7264,7 @@ const htmlNodeCanHotReload = (node) => {
7128
7264
 
7129
7265
  const jsenvPluginImportMetaHot = () => {
7130
7266
  const importMetaHotClientFileUrl = import.meta.resolve(
7131
- "./client/import_meta_hot/import_meta_hot.js",
7267
+ "../client/import_meta_hot/import_meta_hot.js",
7132
7268
  );
7133
7269
 
7134
7270
  return {
@@ -7240,7 +7376,7 @@ import.meta.hot = createImportMetaHot(import.meta.url);
7240
7376
  };
7241
7377
 
7242
7378
  const jsenvPluginAutoreloadClient = () => {
7243
- const autoreloadClientFileUrl = import.meta.resolve("./client/autoreload/autoreload.js");
7379
+ const autoreloadClientFileUrl = import.meta.resolve("../client/autoreload/autoreload.js");
7244
7380
 
7245
7381
  return {
7246
7382
  name: "jsenv:autoreload_client",
@@ -7759,7 +7895,7 @@ const jsenvPluginRibbon = ({
7759
7895
  rootDirectoryUrl,
7760
7896
  htmlInclude = "/**/*.html",
7761
7897
  }) => {
7762
- const ribbonClientFileUrl = import.meta.resolve("./client/ribbon/ribbon.js");
7898
+ const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
7763
7899
  const associations = URL_META.resolveAssociations(
7764
7900
  {
7765
7901
  ribbon: {
@@ -7941,4 +8077,796 @@ const getCorePlugins = ({
7941
8077
  ];
7942
8078
  };
7943
8079
 
7944
- export { GRAPH_VISITOR, createEventEmitter, createKitchen, createPluginController, createPluginStore, defaultRuntimeCompat, getCorePlugins, getDefaultBase, isWebWorkerUrlInfo, jsenvPluginDirectoryReferenceEffect, jsenvPluginInlining, jsenvPluginReferenceAnalysis, logsDefault, prependContent, watchSourceFiles };
8080
+ /*
8081
+ * This plugin is very special because it is here
8082
+ * to provide "serverEvents" used by other plugins
8083
+ */
8084
+
8085
+
8086
+ const serverEventsClientFileUrl = new URL(
8087
+ "../client/server_events_client/server_events_client.js",
8088
+ import.meta.url,
8089
+ ).href;
8090
+
8091
+ const jsenvPluginServerEvents = ({ clientAutoreload }) => {
8092
+ let serverEvents = new ServerEvents({
8093
+ actionOnClientLimitReached: "kick-oldest",
8094
+ });
8095
+ const { clientServerEventsConfig } = clientAutoreload;
8096
+ const { logs = true } = clientServerEventsConfig;
8097
+
8098
+ return {
8099
+ name: "jsenv:server_events",
8100
+ appliesDuring: "dev",
8101
+ effect: ({ kitchenContext, otherPlugins }) => {
8102
+ const allServerEvents = {};
8103
+ for (const otherPlugin of otherPlugins) {
8104
+ const { serverEvents } = otherPlugin;
8105
+ if (!serverEvents) {
8106
+ continue;
8107
+ }
8108
+ for (const serverEventName of Object.keys(serverEvents)) {
8109
+ // we could throw on serverEvent name conflict
8110
+ // we could throw if serverEvents[serverEventName] is not a function
8111
+ allServerEvents[serverEventName] = serverEvents[serverEventName];
8112
+ }
8113
+ }
8114
+ const serverEventNames = Object.keys(allServerEvents);
8115
+ if (serverEventNames.length === 0) {
8116
+ return false;
8117
+ }
8118
+
8119
+ const onabort = () => {
8120
+ serverEvents.close();
8121
+ };
8122
+ kitchenContext.signal.addEventListener("abort", onabort);
8123
+ for (const serverEventName of Object.keys(allServerEvents)) {
8124
+ const serverEventInfo = {
8125
+ ...kitchenContext,
8126
+ // serverEventsDispatcher variable is safe, we can disable esling warning
8127
+ // eslint-disable-next-line no-loop-func
8128
+ sendServerEvent: (data) => {
8129
+ if (!serverEvents) {
8130
+ // this can happen if a plugin wants to send a server event but
8131
+ // server is closing or the plugin got destroyed but still wants to do things
8132
+ // if plugin code is correctly written it is never supposed to happen
8133
+ // because it means a plugin is still trying to do stuff after being destroyed
8134
+ return;
8135
+ }
8136
+ serverEvents.sendEventToAllClients({
8137
+ type: serverEventName,
8138
+ data,
8139
+ });
8140
+ },
8141
+ };
8142
+ const serverEventInit = allServerEvents[serverEventName];
8143
+ serverEventInit(serverEventInfo);
8144
+ }
8145
+ return () => {
8146
+ kitchenContext.signal.removeEventListener("abort", onabort);
8147
+ serverEvents.close();
8148
+ serverEvents = undefined;
8149
+ };
8150
+ },
8151
+ transformUrlContent: {
8152
+ html: (urlInfo) => {
8153
+ const htmlAst = parseHtml({
8154
+ html: urlInfo.content,
8155
+ url: urlInfo.url,
8156
+ });
8157
+ injectJsenvScript(htmlAst, {
8158
+ src: serverEventsClientFileUrl,
8159
+ initCall: {
8160
+ callee: "window.__server_events__.setup",
8161
+ params: {
8162
+ logs,
8163
+ },
8164
+ },
8165
+ pluginName: "jsenv:server_events",
8166
+ });
8167
+ return stringifyHtmlAst(htmlAst);
8168
+ },
8169
+ },
8170
+ devServerRoutes: [
8171
+ {
8172
+ endpoint: "GET /.internal/events.websocket",
8173
+ description: `Jsenv dev server emit server events on this endpoint. When a file is saved the "reload" event is sent here.`,
8174
+ fetch: serverEvents.fetch,
8175
+ declarationSource: import.meta.url,
8176
+ },
8177
+ ],
8178
+ };
8179
+ };
8180
+
8181
+ const requireFromJsenv = createRequire(import.meta.url);
8182
+
8183
+ const parseUserAgentHeader = memoizeByFirstArgument((userAgent) => {
8184
+ if (userAgent.includes("node-fetch/")) {
8185
+ // it's not really node and conceptually we can't assume the node version
8186
+ // but good enough for now
8187
+ return {
8188
+ runtimeName: "node",
8189
+ runtimeVersion: process.version.slice(1),
8190
+ };
8191
+ }
8192
+ const UA = requireFromJsenv("@financial-times/polyfill-useragent-normaliser");
8193
+ const { ua } = new UA(userAgent);
8194
+ const { family, major, minor, patch } = ua;
8195
+ return {
8196
+ runtimeName: family.toLowerCase(),
8197
+ runtimeVersion:
8198
+ family === "Other" ? "unknown" : `${major}.${minor}${patch}`,
8199
+ };
8200
+ });
8201
+
8202
+ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
8203
+
8204
+ /**
8205
+ * Starts the development server.
8206
+ *
8207
+ * @param {Object} [params={}] - Configuration params for the dev server.
8208
+ * @param {number} [params.port=3456] - Port number the server should listen on.
8209
+ * @param {string} [params.hostname="localhost"] - Hostname to bind the server to.
8210
+ * @param {boolean} [params.https=false] - Whether to use HTTPS.
8211
+ *
8212
+ * @returns {Promise<Object>} A promise that resolves to the server instance.
8213
+ * @throws {Error} Will throw an error if the server fails to start or is called with unexpected params.
8214
+ *
8215
+ * @example
8216
+ * // Start a basic dev server
8217
+ * const server = await startDevServer();
8218
+ * console.log(`Server started at ${server.origin}`);
8219
+ *
8220
+ * @example
8221
+ * // Start a server with custom params
8222
+ * const server = await startDevServer({
8223
+ * port: 8080,
8224
+ * });
8225
+ */
8226
+ const startDevServer = async ({
8227
+ sourceDirectoryUrl,
8228
+ sourceMainFilePath = "./index.html",
8229
+ ignore,
8230
+ port = 3456,
8231
+ hostname,
8232
+ acceptAnyIp,
8233
+ https,
8234
+ // it's better to use http1 by default because it allows to get statusText in devtools
8235
+ // which gives valuable information when there is errors
8236
+ http2 = false,
8237
+ logLevel = EXECUTED_BY_TEST_PLAN ? "warn" : "info",
8238
+ serverLogLevel = "warn",
8239
+ services = [],
8240
+
8241
+ signal = new AbortController().signal,
8242
+ handleSIGINT = true,
8243
+ keepProcessAlive = true,
8244
+ onStop = () => {},
8245
+
8246
+ sourceFilesConfig = {},
8247
+ clientAutoreload = true,
8248
+
8249
+ // runtimeCompat is the runtimeCompat for the build
8250
+ // when specified, dev server use it to warn in case
8251
+ // code would be supported during dev but not after build
8252
+ runtimeCompat = defaultRuntimeCompat,
8253
+ plugins = [],
8254
+ referenceAnalysis = {},
8255
+ nodeEsmResolution,
8256
+ supervisor = true,
8257
+ magicExtensions,
8258
+ magicDirectoryIndex,
8259
+ directoryListing,
8260
+ injections,
8261
+ transpilation,
8262
+ cacheControl = true,
8263
+ ribbon = true,
8264
+ // toolbar = false,
8265
+ onKitchenCreated = () => {},
8266
+
8267
+ sourcemaps = "inline",
8268
+ sourcemapsSourcesContent,
8269
+ outDirectoryUrl,
8270
+ ...rest
8271
+ }) => {
8272
+ // params type checking
8273
+ {
8274
+ const unexpectedParamNames = Object.keys(rest);
8275
+ if (unexpectedParamNames.length > 0) {
8276
+ throw new TypeError(
8277
+ `${unexpectedParamNames.join(",")}: there is no such param`,
8278
+ );
8279
+ }
8280
+ sourceDirectoryUrl = assertAndNormalizeDirectoryUrl(
8281
+ sourceDirectoryUrl,
8282
+ "sourceDirectoryUrl",
8283
+ );
8284
+ if (!existsSync(new URL(sourceDirectoryUrl))) {
8285
+ throw new Error(`ENOENT on sourceDirectoryUrl at ${sourceDirectoryUrl}`);
8286
+ }
8287
+ if (typeof sourceMainFilePath !== "string") {
8288
+ throw new TypeError(
8289
+ `sourceMainFilePath must be a string, got ${sourceMainFilePath}`,
8290
+ );
8291
+ }
8292
+ sourceMainFilePath = urlToRelativeUrl(
8293
+ new URL(sourceMainFilePath, sourceDirectoryUrl),
8294
+ sourceDirectoryUrl,
8295
+ );
8296
+ if (outDirectoryUrl === undefined) {
8297
+ if (
8298
+ process.env.CAPTURING_SIDE_EFFECTS ||
8299
+ (false)
8300
+ ) {
8301
+ outDirectoryUrl = new URL("../.jsenv/", sourceDirectoryUrl);
8302
+ } else {
8303
+ const packageDirectoryUrl = lookupPackageDirectory(sourceDirectoryUrl);
8304
+ if (packageDirectoryUrl) {
8305
+ outDirectoryUrl = `${packageDirectoryUrl}.jsenv/`;
8306
+ }
8307
+ }
8308
+ } else if (outDirectoryUrl !== null && outDirectoryUrl !== false) {
8309
+ outDirectoryUrl = assertAndNormalizeDirectoryUrl(
8310
+ outDirectoryUrl,
8311
+ "outDirectoryUrl",
8312
+ );
8313
+ }
8314
+ }
8315
+
8316
+ // params normalization
8317
+ {
8318
+ if (clientAutoreload === true) {
8319
+ clientAutoreload = {};
8320
+ }
8321
+ if (clientAutoreload === false) {
8322
+ clientAutoreload = { enabled: false };
8323
+ }
8324
+ }
8325
+
8326
+ const logger = createLogger({ logLevel });
8327
+ const startDevServerTask = createTaskLog("start dev server", {
8328
+ disabled: !logger.levels.info,
8329
+ });
8330
+
8331
+ const serverStopCallbackSet = new Set();
8332
+ const serverStopAbortController = new AbortController();
8333
+ serverStopCallbackSet.add(() => {
8334
+ serverStopAbortController.abort();
8335
+ });
8336
+ const serverStopAbortSignal = serverStopAbortController.signal;
8337
+ const kitchenCache = new Map();
8338
+
8339
+ const finalServices = [];
8340
+ // x-server-inspect service
8341
+ {
8342
+ finalServices.push({
8343
+ name: "jsenv:server_header",
8344
+ routes: [
8345
+ {
8346
+ endpoint: "GET /.internal/server.json",
8347
+ description: "Get information about jsenv dev server",
8348
+ availableMediaTypes: ["application/json"],
8349
+ declarationSource: import.meta.url,
8350
+ fetch: () =>
8351
+ Response.json({
8352
+ server: "jsenv_dev_server/1",
8353
+ sourceDirectoryUrl,
8354
+ }),
8355
+ },
8356
+ ],
8357
+ injectResponseProperties: () => {
8358
+ return {
8359
+ headers: {
8360
+ server: "jsenv_dev_server/1",
8361
+ },
8362
+ };
8363
+ },
8364
+ });
8365
+ }
8366
+ // cors service
8367
+ {
8368
+ finalServices.push(
8369
+ jsenvServiceCORS({
8370
+ accessControlAllowRequestOrigin: true,
8371
+ accessControlAllowRequestMethod: true,
8372
+ accessControlAllowRequestHeaders: true,
8373
+ accessControlAllowedRequestHeaders: [
8374
+ ...jsenvAccessControlAllowedHeaders,
8375
+ "x-jsenv-execution-id",
8376
+ ],
8377
+ accessControlAllowCredentials: true,
8378
+ timingAllowOrigin: true,
8379
+ }),
8380
+ );
8381
+ }
8382
+ // custom services
8383
+ {
8384
+ finalServices.push(...services);
8385
+ }
8386
+ // file_service
8387
+ {
8388
+ const clientFileChangeEventEmitter = createEventEmitter();
8389
+ const clientFileDereferencedEventEmitter = createEventEmitter();
8390
+ clientAutoreload = {
8391
+ enabled: true,
8392
+ clientServerEventsConfig: {},
8393
+ clientFileChangeEventEmitter,
8394
+ clientFileDereferencedEventEmitter,
8395
+ ...clientAutoreload,
8396
+ };
8397
+ const stopWatchingSourceFiles = watchSourceFiles(
8398
+ sourceDirectoryUrl,
8399
+ (fileInfo) => {
8400
+ clientFileChangeEventEmitter.emit(fileInfo);
8401
+ },
8402
+ {
8403
+ sourceFilesConfig,
8404
+ keepProcessAlive: false,
8405
+ cooldownBetweenFileEvents: clientAutoreload.cooldownBetweenFileEvents,
8406
+ },
8407
+ );
8408
+ serverStopCallbackSet.add(stopWatchingSourceFiles);
8409
+
8410
+ const devServerPluginStore = createPluginStore([
8411
+ jsenvPluginServerEvents({ clientAutoreload }),
8412
+ ...plugins,
8413
+ ...getCorePlugins({
8414
+ rootDirectoryUrl: sourceDirectoryUrl,
8415
+ mainFilePath: sourceMainFilePath,
8416
+ runtimeCompat,
8417
+ sourceFilesConfig,
8418
+
8419
+ referenceAnalysis,
8420
+ nodeEsmResolution,
8421
+ magicExtensions,
8422
+ magicDirectoryIndex,
8423
+ directoryListing,
8424
+ supervisor,
8425
+ injections,
8426
+ transpilation,
8427
+
8428
+ clientAutoreload,
8429
+ cacheControl,
8430
+ ribbon,
8431
+ }),
8432
+ ]);
8433
+ const getOrCreateKitchen = (request) => {
8434
+ const { runtimeName, runtimeVersion } = parseUserAgentHeader(
8435
+ request.headers["user-agent"] || "",
8436
+ );
8437
+ const runtimeId = `${runtimeName}@${runtimeVersion}`;
8438
+ const existing = kitchenCache.get(runtimeId);
8439
+ if (existing) {
8440
+ return existing;
8441
+ }
8442
+ const watchAssociations = URL_META.resolveAssociations(
8443
+ { watch: stopWatchingSourceFiles.watchPatterns },
8444
+ sourceDirectoryUrl,
8445
+ );
8446
+ let kitchen;
8447
+ clientFileChangeEventEmitter.on(({ url, event }) => {
8448
+ const urlInfo = kitchen.graph.getUrlInfo(url);
8449
+ if (urlInfo) {
8450
+ if (event === "removed") {
8451
+ urlInfo.onRemoved();
8452
+ } else {
8453
+ urlInfo.onModified();
8454
+ }
8455
+ }
8456
+ });
8457
+ const clientRuntimeCompat = { [runtimeName]: runtimeVersion };
8458
+
8459
+ kitchen = createKitchen({
8460
+ name: runtimeId,
8461
+ signal: serverStopAbortSignal,
8462
+ logLevel,
8463
+ rootDirectoryUrl: sourceDirectoryUrl,
8464
+ mainFilePath: sourceMainFilePath,
8465
+ ignore,
8466
+ dev: true,
8467
+ runtimeCompat,
8468
+ clientRuntimeCompat,
8469
+ supervisor,
8470
+ sourcemaps,
8471
+ sourcemapsSourcesContent,
8472
+ outDirectoryUrl: outDirectoryUrl
8473
+ ? new URL(`${runtimeName}@${runtimeVersion}/`, outDirectoryUrl)
8474
+ : undefined,
8475
+ });
8476
+ kitchen.graph.urlInfoCreatedEventEmitter.on((urlInfoCreated) => {
8477
+ const { watch } = URL_META.applyAssociations({
8478
+ url: urlInfoCreated.url,
8479
+ associations: watchAssociations,
8480
+ });
8481
+ urlInfoCreated.isWatched = watch;
8482
+ // when an url depends on many others, we check all these (like package.json)
8483
+ urlInfoCreated.isValid = () => {
8484
+ if (!urlInfoCreated.url.startsWith("file:")) {
8485
+ return false;
8486
+ }
8487
+ if (urlInfoCreated.content === undefined) {
8488
+ // urlInfo content is undefined when:
8489
+ // - url info content never fetched
8490
+ // - it is considered as modified because undelying file is watched and got saved
8491
+ // - it is considered as modified because underlying file content
8492
+ // was compared using etag and it has changed
8493
+ return false;
8494
+ }
8495
+ if (!watch) {
8496
+ // file is not watched, check the filesystem
8497
+ let fileContentAsBuffer;
8498
+ try {
8499
+ fileContentAsBuffer = readFileSync(new URL(urlInfoCreated.url));
8500
+ } catch (e) {
8501
+ if (e.code === "ENOENT") {
8502
+ urlInfoCreated.onModified();
8503
+ return false;
8504
+ }
8505
+ return false;
8506
+ }
8507
+ const fileContentEtag = bufferToEtag(fileContentAsBuffer);
8508
+ if (fileContentEtag !== urlInfoCreated.originalContentEtag) {
8509
+ urlInfoCreated.onModified();
8510
+ // restore content to be able to compare it again later
8511
+ urlInfoCreated.kitchen.urlInfoTransformer.setContent(
8512
+ urlInfoCreated,
8513
+ String(fileContentAsBuffer),
8514
+ {
8515
+ contentEtag: fileContentEtag,
8516
+ },
8517
+ );
8518
+ return false;
8519
+ }
8520
+ }
8521
+ for (const implicitUrl of urlInfoCreated.implicitUrlSet) {
8522
+ const implicitUrlInfo =
8523
+ urlInfoCreated.graph.getUrlInfo(implicitUrl);
8524
+ if (!implicitUrlInfo) {
8525
+ continue;
8526
+ }
8527
+ if (implicitUrlInfo.content === undefined) {
8528
+ // happens when we explicitely load an url with a search param
8529
+ // - it creates an implicit url info to the url without params
8530
+ // - we never explicitely request the url without search param so it has no content
8531
+ // in that case the underlying urlInfo cannot be invalidate by the implicit
8532
+ // we use modifiedTimestamp to detect if the url was loaded once
8533
+ // or is just here to be used later
8534
+ if (implicitUrlInfo.modifiedTimestamp) {
8535
+ return false;
8536
+ }
8537
+ continue;
8538
+ }
8539
+ if (!implicitUrlInfo.isValid()) {
8540
+ return false;
8541
+ }
8542
+ }
8543
+ return true;
8544
+ };
8545
+ });
8546
+ kitchen.graph.urlInfoDereferencedEventEmitter.on(
8547
+ (urlInfoDereferenced, lastReferenceFromOther) => {
8548
+ clientFileDereferencedEventEmitter.emit(
8549
+ urlInfoDereferenced,
8550
+ lastReferenceFromOther,
8551
+ );
8552
+ },
8553
+ );
8554
+ const devServerPluginController = createPluginController(
8555
+ devServerPluginStore,
8556
+ kitchen,
8557
+ );
8558
+ kitchen.setPluginController(devServerPluginController);
8559
+
8560
+ serverStopCallbackSet.add(() => {
8561
+ devServerPluginController.callHooks("destroy", kitchen.context);
8562
+ });
8563
+ kitchenCache.set(runtimeId, kitchen);
8564
+ onKitchenCreated(kitchen);
8565
+ return kitchen;
8566
+ };
8567
+
8568
+ finalServices.push({
8569
+ name: "jsenv:dev_server_routes",
8570
+ augmentRouteFetchSecondArg: (request) => {
8571
+ const kitchen = getOrCreateKitchen(request);
8572
+ return { kitchen };
8573
+ },
8574
+ routes: [
8575
+ ...devServerPluginStore.allDevServerRoutes,
8576
+ {
8577
+ endpoint: "GET *",
8578
+ description: "Serve project files.",
8579
+ declarationSource: import.meta.url,
8580
+ fetch: async (request, { kitchen }) => {
8581
+ const { rootDirectoryUrl, mainFilePath } = kitchen.context;
8582
+ let requestResource = request.resource;
8583
+ let requestedUrl;
8584
+ if (requestResource.startsWith("/@fs/")) {
8585
+ const fsRootRelativeUrl = requestResource.slice("/@fs/".length);
8586
+ requestedUrl = `file:///${fsRootRelativeUrl}`;
8587
+ } else {
8588
+ const requestedUrlObject = new URL(
8589
+ requestResource === "/"
8590
+ ? mainFilePath
8591
+ : requestResource.slice(1),
8592
+ rootDirectoryUrl,
8593
+ );
8594
+ requestedUrlObject.searchParams.delete("hot");
8595
+ requestedUrl = requestedUrlObject.href;
8596
+ }
8597
+ const { referer } = request.headers;
8598
+ const parentUrl = referer
8599
+ ? WEB_URL_CONVERTER.asFileUrl(referer, {
8600
+ origin: request.origin,
8601
+ rootDirectoryUrl: sourceDirectoryUrl,
8602
+ })
8603
+ : sourceDirectoryUrl;
8604
+ let reference = kitchen.graph.inferReference(
8605
+ request.resource,
8606
+ parentUrl,
8607
+ );
8608
+ if (reference) {
8609
+ reference.urlInfo.context.request = request;
8610
+ reference.urlInfo.context.requestedUrl = requestedUrl;
8611
+ } else {
8612
+ const rootUrlInfo = kitchen.graph.rootUrlInfo;
8613
+ rootUrlInfo.context.request = request;
8614
+ rootUrlInfo.context.requestedUrl = requestedUrl;
8615
+ reference = rootUrlInfo.dependencies.createResolveAndFinalize({
8616
+ trace: { message: parentUrl },
8617
+ type: "http_request",
8618
+ specifier: request.resource,
8619
+ });
8620
+ rootUrlInfo.context.request = null;
8621
+ rootUrlInfo.context.requestedUrl = null;
8622
+ }
8623
+ const urlInfo = reference.urlInfo;
8624
+ const ifNoneMatch = request.headers["if-none-match"];
8625
+ const urlInfoTargetedByCache =
8626
+ urlInfo.findParentIfInline() || urlInfo;
8627
+
8628
+ try {
8629
+ if (!urlInfo.error && ifNoneMatch) {
8630
+ const [clientOriginalContentEtag, clientContentEtag] =
8631
+ ifNoneMatch.split("_");
8632
+ if (
8633
+ urlInfoTargetedByCache.originalContentEtag ===
8634
+ clientOriginalContentEtag &&
8635
+ urlInfoTargetedByCache.contentEtag === clientContentEtag &&
8636
+ urlInfoTargetedByCache.isValid()
8637
+ ) {
8638
+ const headers = {
8639
+ "cache-control": `private,max-age=0,must-revalidate`,
8640
+ };
8641
+ Object.keys(urlInfo.headers).forEach((key) => {
8642
+ if (key !== "content-length") {
8643
+ headers[key] = urlInfo.headers[key];
8644
+ }
8645
+ });
8646
+ return {
8647
+ status: 304,
8648
+ headers,
8649
+ };
8650
+ }
8651
+ }
8652
+ await urlInfo.cook({ request, reference });
8653
+ let { response } = urlInfo;
8654
+ if (response) {
8655
+ return response;
8656
+ }
8657
+ response = {
8658
+ url: reference.url,
8659
+ status: 200,
8660
+ headers: {
8661
+ // when we send eTag to the client the next request to the server
8662
+ // will send etag in request headers.
8663
+ // If they match jsenv bypass cooking and returns 304
8664
+ // This must not happen when a plugin uses "no-store" or "no-cache" as it means
8665
+ // plugin logic wants to happens for every request to this url
8666
+ ...(cacheIsDisabledInResponseHeader(urlInfoTargetedByCache)
8667
+ ? {
8668
+ "cache-control": "no-store", // for inline file we force no-store when parent is no-store
8669
+ }
8670
+ : {
8671
+ "cache-control": `private,max-age=0,must-revalidate`,
8672
+ // it's safe to use "_" separator because etag is encoded with base64 (see https://stackoverflow.com/a/13195197)
8673
+ "eTag": `${urlInfoTargetedByCache.originalContentEtag}_${urlInfoTargetedByCache.contentEtag}`,
8674
+ }),
8675
+ ...urlInfo.headers,
8676
+ "content-type": urlInfo.contentType,
8677
+ "content-length": urlInfo.contentLength,
8678
+ },
8679
+ body: urlInfo.content,
8680
+ timing: urlInfo.timing, // TODO: use something else
8681
+ };
8682
+ const augmentResponseInfo = {
8683
+ ...kitchen.context,
8684
+ reference,
8685
+ urlInfo,
8686
+ };
8687
+ kitchen.pluginController.callHooks(
8688
+ "augmentResponse",
8689
+ augmentResponseInfo,
8690
+ (returnValue) => {
8691
+ response = composeTwoResponses(response, returnValue);
8692
+ },
8693
+ );
8694
+ return response;
8695
+ } catch (error) {
8696
+ const originalError = error ? error.cause || error : error;
8697
+ if (originalError.asResponse) {
8698
+ return originalError.asResponse();
8699
+ }
8700
+ const code = originalError.code;
8701
+ if (code === "PARSE_ERROR") {
8702
+ // when possible let browser re-throw the syntax error
8703
+ // it's not possible to do that when url info content is not available
8704
+ // (happens for js_module_fallback for instance)
8705
+ if (urlInfo.content !== undefined) {
8706
+ kitchen.context.logger
8707
+ .error(`Error while handling ${request.url}:
8708
+ ${originalError.reasonCode || originalError.code}
8709
+ ${error.trace?.message}`);
8710
+ return {
8711
+ url: reference.url,
8712
+ status: 200,
8713
+ // reason becomes the http response statusText, it must not contain invalid chars
8714
+ // https://github.com/nodejs/node/blob/0c27ca4bc9782d658afeaebcec85ec7b28f1cc35/lib/_http_common.js#L221
8715
+ statusText: error.reason,
8716
+ statusMessage: originalError.message,
8717
+ headers: {
8718
+ "content-type": urlInfo.contentType,
8719
+ "content-length": urlInfo.contentLength,
8720
+ "cache-control": "no-store",
8721
+ },
8722
+ body: urlInfo.content,
8723
+ };
8724
+ }
8725
+ return {
8726
+ url: reference.url,
8727
+ status: 500,
8728
+ statusText: error.reason,
8729
+ statusMessage: originalError.message,
8730
+ headers: {
8731
+ "cache-control": "no-store",
8732
+ },
8733
+ body: urlInfo.content,
8734
+ };
8735
+ }
8736
+ if (code === "DIRECTORY_REFERENCE_NOT_ALLOWED") {
8737
+ return serveDirectory(reference.url, {
8738
+ headers: {
8739
+ accept: "text/html",
8740
+ },
8741
+ canReadDirectory: true,
8742
+ rootDirectoryUrl: sourceDirectoryUrl,
8743
+ });
8744
+ }
8745
+ if (code === "NOT_ALLOWED") {
8746
+ return {
8747
+ url: reference.url,
8748
+ status: 403,
8749
+ statusText: originalError.reason,
8750
+ };
8751
+ }
8752
+ if (code === "NOT_FOUND") {
8753
+ return {
8754
+ url: reference.url,
8755
+ status: 404,
8756
+ statusText: originalError.reason,
8757
+ statusMessage: originalError.message,
8758
+ };
8759
+ }
8760
+ return {
8761
+ url: reference.url,
8762
+ status: 500,
8763
+ statusText: error.reason,
8764
+ statusMessage: error.stack,
8765
+ headers: {
8766
+ "cache-control": "no-store",
8767
+ },
8768
+ };
8769
+ }
8770
+ },
8771
+ },
8772
+ ],
8773
+ });
8774
+ }
8775
+ // jsenv error handler service
8776
+ {
8777
+ finalServices.push({
8778
+ name: "jsenv:omega_error_handler",
8779
+ handleError: (error) => {
8780
+ const getResponseForError = () => {
8781
+ if (error && error.asResponse) {
8782
+ return error.asResponse();
8783
+ }
8784
+ if (error && error.statusText === "Unexpected directory operation") {
8785
+ return {
8786
+ status: 403,
8787
+ };
8788
+ }
8789
+ return convertFileSystemErrorToResponseProperties(error);
8790
+ };
8791
+ const response = getResponseForError();
8792
+ if (!response) {
8793
+ return null;
8794
+ }
8795
+ const body = JSON.stringify({
8796
+ status: response.status,
8797
+ statusText: response.statusText,
8798
+ headers: response.headers,
8799
+ body: response.body,
8800
+ });
8801
+ return {
8802
+ status: 200,
8803
+ headers: {
8804
+ "content-type": "application/json",
8805
+ "content-length": Buffer.byteLength(body),
8806
+ },
8807
+ body,
8808
+ };
8809
+ },
8810
+ });
8811
+ }
8812
+ // default error handler
8813
+ {
8814
+ finalServices.push(
8815
+ jsenvServiceErrorHandler({
8816
+ sendErrorDetails: true,
8817
+ }),
8818
+ );
8819
+ }
8820
+
8821
+ const server = await startServer({
8822
+ signal,
8823
+ stopOnExit: false,
8824
+ stopOnSIGINT: handleSIGINT,
8825
+ stopOnInternalError: false,
8826
+ keepProcessAlive,
8827
+ logLevel: serverLogLevel,
8828
+ startLog: false,
8829
+
8830
+ https,
8831
+ http2,
8832
+ acceptAnyIp,
8833
+ hostname,
8834
+ port,
8835
+ requestWaitingMs: 60_000,
8836
+ services: finalServices,
8837
+ });
8838
+ server.stoppedPromise.then((reason) => {
8839
+ onStop();
8840
+ for (const serverStopCallback of serverStopCallbackSet) {
8841
+ serverStopCallback(reason);
8842
+ }
8843
+ serverStopCallbackSet.clear();
8844
+ });
8845
+ startDevServerTask.done();
8846
+ if (hostname) {
8847
+ delete server.origins.localip;
8848
+ delete server.origins.externalip;
8849
+ }
8850
+ logger.info(``);
8851
+ Object.keys(server.origins).forEach((key) => {
8852
+ logger.info(`- ${server.origins[key]}`);
8853
+ });
8854
+ logger.info(``);
8855
+ return {
8856
+ origin: server.origin,
8857
+ sourceDirectoryUrl,
8858
+ stop: () => {
8859
+ server.stop();
8860
+ },
8861
+ kitchenCache,
8862
+ };
8863
+ };
8864
+
8865
+ const cacheIsDisabledInResponseHeader = (urlInfo) => {
8866
+ return (
8867
+ urlInfo.headers["cache-control"] === "no-store" ||
8868
+ urlInfo.headers["cache-control"] === "no-cache"
8869
+ );
8870
+ };
8871
+
8872
+ export { startDevServer };