@b9g/shovel 0.2.0-beta.2 → 0.2.0-beta.21

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,1062 @@
1
+ import {
2
+ findProjectRoot,
3
+ generateConfigModule,
4
+ generateStorageTypes,
5
+ getNodeModulesPath,
6
+ loadRawConfig
7
+ } from "./chunk-PTLNYIRW.js";
8
+
9
+ // src/utils/bundler.ts
10
+ import * as ESBuild2 from "esbuild";
11
+ import { builtinModules, createRequire } from "node:module";
12
+ import { resolve, join as join4, dirname as dirname4, basename as basename2, relative as relative2 } from "path";
13
+ import { mkdir } from "fs/promises";
14
+ import { watch, existsSync as existsSync3 } from "fs";
15
+ import { getLogger as getLogger2 } from "@logtape/logtape";
16
+
17
+ // src/plugins/assets.ts
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
19
+ import { createHash } from "crypto";
20
+ import { join, basename, extname, relative, dirname } from "path";
21
+ import mime from "mime";
22
+ import * as ESBuild from "esbuild";
23
+ import { getLogger } from "@logtape/logtape";
24
+ import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill";
25
+ import { NodeGlobalsPolyfillPlugin } from "@esbuild-plugins/node-globals-polyfill";
26
+ var TRANSPILABLE_EXTENSIONS = /* @__PURE__ */ new Set([
27
+ ".ts",
28
+ ".tsx",
29
+ ".jsx",
30
+ ".mts",
31
+ ".cts"
32
+ ]);
33
+ var CSS_EXTENSIONS = /* @__PURE__ */ new Set([".css"]);
34
+ var logger = getLogger(["shovel"]);
35
+ var DEFAULT_CONFIG = {
36
+ outDir: "dist",
37
+ clientBuild: {}
38
+ };
39
+ var HASH_LENGTH = 16;
40
+ function mergeConfig(userConfig = {}) {
41
+ return {
42
+ ...DEFAULT_CONFIG,
43
+ ...userConfig
44
+ };
45
+ }
46
+ function normalizePath(basePath) {
47
+ if (!basePath.startsWith("/")) {
48
+ basePath = "/" + basePath;
49
+ }
50
+ if (!basePath.endsWith("/")) {
51
+ basePath = basePath + "/";
52
+ }
53
+ return basePath;
54
+ }
55
+ function assetsPlugin(options = {}) {
56
+ const config = mergeConfig(options);
57
+ const manifest = {
58
+ assets: {},
59
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
60
+ config: {
61
+ outDir: config.outDir
62
+ }
63
+ };
64
+ const contexts = /* @__PURE__ */ new Map();
65
+ return {
66
+ name: "shovel-assets",
67
+ setup(build2) {
68
+ build2.onResolve({ filter: /.*/ }, (_args) => {
69
+ return null;
70
+ });
71
+ async function getContext(absPath, buildOptions) {
72
+ let ctx = contexts.get(absPath);
73
+ if (!ctx) {
74
+ ctx = await ESBuild.context(buildOptions);
75
+ contexts.set(absPath, ctx);
76
+ }
77
+ return ctx;
78
+ }
79
+ build2.onLoad({ filter: /.*/ }, async (args) => {
80
+ if (!args.with?.assetBase || typeof args.with.assetBase !== "string") {
81
+ return null;
82
+ }
83
+ try {
84
+ const ext = extname(args.path);
85
+ const name = basename(args.path, ext);
86
+ const wantsCSS = args.with.type === "css";
87
+ const needsTranspilation = TRANSPILABLE_EXTENSIONS.has(ext);
88
+ const needsCSSBundling = CSS_EXTENSIONS.has(ext);
89
+ if (wantsCSS && !needsTranspilation) {
90
+ return {
91
+ errors: [
92
+ {
93
+ text: `type: "css" can only be used with transpilable files (.ts, .tsx, .jsx, etc.), not ${ext}`
94
+ }
95
+ ]
96
+ };
97
+ }
98
+ let content;
99
+ let outputExt = ext;
100
+ let mimeType;
101
+ if (needsTranspilation) {
102
+ const clientOpts = config.clientBuild;
103
+ const defaultPlugins = [
104
+ NodeModulesPolyfillPlugin(),
105
+ NodeGlobalsPolyfillPlugin({
106
+ process: true,
107
+ buffer: true
108
+ })
109
+ ];
110
+ const plugins = clientOpts.plugins ? [...clientOpts.plugins, ...defaultPlugins] : defaultPlugins;
111
+ const ctx = await getContext(args.path, {
112
+ entryPoints: [args.path],
113
+ bundle: true,
114
+ format: "esm",
115
+ target: "es2022",
116
+ platform: "browser",
117
+ write: false,
118
+ minify: true,
119
+ // outdir is required for esbuild to know where to put extracted CSS
120
+ outdir: config.outDir,
121
+ // Apply polyfills and user-provided client build options
122
+ plugins,
123
+ define: clientOpts.define,
124
+ inject: clientOpts.inject,
125
+ external: clientOpts.external,
126
+ alias: clientOpts.alias,
127
+ // Apply JSX configuration (defaults to @b9g/crank automatic runtime)
128
+ jsx: clientOpts.jsx ?? "automatic",
129
+ jsxFactory: clientOpts.jsxFactory,
130
+ jsxFragment: clientOpts.jsxFragment,
131
+ jsxImportSource: clientOpts.jsxImportSource ?? "@b9g/crank"
132
+ });
133
+ const result = await ctx.rebuild();
134
+ if (!result.outputFiles) {
135
+ return {
136
+ errors: [{ text: `No output files generated for ${args.path}` }]
137
+ };
138
+ }
139
+ if (wantsCSS) {
140
+ const cssOutput = result.outputFiles.find(
141
+ (f) => f.path.endsWith(".css")
142
+ );
143
+ if (!cssOutput) {
144
+ return {
145
+ errors: [
146
+ {
147
+ text: `No CSS was extracted from ${args.path}. The file must import CSS for type: "css" to work.`
148
+ }
149
+ ]
150
+ };
151
+ }
152
+ content = Buffer.from(cssOutput.text);
153
+ outputExt = ".css";
154
+ mimeType = "text/css";
155
+ } else {
156
+ const jsOutput = result.outputFiles.find(
157
+ (f) => f.path.endsWith(".js")
158
+ );
159
+ if (!jsOutput) {
160
+ return {
161
+ errors: [
162
+ {
163
+ text: `No JavaScript output was generated for ${args.path}`
164
+ }
165
+ ]
166
+ };
167
+ }
168
+ content = Buffer.from(jsOutput.text);
169
+ outputExt = ".js";
170
+ mimeType = "application/javascript";
171
+ }
172
+ } else if (needsCSSBundling) {
173
+ const entryPath = args.path;
174
+ const externalAbsolutePathsPlugin = {
175
+ name: "external-absolute-paths",
176
+ setup(build3) {
177
+ build3.onResolve({ filter: /^\// }, (resolveArgs) => {
178
+ if (resolveArgs.kind === "entry-point") {
179
+ return null;
180
+ }
181
+ return {
182
+ path: resolveArgs.path,
183
+ external: true
184
+ };
185
+ });
186
+ }
187
+ };
188
+ const ctx = await getContext(entryPath, {
189
+ entryPoints: [entryPath],
190
+ bundle: true,
191
+ write: false,
192
+ minify: true,
193
+ // outdir required for esbuild to generate output paths
194
+ outdir: config.outDir,
195
+ plugins: [externalAbsolutePathsPlugin],
196
+ // Loaders for web assets referenced in CSS via url()
197
+ loader: {
198
+ // Fonts
199
+ ".woff": "file",
200
+ ".woff2": "file",
201
+ ".ttf": "file",
202
+ ".eot": "file",
203
+ // Images
204
+ ".svg": "file",
205
+ ".png": "file",
206
+ ".jpg": "file",
207
+ ".jpeg": "file",
208
+ ".gif": "file",
209
+ ".webp": "file",
210
+ ".ico": "file",
211
+ // Media
212
+ ".mp4": "file",
213
+ ".webm": "file",
214
+ ".mp3": "file",
215
+ ".ogg": "file"
216
+ }
217
+ });
218
+ const result = await ctx.rebuild();
219
+ const cssOutput = result.outputFiles?.find(
220
+ (f) => f.path.endsWith(".css")
221
+ );
222
+ if (!cssOutput) {
223
+ return {
224
+ errors: [{ text: `No CSS output generated for ${args.path}` }]
225
+ };
226
+ }
227
+ const basePath2 = normalizePath(args.with.assetBase);
228
+ const cssOutputDir = join(config.outDir, "public", basePath2);
229
+ if (!existsSync(cssOutputDir)) {
230
+ mkdirSync(cssOutputDir, { recursive: true });
231
+ }
232
+ for (const file of result.outputFiles || []) {
233
+ if (file === cssOutput)
234
+ continue;
235
+ const assetFilename = file.path.split("/").pop();
236
+ const assetPath = join(cssOutputDir, assetFilename);
237
+ writeFileSync(assetPath, file.contents);
238
+ const assetUrl = `${basePath2}${assetFilename}`;
239
+ const assetHash = createHash("sha256").update(file.contents).digest("hex").slice(0, HASH_LENGTH);
240
+ manifest.assets[assetFilename] = {
241
+ source: assetFilename,
242
+ output: assetFilename,
243
+ url: assetUrl,
244
+ hash: assetHash,
245
+ size: file.contents.length,
246
+ type: mime.getType(assetFilename) || void 0
247
+ };
248
+ }
249
+ content = Buffer.from(cssOutput.text);
250
+ outputExt = ".css";
251
+ mimeType = "text/css";
252
+ } else {
253
+ content = readFileSync(args.path);
254
+ mimeType = mime.getType(args.path) || void 0;
255
+ }
256
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
257
+ let filename;
258
+ if (args.with.assetName && typeof args.with.assetName === "string") {
259
+ filename = args.with.assetName.replace(/\[name\]/g, name).replace(/\[ext\]/g, outputExt.slice(1));
260
+ } else {
261
+ filename = `${name}-${hash}${outputExt}`;
262
+ }
263
+ const basePath = normalizePath(args.with.assetBase);
264
+ const publicURL = `${basePath}${filename}`;
265
+ const outputDir = join(config.outDir, "public", basePath);
266
+ if (!existsSync(outputDir)) {
267
+ mkdirSync(outputDir, { recursive: true });
268
+ }
269
+ const outputPath = join(outputDir, filename);
270
+ writeFileSync(outputPath, content);
271
+ const sourcePath = relative(process.cwd(), args.path);
272
+ const manifestEntry = {
273
+ source: sourcePath,
274
+ output: filename,
275
+ url: publicURL,
276
+ hash,
277
+ size: content.length,
278
+ type: mimeType
279
+ };
280
+ manifest.assets[sourcePath] = manifestEntry;
281
+ return {
282
+ contents: `export default ${JSON.stringify(publicURL)};`,
283
+ loader: "js"
284
+ };
285
+ } catch (error) {
286
+ return {
287
+ errors: [
288
+ {
289
+ text: `Failed to process asset: ${error.message}`,
290
+ detail: error
291
+ }
292
+ ]
293
+ };
294
+ }
295
+ });
296
+ build2.onEnd(async () => {
297
+ for (const ctx of contexts.values()) {
298
+ await ctx.dispose();
299
+ }
300
+ contexts.clear();
301
+ try {
302
+ const manifestPath = join(config.outDir, "server", "assets.json");
303
+ const manifestDir = dirname(manifestPath);
304
+ if (!existsSync(manifestDir)) {
305
+ mkdirSync(manifestDir, { recursive: true });
306
+ }
307
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
308
+ logger.debug("Generated asset manifest", {
309
+ path: manifestPath,
310
+ assetCount: Object.keys(manifest.assets).length
311
+ });
312
+ } catch (error) {
313
+ logger.warn("Failed to write asset manifest: {error}", { error });
314
+ }
315
+ });
316
+ }
317
+ };
318
+ }
319
+
320
+ // src/plugins/import-meta.ts
321
+ import { readFile } from "fs/promises";
322
+ import { dirname as dirname2 } from "path";
323
+ import { pathToFileURL } from "url";
324
+ function importMetaPlugin() {
325
+ return {
326
+ name: "import-meta-transform",
327
+ setup(build2) {
328
+ build2.onLoad({ filter: /\.[jt]sx?$/, namespace: "file" }, async (args) => {
329
+ if (args.path.includes("node_modules") || args.path.includes("/packages/")) {
330
+ return null;
331
+ }
332
+ const contents = await readFile(args.path, "utf8");
333
+ if (!contents.includes("import.meta.url") && !contents.includes("import.meta.dirname") && !contents.includes("import.meta.filename")) {
334
+ return null;
335
+ }
336
+ const fileUrl = pathToFileURL(args.path).href;
337
+ const fileDirname = dirname2(args.path);
338
+ const fileFilename = args.path;
339
+ let transformed = contents;
340
+ transformed = transformed.replace(
341
+ /\bimport\.meta\.url\b/g,
342
+ JSON.stringify(fileUrl)
343
+ );
344
+ transformed = transformed.replace(
345
+ /\bimport\.meta\.dirname\b/g,
346
+ JSON.stringify(fileDirname)
347
+ );
348
+ transformed = transformed.replace(
349
+ /\bimport\.meta\.filename\b/g,
350
+ JSON.stringify(fileFilename)
351
+ );
352
+ const ext = args.path.split(".").pop();
353
+ let loader = "js";
354
+ if (ext === "ts")
355
+ loader = "ts";
356
+ else if (ext === "tsx")
357
+ loader = "tsx";
358
+ else if (ext === "jsx")
359
+ loader = "jsx";
360
+ return {
361
+ contents: transformed,
362
+ loader
363
+ };
364
+ });
365
+ }
366
+ };
367
+ }
368
+
369
+ // src/plugins/config.ts
370
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
371
+ import { join as join2, isAbsolute } from "node:path";
372
+ function createConfigPlugin(projectRoot, outDir = "dist", options = {}) {
373
+ const absoluteOutDir = isAbsolute(outDir) ? outDir : join2(projectRoot, outDir);
374
+ const serverOutDir = join2(absoluteOutDir, "server");
375
+ return {
376
+ name: "shovel-config",
377
+ setup(build2) {
378
+ build2.onResolve({ filter: /^shovel:config$/ }, (args) => ({
379
+ path: args.path,
380
+ namespace: "shovel-config"
381
+ }));
382
+ build2.onLoad({ filter: /.*/, namespace: "shovel-config" }, () => {
383
+ const rawConfig = loadRawConfig(projectRoot);
384
+ const configModuleCode = generateConfigModule(rawConfig, {
385
+ projectDir: projectRoot,
386
+ outDir: absoluteOutDir,
387
+ platformDefaults: options.platformDefaults,
388
+ lifecycle: options.lifecycle
389
+ });
390
+ const typesCode = generateStorageTypes(rawConfig, {
391
+ platformDefaults: options.platformDefaults
392
+ });
393
+ if (typesCode) {
394
+ mkdirSync2(serverOutDir, { recursive: true });
395
+ const typesPath = join2(serverOutDir, "shovel.d.ts");
396
+ writeFileSync2(typesPath, typesCode);
397
+ }
398
+ return {
399
+ contents: configModuleCode,
400
+ loader: "js",
401
+ resolveDir: projectRoot
402
+ };
403
+ });
404
+ }
405
+ };
406
+ }
407
+
408
+ // src/plugins/entry.ts
409
+ function createEntryPlugin(projectRoot, entryPoints) {
410
+ return {
411
+ name: "shovel-entry",
412
+ setup(build2) {
413
+ build2.onResolve({ filter: /^shovel:entry(:.+)?$/ }, (args) => ({
414
+ path: args.path,
415
+ namespace: "shovel-entry"
416
+ }));
417
+ build2.onLoad({ filter: /.*/, namespace: "shovel-entry" }, (args) => {
418
+ const match = args.path.match(/^shovel:entry(?::(.+))?$/);
419
+ const entryName = match?.[1] ?? Object.keys(entryPoints)[0];
420
+ const contents = entryPoints[entryName];
421
+ if (!contents) {
422
+ const available = Object.keys(entryPoints).join(", ");
423
+ return {
424
+ errors: [
425
+ {
426
+ text: `Unknown entry point "${entryName}". Available: ${available}`
427
+ }
428
+ ]
429
+ };
430
+ }
431
+ return {
432
+ contents,
433
+ loader: "js",
434
+ resolveDir: projectRoot
435
+ };
436
+ });
437
+ }
438
+ };
439
+ }
440
+
441
+ // src/utils/jsx-config.ts
442
+ import { readFile as readFile2 } from "fs/promises";
443
+ import { join as join3, dirname as dirname3 } from "path";
444
+ import { existsSync as existsSync2 } from "fs";
445
+ var CRANK_JSX_DEFAULTS = {
446
+ jsx: "automatic",
447
+ jsxImportSource: "@b9g/crank"
448
+ };
449
+ async function findTsConfig(startDir) {
450
+ let dir = startDir;
451
+ while (dir !== dirname3(dir)) {
452
+ const tsconfigPath = join3(dir, "tsconfig.json");
453
+ if (existsSync2(tsconfigPath)) {
454
+ return tsconfigPath;
455
+ }
456
+ dir = dirname3(dir);
457
+ }
458
+ return null;
459
+ }
460
+ async function parseTsConfig(tsconfigPath) {
461
+ const content = await readFile2(tsconfigPath, "utf8");
462
+ const stripped = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
463
+ const config = JSON.parse(stripped);
464
+ if (config.extends) {
465
+ const baseDir = dirname3(tsconfigPath);
466
+ let extendsPath = config.extends;
467
+ if (extendsPath.startsWith(".")) {
468
+ extendsPath = join3(baseDir, extendsPath);
469
+ } else {
470
+ extendsPath = join3(baseDir, "node_modules", extendsPath);
471
+ }
472
+ if (!extendsPath.endsWith(".json")) {
473
+ extendsPath += ".json";
474
+ }
475
+ if (existsSync2(extendsPath)) {
476
+ const baseConfig = await parseTsConfig(extendsPath);
477
+ return {
478
+ ...baseConfig,
479
+ ...config,
480
+ compilerOptions: {
481
+ ...baseConfig.compilerOptions,
482
+ ...config.compilerOptions
483
+ }
484
+ };
485
+ }
486
+ }
487
+ return config;
488
+ }
489
+ function mapTSConfigToESBuild(compilerOptions) {
490
+ const options = {};
491
+ if (compilerOptions.jsx) {
492
+ switch (compilerOptions.jsx) {
493
+ case "react":
494
+ case "react-native":
495
+ options.jsx = "transform";
496
+ break;
497
+ case "react-jsx":
498
+ case "react-jsxdev":
499
+ options.jsx = "automatic";
500
+ break;
501
+ case "preserve":
502
+ options.jsx = "preserve";
503
+ break;
504
+ }
505
+ }
506
+ if (compilerOptions.jsxFactory) {
507
+ options.jsxFactory = compilerOptions.jsxFactory;
508
+ }
509
+ if (compilerOptions.jsxFragmentFactory) {
510
+ options.jsxFragment = compilerOptions.jsxFragmentFactory;
511
+ }
512
+ if (compilerOptions.jsxImportSource) {
513
+ options.jsxImportSource = compilerOptions.jsxImportSource;
514
+ }
515
+ return options;
516
+ }
517
+ async function loadJSXConfig(projectRoot) {
518
+ const tsconfigPath = await findTsConfig(projectRoot);
519
+ if (tsconfigPath) {
520
+ const config = await parseTsConfig(tsconfigPath);
521
+ const compilerOptions = config.compilerOptions || {};
522
+ const hasJSXConfig = compilerOptions.jsx || compilerOptions.jsxFactory || compilerOptions.jsxFragmentFactory || compilerOptions.jsxImportSource;
523
+ if (hasJSXConfig) {
524
+ const tsOptions = mapTSConfigToESBuild(compilerOptions);
525
+ return {
526
+ ...CRANK_JSX_DEFAULTS,
527
+ ...tsOptions
528
+ };
529
+ }
530
+ }
531
+ return { ...CRANK_JSX_DEFAULTS };
532
+ }
533
+ function applyJSXOptions(buildOptions, jsxOptions) {
534
+ if (jsxOptions.jsx) {
535
+ buildOptions.jsx = jsxOptions.jsx;
536
+ }
537
+ if (jsxOptions.jsxFactory) {
538
+ buildOptions.jsxFactory = jsxOptions.jsxFactory;
539
+ }
540
+ if (jsxOptions.jsxFragment) {
541
+ buildOptions.jsxFragment = jsxOptions.jsxFragment;
542
+ }
543
+ if (jsxOptions.jsxImportSource) {
544
+ buildOptions.jsxImportSource = jsxOptions.jsxImportSource;
545
+ }
546
+ if (jsxOptions.jsxSideEffects !== void 0) {
547
+ buildOptions.jsxSideEffects = jsxOptions.jsxSideEffects;
548
+ }
549
+ }
550
+
551
+ // src/utils/git-sha.ts
552
+ import { execSync } from "child_process";
553
+ function getGitSHA(cwd) {
554
+ try {
555
+ return execSync("git rev-parse HEAD", {
556
+ encoding: "utf8",
557
+ cwd,
558
+ stdio: ["pipe", "pipe", "pipe"]
559
+ }).trim();
560
+ } catch (_err) {
561
+ return "";
562
+ }
563
+ }
564
+
565
+ // src/utils/bundler.ts
566
+ var logger2 = getLogger2(["shovel"]);
567
+ var REQUIRE_SHIM = `import{createRequire as __cR}from'module';const require=__cR(import.meta.url);`;
568
+ var ServerBundler = class {
569
+ #options;
570
+ #ctx;
571
+ #projectRoot;
572
+ #initialBuildComplete;
573
+ #initialBuildResolve;
574
+ #currentOutputs;
575
+ #configWatchers;
576
+ #dirWatchers;
577
+ #userEntryPath;
578
+ #watchOptions;
579
+ constructor(options) {
580
+ this.#options = options;
581
+ this.#projectRoot = findProjectRoot();
582
+ this.#initialBuildComplete = false;
583
+ this.#currentOutputs = { worker: "" };
584
+ this.#configWatchers = [];
585
+ this.#dirWatchers = /* @__PURE__ */ new Map();
586
+ this.#userEntryPath = "";
587
+ }
588
+ /**
589
+ * One-shot build.
590
+ *
591
+ * Produces all platform entry points:
592
+ * - Node/Bun: index.js (supervisor) + worker.js
593
+ * - Cloudflare: worker.js
594
+ *
595
+ * Build options (minify, sourcemap, etc.) come from userBuildConfig.
596
+ */
597
+ async build() {
598
+ const entryPath = resolve(this.#projectRoot, this.#options.entrypoint);
599
+ const outputDir = resolve(this.#projectRoot, this.#options.outDir);
600
+ const serverDir = join4(outputDir, "server");
601
+ await mkdir(serverDir, { recursive: true });
602
+ await mkdir(join4(outputDir, "public"), { recursive: true });
603
+ const buildOptions = await this.#createBuildOptions(entryPath, outputDir);
604
+ const result = await ESBuild2.build(buildOptions);
605
+ const external = buildOptions.external;
606
+ this.#validateBuildResult(result, external ?? ["node:*"]);
607
+ const outputs = this.#extractOutputPaths(result.metafile);
608
+ const success = result.errors.length === 0;
609
+ logger2.debug("Build complete", { outputs, success });
610
+ return { success, outputs };
611
+ }
612
+ /**
613
+ * Start watching and building.
614
+ *
615
+ * Returns after the initial build completes. Subsequent rebuilds
616
+ * trigger the onRebuild callback.
617
+ */
618
+ async watch(options = {}) {
619
+ this.#watchOptions = options;
620
+ const entryPath = resolve(this.#projectRoot, this.#options.entrypoint);
621
+ this.#userEntryPath = entryPath;
622
+ const outputDir = resolve(this.#projectRoot, this.#options.outDir);
623
+ await mkdir(join4(outputDir, "server"), { recursive: true });
624
+ const initialBuildPromise = new Promise((resolve2) => {
625
+ this.#initialBuildResolve = resolve2;
626
+ });
627
+ const buildOptions = await this.#createBuildOptions(entryPath, outputDir, {
628
+ watch: true
629
+ });
630
+ this.#ctx = await ESBuild2.context(buildOptions);
631
+ logger2.debug("Starting esbuild watch mode");
632
+ await this.#ctx.watch();
633
+ this.#watchConfigFiles();
634
+ return initialBuildPromise;
635
+ }
636
+ /**
637
+ * Stop watching and dispose of resources.
638
+ */
639
+ async stop() {
640
+ for (const watcher of this.#configWatchers) {
641
+ watcher.close();
642
+ }
643
+ this.#configWatchers = [];
644
+ for (const entry of this.#dirWatchers.values()) {
645
+ entry.watcher.close();
646
+ }
647
+ this.#dirWatchers.clear();
648
+ if (this.#ctx) {
649
+ await this.#ctx.dispose();
650
+ this.#ctx = void 0;
651
+ }
652
+ }
653
+ /**
654
+ * Create ESBuild options.
655
+ */
656
+ async #createBuildOptions(entryPath, outputDir, options = {}) {
657
+ const { watch: watch2 = false } = options;
658
+ const platformESBuildConfig = this.#options.platformESBuildConfig;
659
+ const platformDefaults = this.#options.platform.getDefaults();
660
+ const userBuildConfig = this.#options.userBuildConfig;
661
+ const relativeEntryPath = "./" + relative2(this.#projectRoot, entryPath);
662
+ const platformEntryPoints = this.#options.development ? this.#getDevelopmentEntryPoints(relativeEntryPath) : this.#options.platform.getProductionEntryPoints(relativeEntryPath);
663
+ const jsxOptions = await loadJSXConfig(this.#projectRoot);
664
+ const userPlugins = userBuildConfig?.plugins?.length ? await this.#loadUserPlugins(userBuildConfig.plugins) : [];
665
+ const platformExternal = platformESBuildConfig.external ?? ["node:*"];
666
+ const userExternal = userBuildConfig?.external ?? [];
667
+ const external = [...platformExternal, ...userExternal];
668
+ const isNodePlatform = (platformESBuildConfig.platform ?? "node") === "node";
669
+ const requireShim = isNodePlatform ? REQUIRE_SHIM : "";
670
+ const target = userBuildConfig?.target ?? "es2022";
671
+ const sourcemap = userBuildConfig?.sourcemap ?? (watch2 ? "inline" : false);
672
+ const minify = userBuildConfig?.minify ?? false;
673
+ const treeShaking = userBuildConfig?.treeShaking ?? true;
674
+ const esbuildEntryPoints = {
675
+ config: "shovel:config"
676
+ };
677
+ for (const name of Object.keys(platformEntryPoints)) {
678
+ esbuildEntryPoints[name] = `shovel:entry:${name}`;
679
+ }
680
+ const plugins = [
681
+ ...userPlugins,
682
+ createConfigPlugin(this.#projectRoot, this.#options.outDir, {
683
+ platformDefaults,
684
+ lifecycle: this.#options.lifecycle
685
+ }),
686
+ createEntryPlugin(this.#projectRoot, platformEntryPoints),
687
+ importMetaPlugin(),
688
+ assetsPlugin({
689
+ outDir: outputDir,
690
+ clientBuild: {
691
+ jsx: jsxOptions.jsx,
692
+ jsxFactory: jsxOptions.jsxFactory,
693
+ jsxFragment: jsxOptions.jsxFragment,
694
+ jsxImportSource: jsxOptions.jsxImportSource
695
+ }
696
+ })
697
+ ];
698
+ if (watch2) {
699
+ plugins.push(this.#createBuildNotifyPlugin(external));
700
+ }
701
+ const buildOptions = {
702
+ entryPoints: esbuildEntryPoints,
703
+ bundle: true,
704
+ format: "esm",
705
+ target,
706
+ platform: platformESBuildConfig.platform ?? "node",
707
+ outdir: `${outputDir}/server`,
708
+ entryNames: "[name]",
709
+ metafile: true,
710
+ absWorkingDir: this.#projectRoot,
711
+ conditions: platformESBuildConfig.conditions ?? ["import", "module"],
712
+ nodePaths: [getNodeModulesPath()],
713
+ plugins,
714
+ define: {
715
+ ...platformESBuildConfig.define ?? {},
716
+ ...userBuildConfig?.define ?? {},
717
+ __SHOVEL_OUTDIR__: JSON.stringify(outputDir),
718
+ __SHOVEL_GIT__: JSON.stringify(getGitSHA(this.#projectRoot))
719
+ },
720
+ alias: userBuildConfig?.alias,
721
+ external,
722
+ sourcemap,
723
+ minify,
724
+ treeShaking,
725
+ ...requireShim && { banner: { js: requireShim } }
726
+ };
727
+ applyJSXOptions(buildOptions, jsxOptions);
728
+ return buildOptions;
729
+ }
730
+ /**
731
+ * Extract output paths from metafile.
732
+ */
733
+ #extractOutputPaths(metafile) {
734
+ const outputs = {};
735
+ if (!metafile)
736
+ return outputs;
737
+ const outputPaths = Object.keys(metafile.outputs);
738
+ const indexOutput = outputPaths.find((p) => p.endsWith("index.js"));
739
+ if (indexOutput) {
740
+ outputs.index = resolve(this.#projectRoot, indexOutput);
741
+ }
742
+ const workerOutput = outputPaths.find((p) => p.endsWith("worker.js"));
743
+ if (workerOutput) {
744
+ outputs.worker = resolve(this.#projectRoot, workerOutput);
745
+ }
746
+ return outputs;
747
+ }
748
+ /**
749
+ * Get development entry points.
750
+ * Workers use startWorkerMessageLoop() to handle requests from ServiceWorkerPool.
751
+ */
752
+ #getDevelopmentEntryPoints(userEntryPath) {
753
+ const workerCode = `// Development Worker
754
+ import {initWorkerRuntime, runLifecycle, startWorkerMessageLoop} from "@b9g/platform/runtime";
755
+ import {config} from "shovel:config";
756
+
757
+ const result = await initWorkerRuntime({config});
758
+ const registration = result.registration;
759
+
760
+ await import("${userEntryPath}");
761
+ await runLifecycle(registration);
762
+ startWorkerMessageLoop({registration, databases: result.databases});
763
+ `;
764
+ return { worker: workerCode };
765
+ }
766
+ /**
767
+ * Load user ESBuild plugins from build config.
768
+ */
769
+ async #loadUserPlugins(plugins) {
770
+ const loadedPlugins = [];
771
+ for (const pluginConfig of plugins) {
772
+ const {
773
+ module: modulePath,
774
+ export: exportName = "default",
775
+ ...options
776
+ } = pluginConfig;
777
+ try {
778
+ const projectRequire = createRequire(
779
+ join4(this.#projectRoot, "package.json")
780
+ );
781
+ const resolvedPath = modulePath.startsWith(".") ? resolve(this.#projectRoot, modulePath) : projectRequire.resolve(modulePath);
782
+ const mod = await import(resolvedPath);
783
+ const pluginFactory = exportName === "default" ? mod.default : mod[exportName];
784
+ if (typeof pluginFactory !== "function") {
785
+ throw new Error(
786
+ `Plugin export "${exportName}" from "${modulePath}" is not a function`
787
+ );
788
+ }
789
+ const hasOptions = Object.keys(options).length > 0;
790
+ const plugin = hasOptions ? pluginFactory(options) : pluginFactory();
791
+ loadedPlugins.push(plugin);
792
+ logger2.debug("Loaded ESBuild plugin", {
793
+ module: modulePath,
794
+ export: exportName
795
+ });
796
+ } catch (error) {
797
+ throw new Error(
798
+ `Failed to load ESBuild plugin "${modulePath}": ${error instanceof Error ? error.message : error}`
799
+ );
800
+ }
801
+ }
802
+ return loadedPlugins;
803
+ }
804
+ /**
805
+ * Create the build-notify plugin for watch mode.
806
+ */
807
+ #createBuildNotifyPlugin(external) {
808
+ return {
809
+ name: "build-notify",
810
+ setup: (build2) => {
811
+ build2.onStart(() => {
812
+ logger2.info("Building...");
813
+ });
814
+ build2.onEnd(async (result) => {
815
+ let success = result.errors.length === 0;
816
+ const dynamicImportWarnings = (result.warnings || []).filter(
817
+ (w) => (w.text.includes("cannot be bundled") || w.text.includes("import() call") || w.text.includes("dynamic import")) && !w.text.includes("./server.js")
818
+ );
819
+ if (dynamicImportWarnings.length > 0) {
820
+ success = false;
821
+ for (const warning of dynamicImportWarnings) {
822
+ const loc = warning.location;
823
+ const file = loc?.file || "unknown";
824
+ const line = loc?.line || "?";
825
+ logger2.error(
826
+ "Non-analyzable dynamic import at {file}:{line}: {text}",
827
+ { file, line, text: warning.text }
828
+ );
829
+ }
830
+ logger2.error(
831
+ "Dynamic imports must use literal strings, not variables. For config-driven providers, ensure they are registered in shovel.json."
832
+ );
833
+ }
834
+ if (result.metafile) {
835
+ const hasNodeWildcard = external.includes("node:*");
836
+ const allowedSet = new Set(external);
837
+ const unexpectedExternals = [];
838
+ for (const path of Object.keys(result.metafile.inputs)) {
839
+ if (!path.startsWith("<external>:"))
840
+ continue;
841
+ const moduleName = path.slice("<external>:".length);
842
+ const isAllowed = allowedSet.has(moduleName) || hasNodeWildcard && moduleName.startsWith("node:") || builtinModules.includes(moduleName);
843
+ if (!isAllowed && !unexpectedExternals.includes(moduleName)) {
844
+ unexpectedExternals.push(moduleName);
845
+ }
846
+ }
847
+ if (unexpectedExternals.length > 0) {
848
+ success = false;
849
+ for (const ext of unexpectedExternals) {
850
+ logger2.error("Unexpected external import: {module}", {
851
+ module: ext
852
+ });
853
+ }
854
+ logger2.error(
855
+ "These modules are not bundled and won't be available at runtime."
856
+ );
857
+ }
858
+ }
859
+ const outputs = this.#extractOutputPaths(result.metafile);
860
+ if (success) {
861
+ logger2.debug("Build complete", { outputs });
862
+ } else {
863
+ logger2.error("Build errors: {errors}", { errors: result.errors });
864
+ }
865
+ this.#currentOutputs = outputs;
866
+ if (result.metafile) {
867
+ this.#updateSourceWatchers(result.metafile);
868
+ }
869
+ const buildResult = { success, outputs };
870
+ if (!this.#initialBuildComplete) {
871
+ this.#initialBuildComplete = true;
872
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
873
+ this.#initialBuildResolve?.(buildResult);
874
+ } else {
875
+ await this.#watchOptions?.onRebuild?.(buildResult);
876
+ }
877
+ });
878
+ }
879
+ };
880
+ }
881
+ /**
882
+ * Validate build result.
883
+ */
884
+ #validateBuildResult(result, allowedExternals) {
885
+ const dynamicImportWarnings = (result.warnings || []).filter(
886
+ (w) => (w.text.includes("cannot be bundled") || w.text.includes("import() call") || w.text.includes("dynamic import")) && !w.text.includes("./server.js")
887
+ );
888
+ if (dynamicImportWarnings.length > 0) {
889
+ const locations = dynamicImportWarnings.map((w) => {
890
+ const loc = w.location;
891
+ const file = loc?.file || "unknown";
892
+ const line = loc?.line || "?";
893
+ return ` ${file}:${line} - ${w.text}`;
894
+ }).join("\n");
895
+ throw new Error(
896
+ `Build failed: Non-analyzable dynamic imports found:
897
+ ${locations}
898
+
899
+ Dynamic imports must use literal strings, not variables.`
900
+ );
901
+ }
902
+ if (result.metafile) {
903
+ const allowedSet = new Set(allowedExternals);
904
+ const wildcardPrefixes = allowedExternals.filter((e) => e.endsWith(":*")).map((e) => e.slice(0, -1));
905
+ const unexpectedExternals = [];
906
+ for (const path of Object.keys(result.metafile.inputs)) {
907
+ if (!path.startsWith("<external>:"))
908
+ continue;
909
+ const moduleName = path.slice("<external>:".length);
910
+ const isAllowed = allowedSet.has(moduleName) || wildcardPrefixes.some((prefix) => moduleName.startsWith(prefix)) || builtinModules.includes(moduleName);
911
+ if (!isAllowed && !unexpectedExternals.includes(moduleName)) {
912
+ unexpectedExternals.push(moduleName);
913
+ }
914
+ }
915
+ if (unexpectedExternals.length > 0) {
916
+ const externals = unexpectedExternals.map((e) => ` - ${e}`).join("\n");
917
+ throw new Error(
918
+ `Build failed: Unexpected external imports found:
919
+ ${externals}
920
+
921
+ These modules are not bundled and won't be available at runtime.`
922
+ );
923
+ }
924
+ }
925
+ }
926
+ /**
927
+ * Watch config files for changes.
928
+ */
929
+ #watchConfigFiles() {
930
+ const configFiles = ["shovel.json", "package.json"];
931
+ for (const filename of configFiles) {
932
+ const filepath = join4(this.#projectRoot, filename);
933
+ if (!existsSync3(filepath))
934
+ continue;
935
+ try {
936
+ const watcher = watch(filepath, { persistent: false }, (event) => {
937
+ if (event === "change") {
938
+ logger2.info(`Config changed: ${filename}, rebuilding...`);
939
+ this.#ctx?.rebuild().catch((err) => {
940
+ logger2.error("Rebuild failed: {error}", { error: err });
941
+ });
942
+ }
943
+ });
944
+ this.#configWatchers.push(watcher);
945
+ } catch (err) {
946
+ logger2.warn("Failed to watch {file}: {error}", {
947
+ file: filename,
948
+ error: err
949
+ });
950
+ }
951
+ }
952
+ }
953
+ /**
954
+ * Update source file watchers from metafile.
955
+ */
956
+ #updateSourceWatchers(metafile) {
957
+ const newDirFiles = /* @__PURE__ */ new Map();
958
+ if (this.#userEntryPath) {
959
+ const entryDir = dirname4(this.#userEntryPath);
960
+ const entryFile = basename2(this.#userEntryPath);
961
+ if (!newDirFiles.has(entryDir)) {
962
+ newDirFiles.set(entryDir, /* @__PURE__ */ new Set());
963
+ }
964
+ newDirFiles.get(entryDir).add(entryFile);
965
+ logger2.debug("Explicitly watching user entry file: {path}", {
966
+ path: this.#userEntryPath
967
+ });
968
+ }
969
+ for (const inputPath of Object.keys(metafile.inputs)) {
970
+ if (inputPath.startsWith("<") || inputPath.startsWith("shovel")) {
971
+ continue;
972
+ }
973
+ const fullPath = resolve(this.#projectRoot, inputPath);
974
+ const dir = dirname4(fullPath);
975
+ const file = basename2(fullPath);
976
+ if (!newDirFiles.has(dir)) {
977
+ newDirFiles.set(dir, /* @__PURE__ */ new Set());
978
+ }
979
+ newDirFiles.get(dir).add(file);
980
+ }
981
+ for (const [dir, entry] of this.#dirWatchers) {
982
+ if (!newDirFiles.has(dir)) {
983
+ entry.watcher.close();
984
+ this.#dirWatchers.delete(dir);
985
+ }
986
+ }
987
+ for (const [dir, files] of newDirFiles) {
988
+ const existing = this.#dirWatchers.get(dir);
989
+ if (existing) {
990
+ existing.files = files;
991
+ } else {
992
+ if (!existsSync3(dir))
993
+ continue;
994
+ try {
995
+ const watcher = watch(
996
+ dir,
997
+ { persistent: false },
998
+ (_event, filename) => {
999
+ const entry = this.#dirWatchers.get(dir);
1000
+ if (!entry)
1001
+ return;
1002
+ const isTrackedFile = filename ? entry.files.has(filename) : true;
1003
+ if (isTrackedFile) {
1004
+ logger2.debug("Native watcher detected change: {file}", {
1005
+ file: filename ? join4(dir, filename) : dir
1006
+ });
1007
+ this.#ctx?.rebuild().catch((err) => {
1008
+ logger2.error("Rebuild failed: {error}", { error: err });
1009
+ });
1010
+ }
1011
+ }
1012
+ );
1013
+ this.#dirWatchers.set(dir, { watcher, files });
1014
+ } catch (err) {
1015
+ logger2.debug("Failed to watch directory {dir}: {error}", {
1016
+ dir,
1017
+ error: err
1018
+ });
1019
+ }
1020
+ }
1021
+ }
1022
+ const totalFiles = Array.from(this.#dirWatchers.values()).reduce(
1023
+ (sum, entry) => sum + entry.files.size,
1024
+ 0
1025
+ );
1026
+ const watchedDirs = Array.from(this.#dirWatchers.keys());
1027
+ logger2.debug(
1028
+ "Watching {fileCount} source files in {dirCount} directories with native fs.watch",
1029
+ { fileCount: totalFiles, dirCount: this.#dirWatchers.size }
1030
+ );
1031
+ logger2.debug("Watched directories: {dirs}", {
1032
+ dirs: watchedDirs.slice(0, 5).join(", ") + (watchedDirs.length > 5 ? "..." : "")
1033
+ });
1034
+ }
1035
+ };
1036
+
1037
+ // src/utils/platform.ts
1038
+ async function createPlatform(platformName, options = {}) {
1039
+ switch (platformName) {
1040
+ case "node": {
1041
+ const { default: NodePlatform } = await import("@b9g/platform-node");
1042
+ return new NodePlatform(options);
1043
+ }
1044
+ case "bun": {
1045
+ const { default: BunPlatform } = await import("@b9g/platform-bun");
1046
+ return new BunPlatform(options);
1047
+ }
1048
+ case "cloudflare": {
1049
+ const { default: CloudflarePlatform } = await import("@b9g/platform-cloudflare");
1050
+ return new CloudflarePlatform(options);
1051
+ }
1052
+ default:
1053
+ throw new Error(
1054
+ `Unknown platform: ${platformName}. Valid platforms: node, bun, cloudflare`
1055
+ );
1056
+ }
1057
+ }
1058
+
1059
+ export {
1060
+ ServerBundler,
1061
+ createPlatform
1062
+ };