@staticbolt/core 1.0.0-beta.29 → 1.0.0-beta.30

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,8 +1,9 @@
1
1
  import { A as isTextAssetMetadata, C as isBinaryAssetMetadata, D as isScriptMetadata, E as isPackageMetadata, F as readJsonFile, H as splitHtmlLink, L as safeReadFileSync, M as isScriptType, N as METADATA_TYPES, O as isStyleMetadata, P as Resolver, S as filterStyleMetadata, T as isMarkdownMetadata, U as DependencyTracker, V as isValidRelativePath, _ as mergeMaps, b as printFmtError, c as downloadContent, d as hashContent, f as humanReadableBytes, h as isURL, j as isWebManifestMetadata, k as isSvgMetadata, l as escapeHtml, n as bytesToKB, s as cloneObject, w as isHtmlMetadata, x as filterScriptMetadata, y as PrintFormattedError, z as valueOrError } from "../utilities-D0KXIZ-B.mjs";
2
- import { _ as replaceExtension, a as basename, c as isAbsolute, d as join, f as normalize, g as relative, i as createLog, l as isPathMatch, m as parsePatterns, n as CUSTOM_ATTRIBUTES, o as dirname, p as parse$2, r as Log, s as extname, t as CONFIG_FILE_NAME, u as isSubpath, v as resolve } from "../common-DUFKS3lW.mjs";
2
+ import { _ as replaceExtension, a as basename, c as isAbsolute, d as join, f as normalize, g as relative, i as createLog, l as isPathMatch, m as parsePatterns, n as CUSTOM_ATTRIBUTES, o as dirname, p as parse$2, r as Log, s as extname, u as isSubpath, v as resolve } from "../common-DUFKS3lW.mjs";
3
3
  import { _ as minifyHtml, a as formatStyle, c as minifyStylePass, d as minifyScript, f as minifyScriptPass, g as formatHtmlPass, h as formatHtml, i as minifySvgPass, l as formatScript, m as formatMarkdownPass, n as formatSvgPass, o as formatStylePass, p as formatMarkdown, r as minifySvg, s as minifyStyle, t as formatSvg, u as formatScriptPass, v as minifyHtmlPass, y as formatCode } from "../deferred-DTj91vEg.mjs";
4
+ import { t as loadConfigFile } from "../load-config-D-FtbUws.mjs";
4
5
  import { createRequire } from "node:module";
5
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import nodePath from "node:path";
7
8
  import chalk from "chalk";
8
9
  import json5 from "json5";
@@ -22,6 +23,7 @@ import transformDefine from "babel-plugin-transform-define";
22
23
  import { availableParallelism } from "node:os";
23
24
  import { Worker } from "node:worker_threads";
24
25
  import vm from "node:vm";
26
+ import transformModulesCommonjs from "@babel/plugin-transform-modules-commonjs";
25
27
  import * as z$1 from "zod/v4";
26
28
  import rehypeExternalLinks from "rehype-external-links";
27
29
  import rehypeSlug from "rehype-slug";
