@jsenv/core 41.4.10 → 41.4.12

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,4 +1,6 @@
1
- import { startServer, createFileSystemFetch, serverPluginCORS, jsenvAccessControlAllowedHeaders, serverPluginErrorHandler } from "@jsenv/server";
1
+ import { createFileSystemFetch, startServer, serverPluginCORS, jsenvAccessControlAllowedHeaders, serverPluginErrorHandler } from "@jsenv/server";
2
+ import { parseHtml, injectJsenvScript, stringifyHtmlAst } from "@jsenv/ast";
3
+ import { readFileSync } from "node:fs";
2
4
  import { createLogger, Abort, raceProcessTeardownEvents, createTaskLog } from "./jsenv_core_packages.js";
3
5
  import "./jsenv_core_node_modules.js";
4
6
  import "node:process";
@@ -6,6 +8,43 @@ import "node:os";
6
8
  import "node:tty";
7
9
  import "node:util";
8
10
 
11
+ /*
12
+ * Inject the ribbon into an html string, inlining the client code.
13
+ *
14
+ * Used when serving files that must not be modified on the filesystem (the build
15
+ * directory served by startBuildServer): the ribbon exists only in what the
16
+ * server sends, never in the artifact that gets deployed.
17
+ */
18
+
19
+
20
+ const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
21
+ let ribbonClientFileContent;
22
+
23
+ const injectRibbonIntoHtml = (
24
+ html,
25
+ { text = "BUILD", color, textColor, href, target, position } = {},
26
+ ) => {
27
+ if (ribbonClientFileContent === undefined) {
28
+ ribbonClientFileContent = String(
29
+ readFileSync(new URL(ribbonClientFileUrl)),
30
+ );
31
+ }
32
+ const htmlAst = parseHtml({ html, url: "file:///ribbon.html" });
33
+ const params = { text, color, textColor, href, target, position };
34
+ for (const key of Object.keys(params)) {
35
+ if (params[key] === undefined) {
36
+ delete params[key];
37
+ }
38
+ }
39
+ injectJsenvScript(htmlAst, {
40
+ type: "module",
41
+ content: `${ribbonClientFileContent}
42
+ injectRibbon(${JSON.stringify(params)});`,
43
+ pluginName: "jsenv:ribbon",
44
+ });
45
+ return stringifyHtmlAst(htmlAst);
46
+ };
47
+
9
48
  /*
10
49
  * startBuildServer is mean to interact with the build files;
11
50
  * files that will be deployed to production server(s).
@@ -26,11 +65,16 @@ import "node:util";
26
65
  * Start a server for build files.
27
66
  * @param {Object} buildServerParameters
28
67
  * @param {string|url} buildServerParameters.buildDirectoryUrl Directory where build files are written
68
+ * @param {boolean|object} [buildServerParameters.ribbon=false] Inject a ribbon in the html responses,
69
+ * marking the page as non-production. The build directory itself is left untouched.
70
+ * As an object: `text`, `color`, `textColor`, `href`, `target`, `position`
71
+ * (see startDevServer "ribbon" param).
29
72
  * @return {Object} A build server object
30
73
  */
