@b9g/shovel 0.2.0-beta.10 → 0.2.0-beta.11

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.
@@ -0,0 +1,404 @@
1
+ import {
2
+ createConfigPlugin,
3
+ createEntryPlugin,
4
+ getGitSHA
5
+ } from "./chunk-ADR5RW57.js";
6
+ import {
7
+ applyJSXOptions,
8
+ assetsPlugin,
9
+ importMetaPlugin,
10
+ loadJSXConfig
11
+ } from "./chunk-JJFM7PO2.js";
12
+ import {
13
+ DEFAULTS,
14
+ findProjectRoot
15
+ } from "./chunk-GRAFMTEH.js";
16
+
17
+ // src/commands/develop.ts
18
+ import { getLogger as getLogger2 } from "@logtape/logtape";
19
+ import * as Platform from "@b9g/platform";
20
+
21
+ // src/utils/watcher.ts
22
+ import * as ESBuild from "esbuild";
23
+ import { builtinModules } from "node:module";
24
+ import { resolve, join, dirname, basename } from "path";
25
+ import { mkdir } from "fs/promises";
26
+ import { watch, existsSync } from "fs";
27
+ import { getLogger } from "@logtape/logtape";
28
+ var logger = getLogger(["shovel"]);
29
+ var Watcher = class {
30
+ #options;
31
+ #ctx;
32
+ #projectRoot;
33
+ #initialBuildComplete;
34
+ #initialBuildResolve;
35
+ #currentEntrypoint;
36
+ #configWatchers;
37
+ #dirWatchers;
38
+ #userEntryPath;
39
+ constructor(options) {
40
+ this.#options = options;
41
+ this.#projectRoot = findProjectRoot();
42
+ this.#initialBuildComplete = false;
43
+ this.#currentEntrypoint = "";
44
+ this.#configWatchers = [];
45
+ this.#dirWatchers = /* @__PURE__ */ new Map();
46
+ this.#userEntryPath = "";
47
+ }
48
+ /**
49
+ * Start watching and building
50
+ * @returns Result with success status and the hashed entrypoint path
51
+ */
52
+ async start() {
53
+ const entryPath = resolve(this.#projectRoot, this.#options.entrypoint);
54
+ this.#userEntryPath = entryPath;
55
+ const outputDir = resolve(this.#projectRoot, this.#options.outDir);
56
+ await mkdir(join(outputDir, "server"), { recursive: true });
57
+ const workerEntryWrapper = this.#options.platform.getEntryWrapper(
58
+ entryPath,
59
+ { type: "worker", outDir: outputDir }
60
+ );
61
+ const jsxOptions = await loadJSXConfig(this.#projectRoot);
62
+ const initialBuildPromise = new Promise((resolve2) => {
63
+ this.#initialBuildResolve = resolve2;
64
+ });
65
+ const platformESBuildConfig = this.#options.platformESBuildConfig;
66
+ const external = platformESBuildConfig.external ?? ["node:*"];
67
+ const buildOptions = {
68
+ entryPoints: {
69
+ server: "shovel:entry",
70
+ config: "shovel:config"
71
+ },
72
+ bundle: true,
73
+ format: "esm",
74
+ target: "es2022",
75
+ platform: platformESBuildConfig.platform ?? "node",
76
+ outdir: `${outputDir}/server`,
77
+ // Worker gets stable name, server gets hash for cache busting
78
+ entryNames: "[name]",
79
+ metafile: true,
80
+ absWorkingDir: this.#projectRoot,
81
+ conditions: platformESBuildConfig.conditions ?? ["import", "module"],
82
+ plugins: [
83
+ createConfigPlugin(this.#projectRoot, this.#options.outDir, {
84
+ platformDefaults: this.#options.platform.getDefaults()
85
+ }),
86
+ createEntryPlugin(this.#projectRoot, workerEntryWrapper),
87
+ importMetaPlugin(),
88
+ assetsPlugin({
89
+ outDir: outputDir,
90
+ clientBuild: {
91
+ jsx: jsxOptions.jsx,
92
+ jsxFactory: jsxOptions.jsxFactory,
93
+ jsxFragment: jsxOptions.jsxFragment,
94
+ jsxImportSource: jsxOptions.jsxImportSource
95
+ }
96
+ }),
97
+ // Plugin to detect build completion (works with watch mode)
98
+ {
99
+ name: "build-notify",
100
+ setup: (build) => {
101
+ build.onStart(() => {
102
+ logger.info("Building...");
103
+ });
104
+ build.onEnd(async (result) => {
105
+ let success = result.errors.length === 0;
106
+ const dynamicImportWarnings = (result.warnings || []).filter(
107
+ (w) => (w.text.includes("cannot be bundled") || w.text.includes("import() call") || w.text.includes("dynamic import")) && !w.text.includes("./server.js")
108
+ );
109
+ if (dynamicImportWarnings.length > 0) {
110
+ success = false;
111
+ for (const warning of dynamicImportWarnings) {
112
+ const loc = warning.location;
113
+ const file = loc?.file || "unknown";
114
+ const line = loc?.line || "?";
115
+ logger.error(
116
+ "Non-analyzable dynamic import at {file}:{line}: {text}",
117
+ { file, line, text: warning.text }
118
+ );
119
+ }
120
+ logger.error(
121
+ "Dynamic imports must use literal strings, not variables. For config-driven providers, ensure they are registered in shovel.json."
122
+ );
123
+ }
124
+ if (result.metafile) {
125
+ const hasNodeWildcard = external.includes("node:*");
126
+ const allowedSet = new Set(external);
127
+ const unexpectedExternals = [];
128
+ for (const path of Object.keys(result.metafile.inputs)) {
129
+ if (!path.startsWith("<external>:"))
130
+ continue;
131
+ const moduleName = path.slice("<external>:".length);
132
+ const isAllowed = allowedSet.has(moduleName) || hasNodeWildcard && moduleName.startsWith("node:") || builtinModules.includes(moduleName);
133
+ if (!isAllowed && !unexpectedExternals.includes(moduleName)) {
134
+ unexpectedExternals.push(moduleName);
135
+ }
136
+ }
137
+ if (unexpectedExternals.length > 0) {
138
+ success = false;
139
+ for (const ext of unexpectedExternals) {
140
+ logger.error("Unexpected external import: {module}", {
141
+ module: ext
142
+ });
143
+ }
144
+ logger.error(
145
+ "These modules are not bundled and won't be available at runtime."
146
+ );
147
+ }
148
+ }
149
+ let outputPath = "";
150
+ if (result.metafile) {
151
+ const outputs = Object.keys(result.metafile.outputs);
152
+ const serverOutput = outputs.find(
153
+ (p) => p.endsWith("server.js")
154
+ );
155
+ if (serverOutput) {
156
+ outputPath = resolve(this.#projectRoot, serverOutput);
157
+ }
158
+ }
159
+ if (success) {
160
+ logger.debug("Build complete", { entrypoint: outputPath });
161
+ } else {
162
+ logger.error("Build errors: {errors}", { errors: result.errors });
163
+ }
164
+ this.#currentEntrypoint = outputPath;
165
+ if (result.metafile) {
166
+ this.#updateSourceWatchers(result.metafile);
167
+ }
168
+ if (!this.#initialBuildComplete) {
169
+ this.#initialBuildComplete = true;
170
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
171
+ this.#initialBuildResolve?.({ success, entrypoint: outputPath });
172
+ } else {
173
+ await this.#options.onBuild?.(success, outputPath);
174
+ }
175
+ });
176
+ }
177
+ }
178
+ ],
179
+ define: {
180
+ ...platformESBuildConfig.define ?? {},
181
+ // Inject output directory for [outdir] placeholder resolution
182
+ __SHOVEL_OUTDIR__: JSON.stringify(outputDir),
183
+ // Inject git commit SHA for [git] placeholder
184
+ __SHOVEL_GIT__: JSON.stringify(getGitSHA(this.#projectRoot))
185
+ },
186
+ // Mark ./server.js as external so it's imported at runtime (sibling output file)
187
+ external: [...external, "./server.js"],
188
+ sourcemap: "inline",
189
+ minify: false,
190
+ treeShaking: true
191
+ };
192
+ applyJSXOptions(buildOptions, jsxOptions);
193
+ this.#ctx = await ESBuild.context(buildOptions);
194
+ logger.debug("Starting esbuild watch mode");
195
+ await this.#ctx.watch();
196
+ this.#watchConfigFiles();
197
+ return initialBuildPromise;
198
+ }
199
+ /**
200
+ * Watch shovel.json and package.json for changes
201
+ * Triggers rebuild when config changes
202
+ */
203
+ #watchConfigFiles() {
204
+ const configFiles = ["shovel.json", "package.json"];
205
+ for (const filename of configFiles) {
206
+ const filepath = join(this.#projectRoot, filename);
207
+ if (!existsSync(filepath))
208
+ continue;
209
+ try {
210
+ const watcher = watch(filepath, { persistent: false }, (event) => {
211
+ if (event === "change") {
212
+ logger.info(`Config changed: ${filename}, rebuilding...`);
213
+ this.#ctx?.rebuild().catch((err) => {
214
+ logger.error("Rebuild failed: {error}", { error: err });
215
+ });
216
+ }
217
+ });
218
+ this.#configWatchers.push(watcher);
219
+ } catch (err) {
220
+ logger.warn("Failed to watch {file}: {error}", {
221
+ file: filename,
222
+ error: err
223
+ });
224
+ }
225
+ }
226
+ }
227
+ /**
228
+ * Update native directory watchers for source files from metafile.
229
+ * Uses fs.watch on directories for instant inotify/fsevents detection
230
+ * as a complement to esbuild's polling-based watch mode.
231
+ *
232
+ * Watching directories instead of files handles:
233
+ * - File deletion and recreation (directory watcher survives)
234
+ * - Concurrent modifications (one watcher per directory)
235
+ * - Fewer file descriptors (one per directory vs one per file)
236
+ */
237
+ #updateSourceWatchers(metafile) {
238
+ const newDirFiles = /* @__PURE__ */ new Map();
239
+ if (this.#userEntryPath) {
240
+ const entryDir = dirname(this.#userEntryPath);
241
+ const entryFile = basename(this.#userEntryPath);
242
+ if (!newDirFiles.has(entryDir)) {
243
+ newDirFiles.set(entryDir, /* @__PURE__ */ new Set());
244
+ }
245
+ newDirFiles.get(entryDir).add(entryFile);
246
+ logger.debug("Explicitly watching user entry file: {path}", {
247
+ path: this.#userEntryPath
248
+ });
249
+ }
250
+ for (const inputPath of Object.keys(metafile.inputs)) {
251
+ if (inputPath.startsWith("<") || inputPath.startsWith("shovel")) {
252
+ continue;
253
+ }
254
+ const fullPath = resolve(this.#projectRoot, inputPath);
255
+ const dir = dirname(fullPath);
256
+ const file = basename(fullPath);
257
+ if (!newDirFiles.has(dir)) {
258
+ newDirFiles.set(dir, /* @__PURE__ */ new Set());
259
+ }
260
+ newDirFiles.get(dir).add(file);
261
+ }
262
+ for (const [dir, entry] of this.#dirWatchers) {
263
+ if (!newDirFiles.has(dir)) {
264
+ entry.watcher.close();
265
+ this.#dirWatchers.delete(dir);
266
+ }
267
+ }
268
+ for (const [dir, files] of newDirFiles) {
269
+ const existing = this.#dirWatchers.get(dir);
270
+ if (existing) {
271
+ existing.files = files;
272
+ } else {
273
+ if (!existsSync(dir))
274
+ continue;
275
+ try {
276
+ const watcher = watch(dir, { persistent: false }, (event, filename) => {
277
+ const entry = this.#dirWatchers.get(dir);
278
+ if (!entry)
279
+ return;
280
+ const isTrackedFile = filename ? entry.files.has(filename) : true;
281
+ if (isTrackedFile) {
282
+ logger.debug("Native watcher detected change: {file}", {
283
+ file: filename ? join(dir, filename) : dir
284
+ });
285
+ this.#ctx?.rebuild().catch((err) => {
286
+ logger.error("Rebuild failed: {error}", { error: err });
287
+ });
288
+ }
289
+ });
290
+ this.#dirWatchers.set(dir, { watcher, files });
291
+ } catch (err) {
292
+ logger.debug("Failed to watch directory {dir}: {error}", {
293
+ dir,
294
+ error: err
295
+ });
296
+ }
297
+ }
298
+ }
299
+ const totalFiles = Array.from(this.#dirWatchers.values()).reduce(
300
+ (sum, entry) => sum + entry.files.size,
301
+ 0
302
+ );
303
+ const watchedDirs = Array.from(this.#dirWatchers.keys());
304
+ logger.debug(
305
+ "Watching {fileCount} source files in {dirCount} directories with native fs.watch",
306
+ { fileCount: totalFiles, dirCount: this.#dirWatchers.size }
307
+ );
308
+ logger.debug("Watched directories: {dirs}", {
309
+ dirs: watchedDirs.slice(0, 5).join(", ") + (watchedDirs.length > 5 ? "..." : "")
310
+ });
311
+ }
312
+ /**
313
+ * Stop watching and dispose of esbuild context
314
+ */
315
+ async stop() {
316
+ for (const watcher of this.#configWatchers) {
317
+ watcher.close();
318
+ }
319
+ this.#configWatchers = [];
320
+ for (const entry of this.#dirWatchers.values()) {
321
+ entry.watcher.close();
322
+ }
323
+ this.#dirWatchers.clear();
324
+ if (this.#ctx) {
325
+ await this.#ctx.dispose();
326
+ this.#ctx = void 0;
327
+ }
328
+ }
329
+ };
330
+
331
+ // src/commands/develop.ts
332
+ var logger2 = getLogger2(["shovel"]);
333
+ async function developCommand(entrypoint, options, config) {
334
+ try {
335
+ const platformName = Platform.resolvePlatform({ ...options, config });
336
+ const workerCount = getWorkerCount(options, config);
337
+ logger2.debug("Platform: {platform}", { platform: platformName });
338
+ logger2.debug("Worker count: {workerCount}", { workerCount });
339
+ const platformInstance = await Platform.createPlatform(platformName, {
340
+ port: parseInt(options.port || String(DEFAULTS.SERVER.PORT), 10),
341
+ host: options.host || DEFAULTS.SERVER.HOST
342
+ });
343
+ const platformESBuildConfig = platformInstance.getESBuildConfig();
344
+ logger2.info("Starting development server");
345
+ let serviceWorker;
346
+ const outDir = "dist";
347
+ const watcher = new Watcher({
348
+ entrypoint,
349
+ outDir,
350
+ platform: platformInstance,
351
+ platformESBuildConfig,
352
+ onBuild: async (success, builtEntrypoint2) => {
353
+ if (success && serviceWorker) {
354
+ if (platformInstance && typeof platformInstance.reloadWorkers === "function") {
355
+ await platformInstance.reloadWorkers(builtEntrypoint2);
356
+ logger2.info("Reloaded");
357
+ }
358
+ }
359
+ }
360
+ });
361
+ const { success: buildSuccess, entrypoint: builtEntrypoint } = await watcher.start();
362
+ if (!buildSuccess || !builtEntrypoint) {
363
+ logger2.error("Initial build failed, watching for changes to retry");
364
+ await new Promise(() => {
365
+ });
366
+ }
367
+ serviceWorker = await platformInstance.loadServiceWorker(builtEntrypoint, {
368
+ hotReload: true,
369
+ workerCount
370
+ });
371
+ const server = platformInstance.createServer(serviceWorker.handleRequest, {
372
+ port: parseInt(options.port || String(DEFAULTS.SERVER.PORT), 10),
373
+ host: options.host || DEFAULTS.SERVER.HOST
374
+ });
375
+ await server.listen();
376
+ logger2.info("Server running at http://{host}:{port}", {
377
+ host: options.host,
378
+ port: options.port
379
+ });
380
+ const shutdown = async (signal) => {
381
+ logger2.debug("Shutting down ({signal})", { signal });
382
+ await watcher.stop();
383
+ await serviceWorker?.dispose();
384
+ await platformInstance.dispose();
385
+ await server.close();
386
+ logger2.debug("Shutdown complete");
387
+ process.exit(0);
388
+ };
389
+ process.on("SIGINT", () => shutdown("SIGINT"));
390
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
391
+ } catch (error) {
392
+ logger2.error("Failed to start development server: {error}", { error });
393
+ process.exit(1);
394
+ }
395
+ }
396
+ function getWorkerCount(options, config) {
397
+ if (options.workers) {
398
+ return parseInt(options.workers, 10);
399
+ }
400
+ return config?.workers ?? DEFAULTS.WORKERS;
401
+ }
402
+ export {
403
+ developCommand
404
+ };
@@ -1,7 +1,7 @@
1
1
  // src/commands/info.ts
2
2
  import { getLogger } from "@logtape/logtape";
3
3
  import { detectRuntime, detectDevelopmentPlatform } from "@b9g/platform";
4
- var logger = getLogger(["cli"]);
4
+ var logger = getLogger(["shovel"]);
5
5
  async function infoCommand() {
6
6
  logger.info("Shovel Platform Information", {});
7
7
  logger.info("---", {});