@dokebi/template-inertia 0.4.1 → 0.4.4

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.
package/dist/vite-host.js CHANGED
@@ -3,17 +3,49 @@
3
3
  // `{ type: 'dokebi-vite-host', state: 'ready', url, key }` over the IPC channel; dev passes them to
4
4
  // the app as DOKEBI_VITE_DEV_URL and DOKEBI_VITE_DEV_KEY. The host outlives app restarts.
5
5
  // A start failure is reported as `state: 'failed'` and the process exits 1.
6
- import { ConsoleLogger } from '@dokebi/core';
6
+ // Its settings come from dokebi.yaml (`options.dokebi-template-inertia`, read once at start):
7
+ // `devDepsRepairIntervalMs` (D-0128).
8
+ import { existsSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { ConsoleLogger, loadConfig } from '@dokebi/core';
7
11
  import { diagnostics } from '@dokebi/core/libraries';
12
+ import { options } from './options.js';
8
13
  import { startViteHost } from './vite-dev.js';
9
14
  const { describeError } = diagnostics;
10
15
  const LEVELS = new Set(['debug', 'info', 'warn', 'error', 'silent']);
11
16
  const asked = process.env.DOKEBI_LOG_LEVEL?.trim().toLowerCase() ?? '';
12
17
  const logger = new ConsoleLogger({ level: LEVELS.has(asked) ? asked : 'info' }, { plugin: 'dokebi-template-inertia', process: 'vite-host' });
13
- const send = (message) => process.send?.(message);
18
+ /** Started by dokebi dev (an IPC channel); `process.send` stays defined after the channel closes. */
19
+ const underDev = typeof process.send === 'function';
20
+ /**
21
+ * A report to dokebi dev while its channel is open. Sent on a closed channel, Node emits an
22
+ * `error` event on process (ERR_IPC_CHANNEL_CLOSED) that nothing handles: the host crashed.
23
+ */
24
+ const send = (message) => {
25
+ if (process.connected)
26
+ process.send?.(message);
27
+ };
28
+ /**
29
+ * The host's settings from the project's dokebi.yaml (the app's own file, DOKEBI_CONFIG; an empty
30
+ * value means dokebi.yaml, as for the CLI).
31
+ */
32
+ async function settings() {
33
+ const file = resolve(process.env.DOKEBI_CONFIG || join(process.cwd(), 'dokebi.yaml'));
34
+ if (!existsSync(file)) {
35
+ logger.log('debug', 'Vite dev host uses default settings (no dokebi.yaml)', { file });
36
+ return {};
37
+ }
38
+ const document = await loadConfig(file);
39
+ const parsed = options.parse(document.options['dokebi-template-inertia'] ?? {});
40
+ logger.log('debug', 'Vite dev host settings read', {
41
+ file,
42
+ depsRepairIntervalMs: parsed.devDepsRepairIntervalMs,
43
+ });
44
+ return { depsRepairIntervalMs: parsed.devDepsRepairIntervalMs };
45
+ }
14
46
  let host;
15
47
  try {
16
- host = await startViteHost(logger, process.cwd());
48
+ host = await startViteHost(logger, process.cwd(), await settings());
17
49
  }
18
50
  catch (error) {
19
51
  logger.log('error', 'Vite dev host failed to start', {
@@ -35,16 +67,36 @@ if (host) {
35
67
  return;
36
68
  stopping = true;
37
69
  // Rejections become an error log and exit code 1 here (D-0047).
38
- void started.close().then(() => logger.log('debug', 'Vite dev host stopped', { signal }), (error) => {
70
+ void started
71
+ .close()
72
+ .then(() => logger.log('debug', 'Vite dev host stopped', { signal }), (error) => {
39
73
  process.exitCode = 1;
40
74
  logger.log('error', 'Vite dev host failed to stop', {
41
75
  code: 'INERTIA_VITE_HOST_FAILED',
42
76
  signal,
43
77
  error: describeError(error),
44
78
  });
79
+ })
80
+ // The IPC channel of dokebi dev keeps the process alive after the close: let it go, so the
81
+ // host exits now instead of at dokebi dev's SIGKILL 5 s later.
82
+ .finally(() => {
83
+ if (process.connected)
84
+ process.disconnect();
45
85
  });
46
86
  };
47
87
  process.on('SIGTERM', () => stop('SIGTERM'));
88
+ // dokebi dev went away without stopping the host (killed, crashed): its IPC channel closes.
89
+ // Nobody can reach or stop this host any more, and it would keep its port, its watcher and the
90
+ // dependency cache marker (a later `dokebi dev` then warns INERTIA_VITE_HOST_SHARED).
91
+ const orphaned = () => {
92
+ if (stopping)
93
+ return;
94
+ logger.log('warn', 'Vite dev host lost dokebi dev; stopping', {
95
+ code: 'INERTIA_VITE_HOST_ORPHANED',
96
+ });
97
+ stop('disconnect');
98
+ };
99
+ process.once('disconnect', orphaned);
48
100
  // Under dokebi dev (an IPC channel) a terminal Ctrl+C reaches the whole process group: the app
49
101
  // drains first and dev stops the host after it (SIGTERM). Alone, Ctrl+C stops the host.
50
102
  process.on('SIGINT', () => {
@@ -53,10 +105,15 @@ if (host) {
53
105
  else
54
106
  stop('SIGINT');
55
107
  });
56
- logger.log('info', 'Vite dev host ready', {
57
- code: 'INERTIA_VITE_HOST',
58
- url: started.url,
59
- hosts: started.hosts,
60
- });
61
- send({ type: 'dokebi-vite-host', state: 'ready', url: started.url, key: started.key });
108
+ // The channel closed while Vite was starting (before the listener above): that event is gone.
109
+ if (underDev && !process.connected)
110
+ orphaned();
111
+ else {
112
+ logger.log('info', 'Vite dev host ready', {
113
+ code: 'INERTIA_VITE_HOST',
114
+ url: started.url,
115
+ hosts: started.hosts,
116
+ });
117
+ send({ type: 'dokebi-vite-host', state: 'ready', url: started.url, key: started.key });
118
+ }
62
119
  }
@@ -0,0 +1,69 @@
1
+ import { type Logger } from '@dokebi/core';
2
+ import { type DevFilesOptions, type DevFilesPatch, type DevMiddleware, type ResolvedFilesShape } from './vite-files.js';
3
+ export { DEV_FILES_DENY, type DevFilesOptions, type DokebiDevFilesPlugin, dokebiDevFiles, } from './vite-files.js';
4
+ /** Specifier → the URL the app serves it at (@dokebi/seal, `GET /dokebi/seal/*`). */
5
+ export declare const SERVED_MODULES: Readonly<Record<string, string>>;
6
+ /** Where the dev server answers the re-export of a served module (under `base`). */
7
+ export declare const DEV_MODULE_PREFIX = "/@dokebi-served/";
8
+ export interface DokebiInertiaViteOptions extends DevFilesOptions {
9
+ /**
10
+ * The logger of the plugin's debug records (D-0047). Default: a console logger at
11
+ * `DOKEBI_LOG_LEVEL` (`info` when unset), the level `dokebi dev` passes to its processes.
12
+ */
13
+ readonly logger?: Logger;
14
+ }
15
+ /** The part of Vite's resolved config this plugin reads. */
16
+ type ResolvedConfigShape = ResolvedFilesShape;
17
+ /** The part of Vite's dev server this plugin uses (connect middlewares). */
18
+ interface DevServerShape {
19
+ readonly middlewares: {
20
+ use(handler: DevMiddleware): unknown;
21
+ };
22
+ }
23
+ /**
24
+ * The rolldown plugin of Vite's dependency optimizer (`optimizeDeps.rolldownOptions.plugins`,
25
+ * D-0119): served specifiers and dev ids are externals there, never files to read, crawl or bundle.
26
+ */
27
+ export interface DokebiServedDepsPlugin {
28
+ readonly name: 'dokebi-inertia:served-deps';
29
+ readonly resolveId: {
30
+ readonly order: 'pre';
31
+ handler(source: string, importer: string | undefined): {
32
+ id: string;
33
+ external: true;
34
+ } | null;
35
+ };
36
+ }
37
+ /** What the `config` hook adds to the dev server's config (Vite merges it into the user's). */
38
+ export interface DokebiInertiaConfigPatch {
39
+ readonly optimizeDeps: {
40
+ readonly rolldownOptions: {
41
+ readonly plugins: readonly DokebiServedDepsPlugin[];
42
+ };
43
+ };
44
+ /** The dev file policy (D-0129). */
45
+ readonly server?: DevFilesPatch['server'];
46
+ }
47
+ /** A Vite plugin (structural, so this package needs no Vite types). */
48
+ export interface DokebiInertiaVitePlugin {
49
+ readonly name: 'dokebi-inertia';
50
+ readonly enforce: 'pre';
51
+ config(config: unknown, env: {
52
+ command: 'build' | 'serve';
53
+ }): DokebiInertiaConfigPatch | null;
54
+ configResolved(config: ResolvedConfigShape): void;
55
+ resolveId(source: string, importer: string | undefined, options?: {
56
+ ssr?: boolean;
57
+ }): {
58
+ id: string;
59
+ external: true | 'absolute';
60
+ } | null;
61
+ load(id: string): string | null;
62
+ configureServer(server: DevServerShape): void;
63
+ }
64
+ /**
65
+ * The plugin for `plugins: [vue(), dokebiInertia()]` of an Inertia app's vite.config.ts: imports
66
+ * of the modules in {@link SERVED_MODULES} load the file the app serves (one instance per page)
67
+ * in the client build and the dev server, and stay untouched in server rendering.
68
+ */
69
+ export declare function dokebiInertia(options?: DokebiInertiaViteOptions): DokebiInertiaVitePlugin;
@@ -0,0 +1,173 @@
1
+ // `@dokebi/template-inertia/vite`: the Vite plugin of an Inertia app's vite.config.ts (D-0062
2
+ // addendum 2026-10-20). Some browser modules must exist once per page: the seal client keeps its
3
+ // bridge (the opened seal, the clock offset) in module state, so the `sealInertia(http)` bootstrap
4
+ // of views/app.js, `sealedFetch` in pages and `currentSealBridge()` must be one instance. The
5
+ // app's server serves that instance (`/dokebi/seal/http.mjs`, from @dokebi/seal). This plugin
6
+ // makes every `import … from '@dokebi/seal/http'` of the app's browser code load the served file
7
+ // instead of bundling a second copy without a bridge:
8
+ //
9
+ // - `vite build` (client): the import stays an import of `/dokebi/seal/http.mjs` (an absolute
10
+ // external), so the bundle carries no copy and no import map is needed.
11
+ // - dev server (`dokebi dev`): Vite prefixes absolute imports with `base`, so the import goes to
12
+ // `<base>@dokebi-served/seal/http.mjs` instead, which this plugin answers before Vite with
13
+ // `export * from "/dokebi/seal/http.mjs"`: the page still loads the served file, once.
14
+ // - server rendering (SSR builds, `ssrLoadModule`): untouched. Node resolves the package as usual
15
+ // (a sealed page is never server rendered, D-0062 §5).
16
+ // - Vite's dependency optimizer (dev, `optimizeDeps`, D-0119): its scan asks this plugin too,
17
+ // takes only the id of the answer and reads `/@dokebi-served/…` as a file, which fails the whole
18
+ // scan ("Failed to run dependency scan. Skipping dependency pre-bundling"). The `config` hook
19
+ // adds a rolldown plugin to `optimizeDeps.rolldownOptions.plugins` (Vite 8, the version the CLI
20
+ // installs) that answers the served specifiers and dev ids first, as externals: the scan
21
+ // neither reads nor crawls them and pre-bundles the app's real dependencies as usual, and a
22
+ // pre-bundled dependency that imports `@dokebi/seal/http` keeps the import, which Vite then
23
+ // points at the dev re-export like any other (no bundled copy).
24
+ //
25
+ // It does not import @dokebi/seal: the served paths are the seal plugin's public contract.
26
+ //
27
+ // D-0129: the plugin also carries the dev file policy of `vite-files.ts` (what the dev server may
28
+ // serve: strict, a narrowed allow list, a deny list the app cannot shrink, a short 403 for
29
+ // remote hosts), so an upgrade of this package secures every vite.config that uses it.
30
+ import { ConsoleLogger } from '@dokebi/core';
31
+ import { DevFilesPolicy, } from './vite-files.js';
32
+ export { DEV_FILES_DENY, dokebiDevFiles, } from './vite-files.js';
33
+ /** Specifier → the URL the app serves it at (@dokebi/seal, `GET /dokebi/seal/*`). */
34
+ export const SERVED_MODULES = Object.freeze({
35
+ '@dokebi/seal/http': '/dokebi/seal/http.mjs',
36
+ '@dokebi/seal/forms': '/dokebi/seal/forms.mjs',
37
+ });
38
+ /** Where the dev server answers the re-export of a served module (under `base`). */
39
+ export const DEV_MODULE_PREFIX = '/@dokebi-served/';
40
+ const LEVELS = new Set(['debug', 'info', 'warn', 'error', 'silent']);
41
+ function defaultLogger() {
42
+ const asked = process.env.DOKEBI_LOG_LEVEL?.trim().toLowerCase() ?? '';
43
+ return new ConsoleLogger({ level: LEVELS.has(asked) ? asked : 'info' }, { plugin: 'dokebi-template-inertia', process: 'vite' });
44
+ }
45
+ /** `/dokebi/seal/http.mjs` → `/@dokebi-served/seal/http.mjs` (the id of its dev re-export). */
46
+ function devId(served) {
47
+ return `${DEV_MODULE_PREFIX}${served.replace(/^\/dokebi\//, '')}`;
48
+ }
49
+ /**
50
+ * The dependency optimizer's view of the served modules (D-0119): a served specifier
51
+ * (`@dokebi/seal/http`) or a dev id (`/@dokebi-served/seal/http.mjs`, with or without `base`)
52
+ * stays itself, as an external. Only the optimizer's scan and pre-bundling run it; the dev
53
+ * server's module graph and `vite build` never do.
54
+ */
55
+ function servedDepsPlugin(logger, devIds) {
56
+ return {
57
+ name: 'dokebi-inertia:served-deps',
58
+ resolveId: {
59
+ order: 'pre',
60
+ handler(source, importer) {
61
+ if (SERVED_MODULES[source] === undefined &&
62
+ !devIds.some((id) => source === id || source.endsWith(id)))
63
+ return null;
64
+ if (logger.isEnabled('debug'))
65
+ logger.log('debug', 'Inertia Vite plugin kept a served module out of dependency pre-bundling', {
66
+ source,
67
+ importer,
68
+ });
69
+ return { id: source, external: true };
70
+ },
71
+ },
72
+ };
73
+ }
74
+ /**
75
+ * The plugin for `plugins: [vue(), dokebiInertia()]` of an Inertia app's vite.config.ts: imports
76
+ * of the modules in {@link SERVED_MODULES} load the file the app serves (one instance per page)
77
+ * in the client build and the dev server, and stay untouched in server rendering.
78
+ */
79
+ export function dokebiInertia(options = {}) {
80
+ const logger = options.logger ?? defaultLogger();
81
+ const files = new DevFilesPolicy(logger, {
82
+ ...(options.allow ? { allow: options.allow } : {}),
83
+ ...(options.deny ? { deny: options.deny } : {}),
84
+ }, 'dokebi-inertia');
85
+ let command = 'serve';
86
+ let base = '/';
87
+ /** Dev re-export id → the served URL it re-exports. */
88
+ const dev = new Map(Object.values(SERVED_MODULES).map((served) => [devId(served), served]));
89
+ /** Request path (with and without `base`) → the module text the dev server answers. */
90
+ const answers = new Map();
91
+ return {
92
+ name: 'dokebi-inertia',
93
+ enforce: 'pre',
94
+ config(config, env) {
95
+ // The client build has no dependency optimizer; the dev server's scan and pre-bundling get
96
+ // the served modules as externals (Vite merges this array into the app's own plugins).
97
+ const policy = files.config(config, env.command);
98
+ if (env.command !== 'serve')
99
+ return null;
100
+ logger.log('debug', 'Inertia Vite plugin keeps served modules out of dependency pre-bundling', {
101
+ modules: Object.keys(SERVED_MODULES),
102
+ });
103
+ return {
104
+ optimizeDeps: { rolldownOptions: { plugins: [servedDepsPlugin(logger, [...dev.keys()])] } },
105
+ ...(policy ? { server: policy.server } : {}),
106
+ };
107
+ },
108
+ configResolved(config) {
109
+ files.configResolved(config);
110
+ command = config.command;
111
+ base = config.base || '/';
112
+ answers.clear();
113
+ for (const [id, served] of dev) {
114
+ const text = `export * from ${JSON.stringify(served)};\n`;
115
+ answers.set(id, text);
116
+ answers.set(`${base.replace(/\/$/, '')}${id}`, text);
117
+ }
118
+ logger.log('debug', 'Inertia Vite plugin keeps served modules out of the bundle', {
119
+ command,
120
+ base,
121
+ modules: SERVED_MODULES,
122
+ });
123
+ },
124
+ resolveId(source, importer, resolveOptions) {
125
+ const served = SERVED_MODULES[source];
126
+ if (served === undefined)
127
+ return null;
128
+ // Server rendering imports the package as Node resolves it (sealed pages are never SSR).
129
+ if (resolveOptions?.ssr)
130
+ return null;
131
+ const id = command === 'build' ? served : devId(served);
132
+ if (logger.isEnabled('debug'))
133
+ logger.log('debug', 'Inertia Vite plugin resolved a served module', {
134
+ source,
135
+ id,
136
+ command,
137
+ importer,
138
+ });
139
+ return command === 'build' ? { id, external: 'absolute' } : { id, external: true };
140
+ },
141
+ load(id) {
142
+ // Vite pre-transforms the imports it sees, the dev re-export too. The middleware below
143
+ // answers its URL before Vite does, so this text only fills Vite's module graph; were it
144
+ // ever served, the page would fail loudly instead of running without the served module.
145
+ const served = dev.get(id);
146
+ if (served === undefined)
147
+ return null;
148
+ return `throw new Error(${JSON.stringify(`${id} is answered by the dokebi-inertia middleware with export * from "${served}"; Vite served its placeholder instead`)});\n`;
149
+ },
150
+ configureServer(server) {
151
+ // D-0129: the file policy judges every request first, before the re-export and Vite.
152
+ const guard = files.middleware();
153
+ if (guard)
154
+ server.middlewares.use(guard);
155
+ // Added before Vite's own middlewares (the hook returns nothing): the re-export is served
156
+ // as written, without Vite's import rewriting (which would prefix the path with `base`).
157
+ server.middlewares.use((request, response, next) => {
158
+ const path = String(request.url ?? '').split('?', 1)[0] ?? '';
159
+ const text = answers.get(path);
160
+ if (text === undefined || (request.method !== 'GET' && request.method !== 'HEAD')) {
161
+ next();
162
+ return;
163
+ }
164
+ if (logger.isEnabled('debug'))
165
+ logger.log('debug', 'Inertia Vite plugin answered a served module re-export', { path });
166
+ response.statusCode = 200;
167
+ response.setHeader('content-type', 'text/javascript; charset=utf-8');
168
+ response.setHeader('cache-control', 'no-cache');
169
+ response.end(request.method === 'HEAD' ? '' : text);
170
+ });
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,44 @@
1
+ import type { Logger } from '@dokebi/core';
2
+ /** The part of Vite's server the filter uses. */
3
+ interface WatchedServer {
4
+ watcher: {
5
+ emit(event: string | symbol, ...args: unknown[]): boolean;
6
+ };
7
+ }
8
+ /** The plugin as Vite takes it (this package does not depend on Vite's types). */
9
+ export interface StaleEventPlugin {
10
+ name: string;
11
+ enforce: 'pre';
12
+ configureServer(server: WatchedServer): void;
13
+ load(this: {
14
+ environment?: {
15
+ name?: string;
16
+ };
17
+ } | undefined, id: string, options?: {
18
+ ssr?: boolean;
19
+ }): null;
20
+ }
21
+ /**
22
+ * The records and the decision. `record(file, environment)` keeps the first state an environment
23
+ * saw; `stale(file)` answers whether an event for the file is no change and, when it is a change,
24
+ * moves every record of the file to the new state.
25
+ */
26
+ export declare class ServedFiles {
27
+ #private;
28
+ constructor(logger: Logger);
29
+ /** Events dropped so far. */
30
+ get dropped(): number;
31
+ /** Records the state of `file` for `environment` unless it has one (a missing file: none). */
32
+ record(file: string, environment: string): void;
33
+ /** Records the config files of `root` before Vite reads them. */
34
+ recordConfig(root: string): void;
35
+ /** Whether an event for `file` is no change (dropped); a change updates the records. */
36
+ stale(file: string, event: string): boolean;
37
+ }
38
+ /**
39
+ * The Vite plugin of the host: records module files on their first load (`enforce: 'pre'`, so the
40
+ * state is taken before Vite reads the file) and drops watcher events that are no change (the
41
+ * watcher's `emit`, so module invalidation, HMR, reloads and config restarts all skip them).
42
+ */
43
+ export declare function staleEventFilter(files: ServedFiles, logger: Logger): StaleEventPlugin;
44
+ export {};
@@ -0,0 +1,176 @@
1
+ // File events of the Vite host that are not changes (D-0045 addendum 4). On macOS, FSEvents also
2
+ // delivers events for writes made before the watch started (`dokebi init`, `dokebi add`,
3
+ // `npm install`, a checkout right before `dokebi dev`), tens of seconds or minutes late when
4
+ // fseventsd is busy. Vite took each of them for an edit: it invalidated the module, logged
5
+ // `(ssr) page reload views/ssr.js` or sent a reload (buffered until the first page connects, so a
6
+ // page reloaded right after it opened), and restarted itself for a `vite.config.*` event.
7
+ //
8
+ // The filter records a file's state (device, inode, size, modification time) when Vite first loads
9
+ // it in an environment (client, ssr), before Vite reads it, and the config files before Vite
10
+ // starts. A `change`, `add` or `unlink` event of the watcher for a file whose every recorded state
11
+ // equals its state now is dropped before Vite sees it: whatever Vite holds was read from the file
12
+ // as it is. Any other event goes on and the records take the new state, so a real edit is handled
13
+ // at once, as without the filter. Files Vite never loaded through the plugin pipeline (no record)
14
+ // are never filtered.
15
+ import { statSync } from 'node:fs';
16
+ import { isAbsolute, join, resolve } from 'node:path';
17
+ import { diagnostics } from '@dokebi/core/libraries';
18
+ const { describeError } = diagnostics;
19
+ /** Config files Vite looks for in the root (its DEFAULT_CONFIG_FILES). */
20
+ const CONFIG_FILES = [
21
+ 'vite.config.js',
22
+ 'vite.config.mjs',
23
+ 'vite.config.ts',
24
+ 'vite.config.cjs',
25
+ 'vite.config.mts',
26
+ 'vite.config.cts',
27
+ ];
28
+ /** The record that stands for "read before Vite started" (config files). */
29
+ const STARTUP = 'startup';
30
+ /** Watcher events that make Vite reload, update or restart. */
31
+ const FILE_EVENTS = new Set(['change', 'add', 'unlink']);
32
+ /** One path form for Vite's ids (`/`) and chokidar's paths (native). */
33
+ const normal = (path) => resolve(path).replaceAll('\\', '/');
34
+ /** The file of a module id, or undefined for virtual and dependency modules. */
35
+ function fileOf(id) {
36
+ if (id.startsWith('\0'))
37
+ return undefined;
38
+ const path = id.replace(/[?#].*$/s, '');
39
+ if (!isAbsolute(path) || path.includes('/node_modules/'))
40
+ return undefined;
41
+ return normal(path);
42
+ }
43
+ /**
44
+ * The records and the decision. `record(file, environment)` keeps the first state an environment
45
+ * saw; `stale(file)` answers whether an event for the file is no change and, when it is a change,
46
+ * moves every record of the file to the new state.
47
+ */
48
+ export class ServedFiles {
49
+ #records = new Map();
50
+ #logger;
51
+ #dropped = 0;
52
+ #statFailures = 0;
53
+ #warnedAt = Number.NEGATIVE_INFINITY;
54
+ constructor(logger) {
55
+ this.#logger = logger;
56
+ }
57
+ /** Events dropped so far. */
58
+ get dropped() {
59
+ return this.#dropped;
60
+ }
61
+ /** Records the state of `file` for `environment` unless it has one (a missing file: none). */
62
+ record(file, environment) {
63
+ const path = normal(file);
64
+ let records = this.#records.get(path);
65
+ if (records?.has(environment))
66
+ return;
67
+ const state = this.#read(path);
68
+ if (state === undefined || state === null)
69
+ return;
70
+ if (!records) {
71
+ records = new Map();
72
+ this.#records.set(path, records);
73
+ }
74
+ records.set(environment, state);
75
+ }
76
+ /** Records the config files of `root` before Vite reads them. */
77
+ recordConfig(root) {
78
+ for (const name of CONFIG_FILES)
79
+ this.record(join(root, name), STARTUP);
80
+ }
81
+ /** Whether an event for `file` is no change (dropped); a change updates the records. */
82
+ stale(file, event) {
83
+ const path = normal(file);
84
+ const records = this.#records.get(path);
85
+ if (!records)
86
+ return false;
87
+ const state = this.#read(path);
88
+ if (state !== undefined && state !== null && [...records.values()].every((r) => r === state)) {
89
+ this.#dropped++;
90
+ if (this.#dropped === 1 || this.#dropped % 100 === 0)
91
+ this.#logger.log('debug', 'Vite dev host ignored an event for a file that did not change', {
92
+ file: path,
93
+ event,
94
+ dropped: this.#dropped,
95
+ });
96
+ return true;
97
+ }
98
+ if (state === undefined || state === null)
99
+ this.#records.delete(path);
100
+ else
101
+ for (const environment of records.keys())
102
+ records.set(environment, state);
103
+ if (this.#logger.isEnabled('debug'))
104
+ this.#logger.log('debug', 'Vite dev host passes a file change', {
105
+ file: path,
106
+ event,
107
+ state: state === undefined ? 'absent' : state === null ? 'unreadable' : 'present',
108
+ });
109
+ return false;
110
+ }
111
+ /** The state, undefined when absent, null when it cannot be read (logged; the event goes on). */
112
+ #read(path) {
113
+ try {
114
+ const stats = statSync(path);
115
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
116
+ }
117
+ catch (error) {
118
+ if (error.code === 'ENOENT')
119
+ return undefined;
120
+ // Once per 10 seconds, with how many failed since the last warning (D-0047).
121
+ this.#statFailures++;
122
+ const now = Date.now();
123
+ if (now - this.#warnedAt >= 10_000) {
124
+ this.#logger.log('warn', 'Vite dev host could not read the state of a file', {
125
+ code: 'INERTIA_VITE_WATCH_STAT_FAILED',
126
+ file: path,
127
+ repeated: this.#statFailures,
128
+ error: describeError(error),
129
+ });
130
+ this.#warnedAt = now;
131
+ this.#statFailures = 0;
132
+ }
133
+ return null;
134
+ }
135
+ }
136
+ }
137
+ /**
138
+ * The Vite plugin of the host: records module files on their first load (`enforce: 'pre'`, so the
139
+ * state is taken before Vite reads the file) and drops watcher events that are no change (the
140
+ * watcher's `emit`, so module invalidation, HMR, reloads and config restarts all skip them).
141
+ */
142
+ export function staleEventFilter(files, logger) {
143
+ return {
144
+ name: 'dokebi:stale-watch-events',
145
+ enforce: 'pre',
146
+ configureServer(server) {
147
+ const watcher = server.watcher;
148
+ const emit = watcher.emit;
149
+ // chokidar emits the event, then `all` with the same arguments: one decision for both.
150
+ let dropped;
151
+ watcher.emit = function (event, ...args) {
152
+ if (typeof event === 'string' && FILE_EVENTS.has(event) && typeof args[0] === 'string') {
153
+ dropped = files.stale(args[0], event) ? args[0] : undefined;
154
+ if (dropped !== undefined)
155
+ return false;
156
+ }
157
+ else if (event === 'all' &&
158
+ typeof args[1] === 'string' &&
159
+ FILE_EVENTS.has(String(args[0]))) {
160
+ const skip = dropped === args[1];
161
+ dropped = undefined;
162
+ if (skip)
163
+ return false;
164
+ }
165
+ return emit.call(this, event, ...args);
166
+ };
167
+ logger.log('debug', 'Vite dev host filters watcher events of unchanged files');
168
+ },
169
+ load(id, options) {
170
+ const file = fileOf(id);
171
+ if (file)
172
+ files.record(file, this?.environment?.name ?? (options?.ssr ? 'ssr' : 'client'));
173
+ return null;
174
+ },
175
+ };
176
+ }