@@ -3284,15 +3286,23 @@ function resolveWorkerUrl() {
3284
3286
 
3285
3287
  //#endregion
3286
3288
  //#region src/helpers/execute-js-in-vm.ts
3289
+ /** Runs an ES module graph inside a `node:vm` context and hands back what the entry module exported. */
3287
3290
  async function executeJsInVmUnsafe(options) {
3288
- const { root, entryFile, context: contextObject = {}, html } = options;
3291
+ const { dom, context } = await createSandbox(options.html, options.context);
3292
+ return {
3293
+ exports: { ...await new ModuleRunner(context, options).import(options.entryFile) },
3294
+ dom
3295
+ };
3296
+ }
3297
+ /** A jsdom window when there is html to work on, a bare object with the essentials otherwise. */
3298
+ async function createSandbox(html, globals = {}) {
3289
3299
  let dom;
3290
3300
  if (html) {
3291
3301
  const { JSDOM, VirtualConsole } = await import("jsdom");
3292
- dom = html ? new JSDOM(html, {
3302
+ dom = new JSDOM(html, {
3293
3303
  virtualConsole: new VirtualConsole(),
3294
3304
  url: "http://localhost/"
3295
- }) : void 0;
3305
+ });
3296
3306
  }
3297
3307
  const window = dom ? dom.window : {};
3298
3308
  if (!dom) Object.assign(window, {
@@ -3303,96 +3313,150 @@ async function executeJsInVmUnsafe(options) {
3303
3313
  fetch,
3304
3314
  URL
3305
3315
  });
3306
- Object.assign(window, Object.assign({
3307
- ___output: {},
3308
- console
3309
- }, contextObject));
3310
- const context = vm.createContext(window);
3311
- /** - Save modules in memory to avoid creating a new one for the same file */
3312
- const modules = {};
3313
- options.onModuleLoad?.(entryFile);
3314
- const script = await options.getCode(entryFile);
3315
- if (!script) {
3316
- Log.error(`[executeJsInVM] can't find the script '${entryFile}'`);
3317
- return {
3318
- output: context.___output,
3319
- exports: {},
3320
- dom
3321
- };
3316
+ Object.assign(window, { console }, globals);
3317
+ return {
3318
+ dom,
3319
+ context: vm.createContext(window)
3320
+ };
3321
+ }
3322
+ const BASE_META_URL = "http://localhost:3000";
3323
+ /**
3324
+ * Runs modules the way ES modules run: each one once, its static imports before it in source order, top-level `await` included. A
3325
+ * module that imports one of its own importers (a cycle) gets its exports as far as they are.
3326
+ *
3327
+ * Modules are rewritten to CommonJS and run as functions in the context, which keeps them collectable once the run is over.
3328
+ */
3329
+ var ModuleRunner = class {
3330
+ /** Resolves to the module's exports once it has run. Registered before the module starts, so it only ever runs once. */
3331
+ #runs = /* @__PURE__ */ new Map();
3332
+ /** The exports of every module that has started, which is what a cycle gets to see. */
3333
+ #exports = /* @__PURE__ */ new Map();
3334
+ #context;
3335
+ #options;
3336
+ constructor(context, options) {
3337
+ this.#context = context;
3338
+ this.#options = options;
3322
3339
  }
3323
- const module = new vm.SourceTextModule(script, {
3324
- context,
3325
- identifier: entryFile,
3326
- importModuleDynamically,
3327
- initializeImportMeta
3328
- });
3329
- modules[entryFile] = module;
3330
- const baseMetaUrl = "http://localhost:3000";
3331
- function initializeImportMeta(meta, referencingModule) {
3332
- let id = referencingModule.identifier;
3333
- if (extname(id) === ".html") id = dirname(id) + "/";
3334
- meta.resolve = (url) => {
3335
- return new URL(join(id, url), baseMetaUrl).href;
3336
- };
3337
- meta.url = new URL(id, baseMetaUrl).href;
3338
- }
3339
- async function importModuleDynamically(specifier, referrer) {
3340
- const m = await linker(specifier, referrer);
3341
- if (m.status === "unlinked") await m.link(linker);
3342
- else if (m.status === "linked") await m.evaluate();
3343
- return m;
3344
- }
3345
- async function linker(specifier, referencingModule) {
3346
- const isNativeModule = specifier.startsWith("node:");
3347
- const resolveName = isNativeModule ? specifier : join(dirname(referencingModule.identifier), specifier);
3348
- if (!isNativeModule) options.onModuleLoad?.(resolveName);
3349
- if (extname(resolveName) === ".json") {
3350
- const jsonPath = join(root, resolveName);
3351
- if (!existsSync(jsonPath)) throw new Error(`[executeJsInVM] can't find the file '${resolveName}'`);
3352
- let jsonString = readFileSync(jsonPath, "utf8");
3353
- jsonString = "const __json = " + jsonString + ";export default __json;";
3354
- const module = new vm.SourceTextModule(jsonString, {
3355
- context,
3356
- identifier: resolveName
3357
- });
3358
- modules[resolveName] = module;
3359
- return module;
3360
- }
3361
- if (Object.hasOwn(modules, resolveName)) return modules[resolveName];
3362
- if (isNativeModule) {
3363
- const builtIn = await import(specifier);
3364
- const exportNames = Object.keys(builtIn);
3365
- const module = new vm.SyntheticModule(exportNames, function() {
3366
- for (const key of exportNames) this.setExport(key, builtIn[key]);
3367
- }, {
3368
- context,
3369
- identifier: resolveName
3370
- });
3371
- modules[resolveName] = module;
3372
- return module;
3373
- }
3374
- const moduleScript = await options.getCode(resolveName);
3375
- if (!moduleScript) {
3376
- Log.error(`[executeJsInVM] can't find the script '${resolveName}' in the transformed scripts map`);
3377
- throw new Error(`can't resolve module '${specifier}' from '${referencingModule.identifier}'`);
3378
- }
3379
- const module = new vm.SourceTextModule(moduleScript, {
3380
- context: referencingModule.context,
3381
- identifier: resolveName,
3382
- importModuleDynamically,
3383
- initializeImportMeta
3340
+ /**
3341
+ * Runs the module unless it already has, and resolves to its exports. `importers` is the chain of static imports that led here,
3342
+ * empty for the entry and for dynamic imports.
3343
+ */
3344
+ import(id, importers = /* @__PURE__ */ new Set()) {
3345
+ let run = this.#runs.get(id);
3346
+ if (!run) {
3347
+ run = this.#run(id, importers);
3348
+ this.#runs.set(id, run);
3349
+ }
3350
+ return run;
3351
+ }
3352
+ async #run(id, importers) {
3353
+ const exports = {};
3354
+ this.#exports.set(id, exports);
3355
+ if (id.startsWith("node:")) return Object.assign(exports, await import(id));
3356
+ this.#options.onModuleLoad?.(id);
3357
+ if (extname(id) === ".json") {
3358
+ exports.default = this.#readJson(id);
3359
+ return exports;
3360
+ }
3361
+ const source = await this.#options.getCode(id);
3362
+ if (!source) throw new Error(`[executeJsInVM] can't find the script '${id}'`);
3363
+ const { code, imports } = await toModuleBody(source, id);
3364
+ const chain = new Set(importers).add(id);
3365
+ for (const specifier of imports) {
3366
+ const importId = this.#resolve(specifier, id);
3367
+ if (!chain.has(importId)) await this.import(importId, chain);
3368
+ }
3369
+ await this.#compile(code, id)({
3370
+ require: (specifier) => this.#require(specifier, id),
3371
+ __import: (specifier) => this.import(this.#resolve(specifier, id)),
3372
+ __meta: createImportMeta(id),
3373
+ exports
3384
3374
  });
3385
- modules[resolveName] = module;
3386
- return module;
3375
+ return exports;
3376
+ }
3377
+ /** Creates the function in the context, so its globals are the sandbox's. */
3378
+ #compile(code, id) {
3379
+ return new vm.Script(`"use strict"; (async function ({ require, __import, __meta, exports }) {\n${code}\n})`, {
3380
+ filename: id,
3381
+ lineOffset: -1
3382
+ }).runInContext(this.#context);
3383
+ }
3384
+ #require(specifier, importer) {
3385
+ const exports = this.#exports.get(this.#resolve(specifier, importer));
3386
+ if (!exports) throw new Error(`[executeJsInVM] '${specifier}' has not run before '${importer}'`);
3387
+ return exports;
3387
3388
  }
3388
- await module.link(linker);
3389
- await module.evaluate();
3390
- const exports = Object.fromEntries(Object.entries(module.namespace));
3389
+ #resolve(specifier, importer) {
3390
+ return specifier.startsWith("node:") ? specifier : join(dirname(importer), specifier);
3391
+ }
3392
+ #readJson(id) {
3393
+ const jsonPath = join(this.#options.root, id);
3394
+ if (!existsSync(jsonPath)) throw new Error(`[executeJsInVM] can't find the file '${id}'`);
3395
+ return JSON.parse(readFileSync(jsonPath, "utf8"));
3396
+ }
3397
+ };
3398
+ function createImportMeta(id) {
3399
+ if (extname(id) === ".html") id = dirname(id) + "/";
3400
+ const url = new URL(id, BASE_META_URL).href;
3391
3401
  return {
3392
- output: context.___output,
3393
- exports,
3394
- dom
3402
+ url,
3403
+ resolve: (specifier) => new URL(specifier, url).href
3404
+ };
3405
+ }
3406
+ /**
3407
+ * The same module runs once per page that uses it, so its rewrite is kept by source. The oldest entries go once there are more
3408
+ * than `MODULE_BODY_CACHE_SIZE`, which keeps a long editing session from piling up dead ones.
3409
+ */
3410
+ const moduleBodyCache = /* @__PURE__ */ new Map();
3411
+ const MODULE_BODY_CACHE_SIZE = 256;
3412
+ /**
3413
+ * Rewrites an ES module into the body of a `ModuleBody`: imports and exports become `require` and `exports`, `import.meta`
3414
+ * becomes `__meta` and `import()` becomes `__import()`. Also lists what the module imports statically.
3415
+ */
3416
+ async function toModuleBody(source, id) {
3417
+ const cached = moduleBodyCache.get(source);
3418
+ if (cached) return cached;
3419
+ const imports = [];
3420
+ const result = await babel.transformAsync(source, {
3421
+ filename: id,
3422
+ sourceType: "module",
3423
+ babelrc: false,
3424
+ configFile: false,
3425
+ compact: false,
3426
+ sourceMaps: false,
3427
+ plugins: [collectImportsAndRewriteMeta(imports), [transformModulesCommonjs, {
3428
+ importInterop: "none",
3429
+ strictMode: false
3430
+ }]]
3431
+ });
3432
+ if (!result?.code) throw new Error(`[executeJsInVM] failed to transform '${id}'`);
3433
+ const moduleBody = {
3434
+ code: result.code,
3435
+ imports
3395
3436
  };
3437
+ moduleBodyCache.set(source, moduleBody);
3438
+ if (moduleBodyCache.size > MODULE_BODY_CACHE_SIZE) moduleBodyCache.delete(moduleBodyCache.keys().next().value);
3439
+ return moduleBody;
3440
+ }
3441
+ /** Lists the static import sources into `imports`, and turns `import.meta` into `__meta` and `import()` into `__import()`. */
3442
+ function collectImportsAndRewriteMeta(imports) {
3443
+ return { visitor: {
3444
+ ImportDeclaration(path) {
3445
+ imports.push(path.node.source.value);
3446
+ },
3447
+ ExportNamedDeclaration(path) {
3448
+ if (path.node.source) imports.push(path.node.source.value);
3449
+ },
3450
+ ExportAllDeclaration(path) {
3451
+ imports.push(path.node.source.value);
3452
+ },
3453
+ MetaProperty(path) {
3454
+ if (path.node.meta.name === "import") path.replaceWith(t.identifier("__meta"));
3455
+ },
3456
+ CallExpression(path) {
3457
+ if (t.isImport(path.node.callee)) path.node.callee = t.identifier("__import");
3458
+ }
3459
+ } };
3396
3460
  }
3397
3461
  const executeJsInVM = valueOrError(executeJsInVmUnsafe);
3398
3462
 
@@ -3413,7 +3477,6 @@ const executeJsInVM = valueOrError(executeJsInVmUnsafe);
3413
3477
  *
3414
3478
  * Notes:
3415
3479
  *
3416
- * - Requires Node started with "--experimental-vm-modules". Without it the tags are removed without running and an error is logged.
3417
3480
  * - "full-dom" needs a fully constructed page (a head and a body), so place this plugin last when using it.
3418
3481
  * - Failures are per tag: the error is logged and the rest of the page carries on.
3419
3482
  * - In development the files each page executed are tracked, so editing one of them recompiles the pages that used it.
@@ -3426,23 +3489,13 @@ function htmlBuildTimeScript(options = {}) {
3426
3489
  const printFmtError = PrintFormattedError.create({ function: htmlBuildTimeScript });
3427
3490
  /** Page → the files its build-time scripts executed. Used to recompile the page when one of them changes. */
3428
3491
  const dependencies = new DependencyTracker();
3429
- let isVmEnabled = true;
3430
3492
  return {
3431
3493
  name: "html-build-time-script",
3432
- setup() {
3433
- if (vm.SourceTextModule) return;
3434
- isVmEnabled = false;
3435
- printFmtError("Script execution requires the `node:vm` module, which is currently unavailable.", "\nStart Node.js with the `--experimental-vm-modules` flag to enable it.");
3436
- },
3437
3494
  async postTransform() {
3438
3495
  const isTracking = !this.production;
3439
3496
  const executedSources = /* @__PURE__ */ new Map();
3440
3497
  for (const metadata of this.metadataList) {
3441
3498
  if (!isHtmlMetadata(metadata)) continue;
3442
- if (!isVmEnabled) {
3443
- for (const node of metadata.ast.querySelectorAll(query)) node.remove();
3444
- continue;
3445
- }
3446
3499
  const isReady = metadata.ast.querySelector("body") && metadata.ast.querySelector("head");
3447
3500
  const scripts = metadata.ast.querySelectorAll(query);
3448
3501
  if (scripts.length === 0) continue;
@@ -5818,7 +5871,6 @@ function htmlMergeStylesPlugin() {
5818
5871
  * - A resolved link keeps the style it was written in: an extensionless or directory-style link to a page stays that way, while a
5819
5872
  * script path resolving to ".ts", ".tsx" or ".jsx" is rewritten to ".js", which is what actually ships.
5820
5873
  * - Query strings and hashes survive both resolving and rebasing, and a link that ends up pointing at its own page becomes "./".
5821
- * - The config file is watched, but changing it only warns: it takes a restart.
5822
5874
  */
5823
5875
  function coreBasePlugin() {
5824
5876
  const JS_EXTENSIONS = /* @__PURE__ */ new Set([
@@ -5833,16 +5885,12 @@ function coreBasePlugin() {
5833
5885
  ]);
5834
5886
  return {
5835
5887
  name: "core-base",
5836
- setup() {
5837
- this.watcher?.add(CONFIG_FILE_NAME);
5838
- },
5839
5888
  read(absolutePath) {
5840
5889
  const [fileContent, readFileError] = safeReadFileSync(absolutePath, "utf8");
5841
5890
  if (readFileError) return;
5842
5891
  return fileContent;
5843
5892
  },
5844
5893
  onFileEvent(event, filePath) {
5845
- if (basename(filePath) === ".staticbolt.ts") this.log.warn(`Config file "${CONFIG_FILE_NAME}" has changed. Please restart the server.`);
5846
5894
  if (event === "unlink") this.resolver.files.delete(filePath);
5847
5895
  },
5848
5896
  clone(metadata) {
@@ -7691,6 +7739,8 @@ function orderByDependencyDepth(allMetadata, entryPoints, filePaths) {
7691
7739
  * - On a change, the affected pages are recompiled in dependency order and metadata nothing references any more is dropped. If only
7692
7740
  * CSS changed the stylesheets are swapped in place, otherwise the page reloads.
7693
7741
  * - Compiled pages are trimmed to the number of connected clients, dropping the least recently served ones.
7742
+ * - Closing the app drops the connected clients and stops the server, which frees the port for the next one; the browser reloads
7743
+ * itself as soon as it reconnects.
7694
7744
  */
7695
7745
  function developmentServerPlugin(options = {}) {
7696
7746
  const host = options.host ?? "0.0.0.0";
@@ -7701,10 +7751,10 @@ function developmentServerPlugin(options = {}) {
7701
7751
  let websocket;
7702
7752
  const wsClients = /* @__PURE__ */ new Set();
7703
7753
  const entryPointAgeMap = /* @__PURE__ */ new Map();
7704
- function upgradeWebSocketHandler(request, socket, head) {
7705
- websocket.handleUpgrade(request, socket, head, (ws) => {
7754
+ function upgradeWebSocketHandler(server, request, socket, head) {
7755
+ server.handleUpgrade(request, socket, head, (ws) => {
7706
7756
  wsClients.add(ws);
7707
- websocket.emit("connection", ws, request);
7757
+ server.emit("connection", ws, request);
7708
7758
  ws.send(JSON.stringify({ data: "connected" }));
7709
7759
  ws.on("close", () => {
7710
7760
  wsClients.delete(ws);
@@ -7719,9 +7769,10 @@ function developmentServerPlugin(options = {}) {
7719
7769
  }
7720
7770
  return {
7721
7771
  name: "dev-server",
7722
- setup() {
7772
+ async setup() {
7723
7773
  if (this.production) return;
7724
- websocket = new WebSocketServer({ noServer: true });
7774
+ const websocketServer = new WebSocketServer({ noServer: true });
7775
+ websocket = websocketServer;
7725
7776
  const fastifyServe = async (request, reply) => {
7726
7777
  const requestPath = normalize("." + decodeURIComponent(request.urlData().path || "./"));
7727
7778
  await this.handleRequest(requestPath, reply, request);
@@ -7730,18 +7781,8 @@ function developmentServerPlugin(options = {}) {
7730
7781
  fastify.register(fastifyUrlData);
7731
7782
  fastify.register(fastifyStatic, { root: [this.root, join(this.root, publicDirectory)] });
7732
7783
  fastify.addHook("onRequest", fastifyServe);
7733
- fastify.server.on("upgrade", upgradeWebSocketHandler);
7734
- fastify.listen({
7735
- port,
7736
- host
7737
- }, (error, address) => {
7738
- if (error) {
7739
- this.log.error(error.message);
7740
- throw new Error(error.message);
7741
- }
7742
- this.log.info(chalk.bold("Server listening on"), chalk.green(address + entry));
7743
- });
7744
- websocket.on("connection", () => {
7784
+ fastify.server.on("upgrade", (request, socket, head) => upgradeWebSocketHandler(websocketServer, request, socket, head));
7785
+ websocketServer.on("connection", () => {
7745
7786
  const sortedByAge = Array.from(entryPointAgeMap).toSorted((a, b) => a[1] - b[1]).map(([id]) => id);
7746
7787
  const excess = sortedByAge.length - wsClients.size;
7747
7788
  if (excess > 0) for (const id of sortedByAge.slice(0, excess)) {
@@ -7749,6 +7790,17 @@ function developmentServerPlugin(options = {}) {
7749
7790
  removeAndPruneOrphans(this.metadataList, id);
7750
7791
  }
7751
7792
  });
7793
+ const address = await fastify.listen({
7794
+ port,
7795
+ host
7796
+ });
7797
+ this.log.info(chalk.bold("Server listening on"), chalk.green(address + entry));
7798
+ },
7799
+ async teardown() {
7800
+ for (const client of wsClients) client.terminate();
7801
+ wsClients.clear();
7802
+ websocket?.close();
7803
+ await fastify?.close();
7752
7804
  },
7753
7805
  async onFileEvent(event, changedSource) {
7754
7806
  if (event === "unlink") {
@@ -7903,6 +7955,7 @@ function printExecutionTime(executionTime) {
7903
7955
  /** Every bound hook, in bind order. The `satisfies` makes a hook added to `Plugin` a type error until it is listed here. */
7904
7956
  const BOUND_HOOKS = Object.keys({
7905
7957
  setup: true,
7958
+ teardown: true,
7906
7959
  read: true,
7907
7960
  load: true,
7908
7961
  resolveSource: true,
@@ -7947,9 +8000,10 @@ var App = class {
7947
8000
  pluginData = {};
7948
8001
  /** Exclude paths from being emitted. */
7949
8002
  emitExclude = /* @__PURE__ */ new Set();
7950
- /** Chokidar watcher. */
8003
+ /** Chokidar watcher. Development only, and gone once the app is closed. */
7951
8004
  watcher;
7952
8005
  isPluginInitialized = false;
8006
+ isClosed = false;
7953
8007
  measureExecutionTime = false;
7954
8008
  executionTime = {};
7955
8009
  /** How long the hooks called from inside the hook currently being measured took. Held in an object so plugin contexts share it. */
@@ -7991,17 +8045,19 @@ var App = class {
7991
8045
  console.error(error);
7992
8046
  });
7993
8047
  }
8048
+ /** Binds a copy of the plugin's hooks to this app, leaving the user's object untouched. */
7994
8049
  bindPlugin(plugin, pluginIndex) {
7995
8050
  const pluginContext = Object.create(this);
7996
8051
  pluginContext.pluginIndex = pluginIndex;
7997
8052
  pluginContext.pluginName = plugin.name;
7998
8053
  pluginContext.log = createLog(`[${plugin.name}]`);
7999
- const hooks = plugin;
8054
+ const boundPlugin = { ...plugin };
8055
+ const hooks = boundPlugin;
8000
8056
  for (const hookName of BOUND_HOOKS) {
8001
8057
  const hook = hooks[hookName];
8002
8058
  hooks[hookName] = hook ? hook.bind(pluginContext) : emptyFunction;
8003
8059
  }
8004
- return plugin;
8060
+ return boundPlugin;
8005
8061
  }
8006
8062
  callPluginMethod(plugin, method, ...arguments_) {
8007
8063
  if (!this.measureExecutionTime) {
@@ -8069,6 +8125,22 @@ var App = class {
8069
8125
  this.log.info("Build complete");
8070
8126
  if (this.measureExecutionTime) printExecutionTime(this.executionTime);
8071
8127
  }
8128
+ /**
8129
+ * Stops watching and lets every plugin release what its `setup` took, in reverse order. A teardown that throws is reported and
8130
+ * the rest still run. Closing again does nothing.
8131
+ */
8132
+ async close() {
8133
+ if (this.isClosed) return;
8134
+ this.isClosed = true;
8135
+ await this.watcher?.close();
8136
+ this.watcher = void 0;
8137
+ for (const plugin of this.plugins.toReversed()) if (plugin.teardown !== emptyFunction) try {
8138
+ await this.callPluginMethod(plugin, "teardown");
8139
+ } catch (error) {
8140
+ this.log.error(`[${plugin.name}] Teardown failed:`);
8141
+ console.error(error);
8142
+ }
8143
+ }
8072
8144
  async process(id) {
8073
8145
  const metadata = await this.load(id);
8074
8146
  if (!metadata) {
@@ -10119,26 +10191,128 @@ function shortenHexColor(hexColor) {
10119
10191
  *
10120
10192
  * - The command owns the run. It forces development mode onto the config and creates its own App.
10121
10193
  * - Serving itself belongs to developmentServerPlugin; this only starts the app in the right mode.
10194
+ * - The config file is watched. When it changes it is loaded again, the running app is closed and a new one starts from the new
10195
+ * config. A config that fails to load leaves the running app alone; a new app that fails to start is closed, so nothing is
10196
+ * served until the next save. Plugins have to be created inside the config file: only that file is evaluated again, so plugin
10197
+ * objects imported from elsewhere would be the same ones the closed app used, and those are refused.
10122
10198
  */
10123
10199
  function serveCliPlugin(options = {}) {
10124
10200
  const name = options.command ?? "serve";
10125
10201
  const aliases = options.aliases ?? ["dev"];
10126
10202
  return {
10127
10203
  name: "serve-cli-plugin",
10128
- cli(config, configPath) {
10129
- const buildCommand = this.defineSubcommand({
10204
+ cli(config, configPath, projectDirectory) {
10205
+ const serveCommand = this.defineSubcommand({
10130
10206
  name,
10131
10207
  aliases,
10132
- meta: { description: "Dev server for static bolt." }
10208
+ meta: { description: "Dev server for staticbolt." }
10133
10209
  });
10134
- buildCommand.onExecute(async () => {
10135
- config.production = false;
10136
- await new App(config, configPath).run();
10137
- });
10138
- this.addCommand(buildCommand);
10210
+ serveCommand.onExecute(() => new DevelopmentRun(config, configPath, projectDirectory).start());
10211
+ this.addCommand(serveCommand);
10139
10212
  }
10140
10213
  };
10141
10214
  }
10215
+ /** The app running in development mode, replaced by a fresh one whenever the config file changes. */
10216
+ var DevelopmentRun = class {
10217
+ #app;
10218
+ #config;
10219
+ #configPath;
10220
+ #configName;
10221
+ #projectDirectory;
10222
+ /** Restarts run one at a time; a change that lands during one waits its turn. */
10223
+ #restartQueue = Promise.resolve();
10224
+ /** The most recent change, so a queued restart can be skipped once a newer change is waiting behind it. */
10225
+ #latestChange = 0;
10226
+ /** When the config file was last written, as of the last time it was loaded. */
10227
+ #configModified;
10228
+ constructor(config, configPath, projectDirectory) {
10229
+ config.production = false;
10230
+ this.#config = config;
10231
+ this.#app = new App(config, configPath);
10232
+ this.#configPath = configPath;
10233
+ this.#configName = basename(configPath);
10234
+ this.#projectDirectory = projectDirectory;
10235
+ this.#configModified = this.#modifiedTime();
10236
+ }
10237
+ async start() {
10238
+ chokidar.watch(this.#configPath, {
10239
+ ignoreInitial: true,
10240
+ awaitWriteFinish: {
10241
+ stabilityThreshold: 100,
10242
+ pollInterval: 20
10243
+ }
10244
+ }).on("change", () => this.#queueRestart()).on("add", () => this.#queueRestart());
10245
+ this.#restartQueue = this.#app.run();
10246
+ await this.#restartQueue;
10247
+ this.#restartIfModified();
10248
+ }
10249
+ #queueRestart() {
10250
+ const change = ++this.#latestChange;
10251
+ this.#restartQueue = this.#restartQueue.then(async () => {
10252
+ if (change === this.#latestChange) await this.#restart();
10253
+ });
10254
+ }
10255
+ /**
10256
+ * The app starting its own watcher makes the file system drop events for a moment (libuv rebuilds its stream), so a save that
10257
+ * happened while the app was starting is caught by looking at the file instead.
10258
+ */
10259
+ #restartIfModified() {
10260
+ if (this.#modifiedTime() !== this.#configModified) this.#queueRestart();
10261
+ }
10262
+ #modifiedTime() {
10263
+ return statSync(this.#configPath, { throwIfNoEntry: false })?.mtimeMs;
10264
+ }
10265
+ async #restart() {
10266
+ Log.info(`Config file "${this.#configName}" changed, restarting`);
10267
+ this.#configModified = this.#modifiedTime();
10268
+ const nextConfig = await this.#loadNextConfig();
10269
+ if (!nextConfig) return;
10270
+ let nextApp;
10271
+ try {
10272
+ nextApp = new App(nextConfig, this.#configPath);
10273
+ } catch (error) {
10274
+ Log.error("Failed to create the app, keeping the current server:");
10275
+ console.error(error);
10276
+ return;
10277
+ }
10278
+ try {
10279
+ await this.#app.close();
10280
+ this.#app = nextApp;
10281
+ this.#config = nextConfig;
10282
+ await nextApp.run();
10283
+ this.#restartIfModified();
10284
+ } catch (error) {
10285
+ Log.error(`Restart failed, nothing is served until "${this.#configName}" is fixed and saved again:`);
10286
+ console.error(error);
10287
+ await this.#app.close();
10288
+ }
10289
+ }
10290
+ /** The config to restart with, or nothing when the current server is to be kept. */
10291
+ async #loadNextConfig() {
10292
+ const [nextConfig, configError] = await loadConfigFile(this.#configPath, this.#projectDirectory);
10293
+ if (configError) {
10294
+ Log.error(`Failed to load "${this.#configName}", keeping the current server:`);
10295
+ console.error(configError);
10296
+ return;
10297
+ }
10298
+ const reusedPlugins = this.#reusedPlugins(nextConfig);
10299
+ if (reusedPlugins.length > 0) {
10300
+ const names = reusedPlugins.map((plugin) => `"${plugin.name}"`).join(", ");
10301
+ Log.error(`Keeping the current server: ${names} are the same plugin objects as before. Create plugins inside "${this.#configName}" so a reload gets fresh ones.`);
10302
+ return;
10303
+ }
10304
+ nextConfig.production = false;
10305
+ return nextConfig;
10306
+ }
10307
+ /**
10308
+ * A plugin object belongs to one app. Plugins imported from another file come back as the same objects, since only the config
10309
+ * file itself is evaluated again.
10310
+ */
10311
+ #reusedPlugins(nextConfig) {
10312
+ const currentPlugins = new Set((this.#config.plugins ?? []).flat());
10313
+ return (nextConfig.plugins ?? []).flat().filter((plugin) => currentPlugins.has(plugin));
10314
+ }
10315
+ };
10142
10316
 
10143
10317
  //#endregion
10144
10318
  export { HtmlInlineSvgPlugin, analyzeOutputPlugin, buildCliPlugin, bundlePackagesPlugin, convertFontsCliPlugin, convertImagePlugin, copyAssetsPlugin, coreHtmlPlugin, coreMarkdownPlugin, coreScriptPlugin, coreStylePlugin, coreSvgPlugin, coreWebManifestPlugin, customEasePlugin, developmentServerPlugin, generateFontFacesCliPlugin, htmlBuildTimeScript, htmlBundleScriptPlugin, htmlBundleStylePlugin, htmlEnvOnlyPlugin, htmlFragmentPlugin, htmlIifeScriptPlugin, htmlInlineScriptPlugin, htmlInlineStylePlugin, htmlInlineTextPlugin, htmlInsertPlugin, htmlLayoutPlugin, htmlMarkdownPlugin, htmlMergeStylesPlugin, htmlPagesPlugin, htmlPreloadPlugin, svgoPlugin as htmlSvgoPlugin, i18nPlugin, importAsStringPlugin, loadSourcesPlugin, materialYouCliPlugin, robotsTextPlugin, serveCliPlugin, serviceWorkerPlugin, sitemapPlugin, transformCssPlugin, transformJsPlugin, webManifestPlugin, writeFilesPlugin };