31
74
  const startBuildServer = async ({
32
75
  buildDirectoryUrl,
33
76
  buildDirectoryMainFileRelativeUrl = "index.html",
77
+ ribbon = false,
34
78
  port = 9779,
35
79
  routes = [],
36
80
  serverPlugins = [],
@@ -46,6 +90,9 @@ const startBuildServer = async ({
46
90
  keepProcessAlive = true,
47
91
  }) => {
48
92
  const logger = createLogger({ logLevel });
93
+ if (ribbon === true) {
94
+ ribbon = {};
95
+ }
49
96
  const operation = Abort.startOperation();
50
97
  operation.addAbortSignal(signal);
51
98
  if (handleSIGINT) {
@@ -58,6 +105,9 @@ const startBuildServer = async ({
58
105
  });
59
106
  }
60
107
 
108
+ const fileSystemFetch = createFileSystemFetch(buildDirectoryUrl, {
109
+ mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
110
+ });
61
111
  const startBuildServerTask = createTaskLog("start build server", {
62
112
  disabled: !logger.levels.info,
63
113
  });
@@ -96,9 +146,9 @@ const startBuildServer = async ({
96
146
  {
97
147
  endpoint: "GET /",
98
148
  description: "Serve build files",
99
- fetch: createFileSystemFetch(buildDirectoryUrl, {
100
- mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
101
- }),
149
+ fetch: ribbon
150
+ ? withRibbonInjectedInHtml(fileSystemFetch, ribbon)
151
+ : fileSystemFetch,
102
152
  },
103
153
  ],
104
154
  });
@@ -120,4 +170,44 @@ const startBuildServer = async ({
120
170
  };
121
171
  };
122
172
 
173
+ const withRibbonInjectedInHtml = (fetch, ribbon) => {
174
+ return async (request, helpers) => {
175
+ const response = await fetch(request, helpers);
176
+ if (!response || response.status !== 200) {
177
+ return response;
178
+ }
179
+ const contentType = response.headers?.["content-type"];
180
+ if (!contentType || !contentType.startsWith("text/html")) {
181
+ return response;
182
+ }
183
+ const html = await readResponseBodyAsString(response.body);
184
+ const htmlWithRibbon = injectRibbonIntoHtml(html, ribbon);
185
+ const bodyBuffer = Buffer.from(htmlWithRibbon);
186
+ const headers = {
187
+ ...response.headers,
188
+ "content-length": bodyBuffer.length,
189
+ };
190
+ // the body is generated per request; what the file on disk hashes to or when
191
+ // it was modified does not describe it
192
+ delete headers.etag;
193
+ delete headers["last-modified"];
194
+ return {
195
+ ...response,
196
+ headers,
197
+ body: bodyBuffer,
198
+ };
199
+ };
200
+ };
201
+
202
+ const readResponseBodyAsString = async (body) => {
203
+ if (typeof body === "string" || Buffer.isBuffer(body)) {
204
+ return String(body);
205
+ }
206
+ const chunks = [];
207
+ for await (const chunk of body) {
208
+ chunks.push(Buffer.from(chunk));
209
+ }
210
+ return String(Buffer.concat(chunks));
211
+ };
212
+
123
213
  export { startBuildServer };
@@ -1,6 +1,6 @@
1
1
  import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
2
  import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
3
- import { registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, urlToRelativeUrl, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, registerDirectoryLifecycle, asUrlWithoutSearch, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, urlToFilename, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, createDetailedMessage, stringifyUrlSite, injectQueryParamsIntoSpecifier, isSpecifierForNodeBuiltin, injectQueryParams, urlToFileSystemPath, writeFileSync, moveUrl, ensureWindowsDriveLetter, validateResponseIntegrity, setUrlFilename, getCallerPosition, asSpecifierWithoutSearch, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, createLogger, normalizeUrl, ANSI, RUNTIME_COMPAT, formatError, assertAndNormalizeDirectoryUrl, createTaskLog } from "./jsenv_core_packages.js";
3
+ import { urlToRelativeUrl, registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, registerDirectoryLifecycle, asUrlWithoutSearch, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, urlToFilename, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, createDetailedMessage, stringifyUrlSite, injectQueryParamsIntoSpecifier, isSpecifierForNodeBuiltin, injectQueryParams, urlToFileSystemPath, writeFileSync, moveUrl, ensureWindowsDriveLetter, validateResponseIntegrity, setUrlFilename, getCallerPosition, asSpecifierWithoutSearch, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, createLogger, normalizeUrl, ANSI, RUNTIME_COMPAT, formatError, assertAndNormalizeDirectoryUrl, createTaskLog } from "./jsenv_core_packages.js";
4
4
  import { createPluginsController } from "@jsenv/server/src/plugins_controller.js";
5
5
  import { parseHtml, injectJsenvScript, stringifyHtmlAst, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, applyBabelPlugins, analyzeLinkNode, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, generateUrlForInlineContent, parseJsWithAcorn } from "@jsenv/ast";
6
6
  import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
@@ -144,12 +144,21 @@ const createStatus = (
144
144
  declaredVersion,
145
145
  declaredBy,
146
146
  installedVersion: null,
147
+ // the file telling this dependency apart; it is what an install rewrites and
148
+ // what the dev server looks at to know the dependency became the declared one
149
+ watchedPath: null,
147
150
  state: "missing",
148
151
  };
149
152
  const installedDirectoryUrl = findInstalledDirectoryUrl(
150
153
  declaringDirectoryUrl,
151
154
  packageName,
152
155
  );
156
+ status.watchedPath = watchedPathFromDirectoryUrl(
157
+ packageDirectory,
158
+ // not installed yet: name the place where it is expected to appear
159
+ installedDirectoryUrl ||
160
+ `${declaringDirectoryUrl}node_modules/${packageName}/`,
161
+ );
153
162
  if (!installedDirectoryUrl) {
154
163
  return status;
155
164
  }
@@ -168,6 +177,14 @@ const createStatus = (
168
177
  return status;
169
178
  };
170
179
 
180
+ const watchedPathFromDirectoryUrl = (packageDirectory, directoryUrl) => {
181
+ const packageJsonUrl = `${directoryUrl}package.json`;
182
+ if (!packageDirectory.url) {
183
+ return packageJsonUrl;
184
+ }
185
+ return urlToRelativeUrl(packageJsonUrl, packageDirectory.url);
186
+ };
187
+
171
188
  const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
172
189
  let directoryUrl = declaringDirectoryUrl;
173
190
  while (directoryUrl) {
@@ -232,8 +249,13 @@ const watchDependencies = (
232
249
  { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
233
250
  ) => {
234
251
  let problemMap = new Map();
252
+ // every path given to the browser is relative to the package directory, the
253
+ // one holding the package.json being watched
254
+ const packageJsonPath = "package.json";
235
255
  const watcher = {
236
256
  getProblems: () => Array.from(problemMap.values()),
257
+ // what the browser needs to display the watching in progress
258
+ getWatchInfo: () => ({ packageJsonPath, pollInterval }),
237
259
  stop: () => {},
238
260
  };
239
261
  if (!packageDirectory.url) {
@@ -7122,6 +7144,7 @@ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
7122
7144
  const jsenvPluginDependencyStatus = ({
7123
7145
  dependencyProblemEventEmitter,
7124
7146
  getDependencyProblems,
7147
+ getDependencyWatchInfo = () => ({}),
7125
7148
  }) => {
7126
7149
  return {
7127
7150
  name: "jsenv:dependency_status",
@@ -7158,7 +7181,10 @@ const jsenvPluginDependencyStatus = ({
7158
7181
  src: clientReference.generatedSpecifier,
7159
7182
  initCall: {
7160
7183
  callee: "initDependencyStatus",
7161
- params: { problems: getDependencyProblems() },
7184
+ params: {
7185
+ problems: getDependencyProblems(),
7186
+ watchInfo: getDependencyWatchInfo(),
7187
+ },
7162
7188
  },
7163
7189
  pluginName: "jsenv:dependency_status",
7164
7190
  });
@@ -7232,6 +7258,12 @@ const jsenvPluginCustomElementsRedefine = () => {
7232
7258
  const jsenvPluginRibbon = ({
7233
7259
  rootDirectoryUrl,
7234
7260
  htmlInclude = "/**/*.html",
7261
+ text,
7262
+ color,
7263
+ textColor,
7264
+ href,
7265
+ target,
7266
+ position,
7235
7267
  }) => {
7236
7268
  const ribbonClientFileUrl = import.meta.resolve("../client/ribbon/ribbon.js");
7237
7269
  const associations = URL_META.resolveAssociations(
@@ -7244,7 +7276,7 @@ const jsenvPluginRibbon = ({
7244
7276
  );
7245
7277
  return {
7246
7278
  name: "jsenv:ribbon",
7247
- appliesDuring: "dev",
7279
+ appliesDuring: "*",
7248
7280
  transformUrlContent: {
7249
7281
  html: (urlInfo) => {
7250
7282
  const jsenvToolbarHtmlClientFileUrl = urlInfo.context.getPluginMeta(
@@ -7279,9 +7311,19 @@ const jsenvPluginRibbon = ({
7279
7311
  src: ribbonClientFileReference.generatedSpecifier,
7280
7312
  initCall: {
7281
7313
  callee: "injectRibbon",
7282
- params: {
7283
- text: urlInfo.context.dev ? "DEV" : "BUILD",
7284
- },
7314
+ params: withoutUndefinedValues({
7315
+ text:
7316
+ text === undefined
7317
+ ? urlInfo.context.dev
7318
+ ? "DEV"
7319
+ : "BUILD"
7320
+ : text,
7321
+ color,
7322
+ textColor,
7323
+ href,
7324
+ target,
7325
+ position,
7326
+ }),
7285
7327
  },
7286
7328
  pluginName: "jsenv:ribbon",
7287
7329
  });
@@ -7291,6 +7333,16 @@ const jsenvPluginRibbon = ({
7291
7333
  };
7292
7334
  };
7293
7335
 
7336
+ const withoutUndefinedValues = (object) => {
7337
+ const objectWithoutUndefinedValues = {};
7338
+ for (const key of Object.keys(object)) {
7339
+ if (object[key] !== undefined) {
7340
+ objectWithoutUndefinedValues[key] = object[key];
7341
+ }
7342
+ }
7343
+ return objectWithoutUndefinedValues;
7344
+ };
7345
+
7294
7346
  /**
7295
7347
  * HTML page server by jsenv dev server will listen for drop events
7296
7348
  * and redirect the browser to the dropped file location.
@@ -11666,7 +11718,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11666
11718
  * @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
11667
11719
  * @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
11668
11720
  * @param {boolean|object} [params.serverTiming={ minDuration: 0.5 }] - server-timing response headers; `minDuration` (ms) drops entries that took less (0 when run by the test plan, so tests see every entry).
11669
- * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
11721
+ * @param {boolean|object} [params.ribbon=true] - The "ribbon" overlay marking the page as non-production. As an object: `text` (defaults to `"DEV"`), `color` (background, defaults to `"orange"`), `textColor` (defaults to dark or light depending on `color`), `href` (turns the ribbon into a link; only then does it capture clicks), `target` (defaults to `"_blank"`), `position` (`"top-right"` (default), `"top-left"`, `"bottom-right"`, `"bottom-left"` draw a diagonal corner ribbon; `"top"`, `"bottom"` draw a full width band), `htmlInclude` (defaults to `"/**\/*.html"`).
11670
11722
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11671
11723
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
11672
11724
  * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
@@ -11882,6 +11934,7 @@ const startDevServer = async ({
11882
11934
  dependencyStatus: {
11883
11935
  dependencyProblemEventEmitter,
11884
11936
  getDependencyProblems: dependencyWatcher.getProblems,
11937
+ getDependencyWatchInfo: dependencyWatcher.getWatchInfo,
11885
11938
  },
11886
11939
  cacheControl,
11887
11940
  ribbon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.10",
3
+ "version": "41.4.12",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -72,12 +72,12 @@
72
72
  "test:snapshot_clear": "npx @jsenv/filesystem clear **/tests/**/side_effects/"
73
73
  },
74
74
  "dependencies": {
75
- "@jsenv/ast": "6.8.5",
76
- "@jsenv/js-module-fallback": "1.4.38",
75
+ "@jsenv/ast": "6.9.0",
76
+ "@jsenv/js-module-fallback": "1.4.39",
77
77
  "@jsenv/plugin-bundling": "2.10.16",
78
- "@jsenv/plugin-minification": "1.7.6",
79
- "@jsenv/plugin-supervisor": "1.8.9",
80
- "@jsenv/plugin-transpilation": "1.5.79",
78
+ "@jsenv/plugin-minification": "1.7.7",
79
+ "@jsenv/plugin-supervisor": "1.8.10",
80
+ "@jsenv/plugin-transpilation": "1.5.80",
81
81
  "@jsenv/server": "17.6.0",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
@@ -134,6 +134,18 @@ import { jsenvPluginMappings } from "./jsenv_plugin_mappings.js";
134
134
  * How URLs are versioned for this entry point (defaults to "search_param")
135
135
  * @param {('none'|'inline'|'file'|'programmatic')} [entryPoint.sourcemaps]
136
136
  * Sourcemap generation strategy for this entry point (defaults to "none")
137
+ * @param {boolean|object} [entryPoint.ribbon=false]
138
+ * Inject a ribbon marking the page as non-production. Disabled by default
139
+ * because the ribbon ends up inside the build directory: enable it on the
140
+ * builds meant for a preview/review app, not on the production build.
141
+ *
142
+ * ribbon: {
143
+ * text: "preview",
144
+ * color: "#7c3aed",
145
+ * href: "https://github.com/org/repo/pull/42",
146
+ * }
147
+ *
148
+ * See startDevServer "ribbon" param for the full list of options.
137
149
  * @param {object} [entryPoint.injections]
138
150
  * Values to inject into files, as { urlPattern: getInjections }.
139
151
  * Keys are url patterns relative to sourceDirectoryUrl ("./index.html", "**\/*.js"),
@@ -953,6 +965,7 @@ const entryPointDefaultParams = {
953
965
  magicDirectoryIndex: undefined,
954
966
  directoryReferenceEffect: undefined,
955
967
  scenarioPlaceholders: undefined,
968
+ ribbon: false,
956
969
  injections: undefined,
957
970
  transpilation: {},
958
971
  preserveComments: undefined,
@@ -1007,6 +1020,7 @@ const prepareEntryPointBuild = async (
1007
1020
  magicDirectoryIndex,
1008
1021
  directoryReferenceEffect,
1009
1022
  scenarioPlaceholders,
1023
+ ribbon,
1010
1024
  injections,
1011
1025
  transpilation,
1012
1026
  preserveComments,
@@ -1183,6 +1197,7 @@ const prepareEntryPointBuild = async (
1183
1197
  inlining: false,
1184
1198
  http,
1185
1199
  scenarioPlaceholders,
1200
+ ribbon,
1186
1201
  packageSideEffects,
1187
1202
  }),
1188
1203
  ]);
@@ -23,15 +23,22 @@ import {
23
23
  startServer,
24
24
  } from "@jsenv/server";
25
25
 
26
+ import { injectRibbonIntoHtml } from "../plugins/ribbon/ribbon_html_injection.js";
27
+
26
28
  /**
27
29
  * Start a server for build files.
28
30
  * @param {Object} buildServerParameters
29
31
  * @param {string|url} buildServerParameters.buildDirectoryUrl Directory where build files are written
32
+ * @param {boolean|object} [buildServerParameters.ribbon=false] Inject a ribbon in the html responses,
33
+ * marking the page as non-production. The build directory itself is left untouched.
34
+ * As an object: `text`, `color`, `textColor`, `href`, `target`, `position`
35
+ * (see startDevServer "ribbon" param).
30
36
  * @return {Object} A build server object
31
37
  */
32
38
  export const startBuildServer = async ({
33
39
  buildDirectoryUrl,
34
40
  buildDirectoryMainFileRelativeUrl = "index.html",
41
+ ribbon = false,
35
42
  port = 9779,
36
43
  routes = [],
37
44
  serverPlugins = [],
@@ -47,6 +54,9 @@ export const startBuildServer = async ({
47
54
  keepProcessAlive = true,
48
55
  }) => {
49
56
  const logger = createLogger({ logLevel });
57
+ if (ribbon === true) {
58
+ ribbon = {};
59
+ }
50
60
  const operation = Abort.startOperation();
51
61
  operation.addAbortSignal(signal);
52
62
  if (handleSIGINT) {
@@ -60,6 +70,9 @@ export const startBuildServer = async ({
60
70
  });
61
71
  }
62
72
 
73
+ const fileSystemFetch = createFileSystemFetch(buildDirectoryUrl, {
74
+ mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
75
+ });
63
76
  const startBuildServerTask = createTaskLog("start build server", {
64
77
  disabled: !logger.levels.info,
65
78
  });
@@ -98,9 +111,9 @@ export const startBuildServer = async ({
98
111
  {
99
112
  endpoint: "GET /",
100
113
  description: "Serve build files",
101
- fetch: createFileSystemFetch(buildDirectoryUrl, {
102
- mainFileRelativeUrl: buildDirectoryMainFileRelativeUrl,
103
- }),
114
+ fetch: ribbon
115
+ ? withRibbonInjectedInHtml(fileSystemFetch, ribbon)
116
+ : fileSystemFetch,
104
117
  },
105
118
  ],
106
119
  });
@@ -121,3 +134,43 @@ export const startBuildServer = async ({
121
134
  },
122
135
  };
123
136
  };
137
+
138
+ const withRibbonInjectedInHtml = (fetch, ribbon) => {
139
+ return async (request, helpers) => {
140
+ const response = await fetch(request, helpers);
141
+ if (!response || response.status !== 200) {
142
+ return response;
143
+ }
144
+ const contentType = response.headers?.["content-type"];
145
+ if (!contentType || !contentType.startsWith("text/html")) {
146
+ return response;
147
+ }
148
+ const html = await readResponseBodyAsString(response.body);
149
+ const htmlWithRibbon = injectRibbonIntoHtml(html, ribbon);
150
+ const bodyBuffer = Buffer.from(htmlWithRibbon);
151
+ const headers = {
152
+ ...response.headers,
153
+ "content-length": bodyBuffer.length,
154
+ };
155
+ // the body is generated per request; what the file on disk hashes to or when
156
+ // it was modified does not describe it
157
+ delete headers.etag;
158
+ delete headers["last-modified"];
159
+ return {
160
+ ...response,
161
+ headers,
162
+ body: bodyBuffer,
163
+ };
164
+ };
165
+ };
166
+
167
+ const readResponseBodyAsString = async (body) => {
168
+ if (typeof body === "string" || Buffer.isBuffer(body)) {
169
+ return String(body);
170
+ }
171
+ const chunks = [];
172
+ for await (const chunk of body) {
173
+ chunks.push(Buffer.from(chunk));
174
+ }
175
+ return String(Buffer.concat(chunks));
176
+ };
@@ -48,7 +48,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
48
48
  * @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
49
49
  * @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
50
50
  * @param {boolean|object} [params.serverTiming={ minDuration: 0.5 }] - server-timing response headers; `minDuration` (ms) drops entries that took less (0 when run by the test plan, so tests see every entry).
51
- * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
51
+ * @param {boolean|object} [params.ribbon=true] - The "ribbon" overlay marking the page as non-production. As an object: `text` (defaults to `"DEV"`), `color` (background, defaults to `"orange"`), `textColor` (defaults to dark or light depending on `color`), `href` (turns the ribbon into a link; only then does it capture clicks), `target` (defaults to `"_blank"`), `position` (`"top-right"` (default), `"top-left"`, `"bottom-right"`, `"bottom-left"` draw a diagonal corner ribbon; `"top"`, `"bottom"` draw a full width band), `htmlInclude` (defaults to `"/**\/*.html"`).
52
52
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
53
53
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
54
54
  * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
@@ -265,6 +265,7 @@ export const startDevServer = async ({
265
265
  dependencyStatus: {
266
266
  dependencyProblemEventEmitter,
267
267
  getDependencyProblems: dependencyWatcher.getProblems,
268
+ getDependencyWatchInfo: dependencyWatcher.getWatchInfo,
268
269
  },
269
270
  cacheControl,
270
271
  ribbon,
@@ -23,8 +23,13 @@ export const watchDependencies = (
23
23
  { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
24
24
  ) => {
25
25
  let problemMap = new Map();
26
+ // every path given to the browser is relative to the package directory, the
27
+ // one holding the package.json being watched
28
+ const packageJsonPath = "package.json";
26
29
  const watcher = {
27
30
  getProblems: () => Array.from(problemMap.values()),
31
+ // what the browser needs to display the watching in progress
32
+ getWatchInfo: () => ({ packageJsonPath, pollInterval }),
28
33
  stop: () => {},
29
34
  };
30
35
  if (!packageDirectory.url) {
@@ -12,6 +12,7 @@
12
12
  * would pick, which is way beyond what is needed here.
13
13
  */
14
14
 
15
+ import { urlToRelativeUrl } from "@jsenv/urls";
15
16
  import { existsSync } from "node:fs";
16
17
 
17
18
  const DEPENDENCY_FIELDS = [
@@ -93,12 +94,21 @@ const createStatus = (
93
94
  declaredVersion,
94
95
  declaredBy,
95
96
  installedVersion: null,
97
+ // the file telling this dependency apart; it is what an install rewrites and
98
+ // what the dev server looks at to know the dependency became the declared one
99
+ watchedPath: null,
96
100
  state: "missing",
97
101
  };
98
102
  const installedDirectoryUrl = findInstalledDirectoryUrl(
99
103
  declaringDirectoryUrl,
100
104
  packageName,
101
105
  );
106
+ status.watchedPath = watchedPathFromDirectoryUrl(
107
+ packageDirectory,
108
+ // not installed yet: name the place where it is expected to appear
109
+ installedDirectoryUrl ||
110
+ `${declaringDirectoryUrl}node_modules/${packageName}/`,
111
+ );
102
112
  if (!installedDirectoryUrl) {
103
113
  return status;
104
114
  }
@@ -117,6 +127,14 @@ const createStatus = (
117
127
  return status;
118
128
  };
119
129
 
130
+ const watchedPathFromDirectoryUrl = (packageDirectory, directoryUrl) => {
131
+ const packageJsonUrl = `${directoryUrl}package.json`;
132
+ if (!packageDirectory.url) {
133
+ return packageJsonUrl;
134
+ }
135
+ return urlToRelativeUrl(packageJsonUrl, packageDirectory.url);
136
+ };
137
+
120
138
  const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
121
139
  let directoryUrl = declaringDirectoryUrl;
122
140
  while (directoryUrl) {