@whatalo/cli-kit 1.0.2 → 1.1.1
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/bundle/index.cjs +438 -0
- package/dist/bundle/index.d.cts +159 -0
- package/dist/bundle/index.d.ts +159 -0
- package/dist/bundle/index.mjs +398 -0
- package/dist/config/index.cjs +3 -1
- package/dist/config/index.d.cts +3 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/config/index.mjs +3 -1
- package/dist/{env-file-KvUHlLtI.d.cts → env-file-Qh_6c5K2.d.cts} +4 -0
- package/dist/{env-file-KvUHlLtI.d.ts → env-file-Qh_6c5K2.d.ts} +4 -0
- package/dist/index.cjs +76 -14
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +76 -14
- package/dist/tunnel/index.cjs +79 -14
- package/dist/tunnel/index.d.cts +27 -6
- package/dist/tunnel/index.d.ts +27 -6
- package/dist/tunnel/index.mjs +79 -14
- package/package.json +6 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/bundle/index.ts
|
|
31
|
+
var bundle_exports = {};
|
|
32
|
+
__export(bundle_exports, {
|
|
33
|
+
BundleBuilder: () => BundleBuilder,
|
|
34
|
+
BundleUploader: () => BundleUploader,
|
|
35
|
+
BundleWatcher: () => BundleWatcher,
|
|
36
|
+
formatSize: () => formatSize
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(bundle_exports);
|
|
39
|
+
|
|
40
|
+
// src/bundle/builder.ts
|
|
41
|
+
var import_node_child_process = require("child_process");
|
|
42
|
+
var import_promises = require("fs/promises");
|
|
43
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
44
|
+
var MAX_BUNDLE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
45
|
+
async function collectFiles(dir, rootDir) {
|
|
46
|
+
const entries = await (0, import_promises.readdir)(dir, { withFileTypes: true });
|
|
47
|
+
const results = [];
|
|
48
|
+
const resolvedRoot = import_node_path.default.resolve(rootDir);
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const absolutePath = import_node_path.default.join(dir, entry.name);
|
|
51
|
+
const resolvedPath = import_node_path.default.resolve(absolutePath);
|
|
52
|
+
if (!resolvedPath.startsWith(resolvedRoot)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
const nested = await collectFiles(absolutePath, rootDir);
|
|
57
|
+
results.push(...nested);
|
|
58
|
+
} else if (entry.isFile()) {
|
|
59
|
+
const fileStat = await (0, import_promises.stat)(absolutePath);
|
|
60
|
+
const relativePath = import_node_path.default.relative(rootDir, absolutePath);
|
|
61
|
+
if (relativePath.startsWith("..")) continue;
|
|
62
|
+
results.push({
|
|
63
|
+
relativePath,
|
|
64
|
+
absolutePath,
|
|
65
|
+
size: fileStat.size
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
function formatSize(bytes) {
|
|
72
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
73
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
74
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
75
|
+
}
|
|
76
|
+
var BundleBuilder = class {
|
|
77
|
+
/**
|
|
78
|
+
* Runs the user-defined build command and collects the output files.
|
|
79
|
+
*
|
|
80
|
+
* @param buildCommand - Command string from config (e.g. "pnpm build")
|
|
81
|
+
* @param outputDir - Build output directory (relative or absolute)
|
|
82
|
+
* @param cwd - Working directory for the command (defaults to process.cwd())
|
|
83
|
+
* @throws If the build exits with a non-zero code or if total size exceeds 10 MB
|
|
84
|
+
*/
|
|
85
|
+
async build(buildCommand, outputDir, cwd = process.cwd()) {
|
|
86
|
+
const startTime = Date.now();
|
|
87
|
+
const parts = buildCommand.trim().split(/\s+/);
|
|
88
|
+
const [cmd, ...args] = parts;
|
|
89
|
+
if (!cmd) {
|
|
90
|
+
throw new Error("build_command is empty in whatalo.app.toml");
|
|
91
|
+
}
|
|
92
|
+
await this.runBuildProcess(cmd, args, cwd);
|
|
93
|
+
const absoluteOutputDir = import_node_path.default.isAbsolute(outputDir) ? outputDir : import_node_path.default.join(cwd, outputDir);
|
|
94
|
+
let files;
|
|
95
|
+
try {
|
|
96
|
+
files = await collectFiles(absoluteOutputDir, absoluteOutputDir);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Build output directory not found at "${absoluteOutputDir}": ${message}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (files.length === 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Build output directory "${absoluteOutputDir}" is empty after build.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
await this.validateIndexHtmlForCdn(files);
|
|
109
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
110
|
+
if (totalSize > MAX_BUNDLE_SIZE_BYTES) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Bundle size ${formatSize(totalSize)} exceeds the 10 MB limit. Reduce your build output or use --no-cdn to skip CDN upload.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const duration = Date.now() - startTime;
|
|
116
|
+
return { files, totalSize, duration };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Ensures index.html uses relative asset URLs so bundles work under
|
|
120
|
+
* session-scoped CDN paths like /dev-bundles/{sessionId}/.
|
|
121
|
+
*/
|
|
122
|
+
async validateIndexHtmlForCdn(files) {
|
|
123
|
+
const indexFile = files.find((file) => file.relativePath === "index.html");
|
|
124
|
+
if (!indexFile) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const html = await (0, import_promises.readFile)(indexFile.absolutePath, "utf-8");
|
|
128
|
+
const hasRootAbsoluteAssetRefs = /(?:src|href)=["']\/assets\//i.test(html) || /(?:src|href)=["']\/[a-z0-9._-]+\//i.test(html);
|
|
129
|
+
if (hasRootAbsoluteAssetRefs) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"Build output uses root-absolute asset paths in index.html. CDN dev bundles are served from a subpath, so assets must be relative. If you use Vite, set `base: './'` in vite.config.ts and rebuild."
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Spawns the build process and waits for it to finish.
|
|
137
|
+
* Captures stderr for error reporting on non-zero exit.
|
|
138
|
+
*/
|
|
139
|
+
runBuildProcess(cmd, args, cwd) {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const proc = (0, import_node_child_process.spawn)(cmd, args, {
|
|
142
|
+
cwd,
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
// Never use shell: true — avoids injection
|
|
145
|
+
shell: false
|
|
146
|
+
});
|
|
147
|
+
let stderr = "";
|
|
148
|
+
let stdout = "";
|
|
149
|
+
if (proc.stdout) {
|
|
150
|
+
proc.stdout.on("data", (chunk) => {
|
|
151
|
+
stdout += chunk.toString("utf-8");
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (proc.stderr) {
|
|
155
|
+
proc.stderr.on("data", (chunk) => {
|
|
156
|
+
stderr += chunk.toString("utf-8");
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
proc.on("error", (err) => {
|
|
160
|
+
reject(
|
|
161
|
+
new Error(
|
|
162
|
+
`Failed to start build command "${cmd}": ${err.message}. Is the command installed and in PATH?`
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
proc.on("close", (code) => {
|
|
167
|
+
if (code === 0) {
|
|
168
|
+
resolve();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const output = (stderr || stdout).trim();
|
|
172
|
+
reject(
|
|
173
|
+
new Error(
|
|
174
|
+
`Build failed with exit code ${code ?? "unknown"}.` + (output ? `
|
|
175
|
+
|
|
176
|
+
${output}` : "")
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// src/bundle/uploader.ts
|
|
185
|
+
var import_promises2 = require("fs/promises");
|
|
186
|
+
var UPLOAD_TIMEOUT_MS = 6e4;
|
|
187
|
+
var CONTENT_TYPE_MAP = {
|
|
188
|
+
".html": "text/html",
|
|
189
|
+
".htm": "text/html",
|
|
190
|
+
".css": "text/css",
|
|
191
|
+
".js": "application/javascript",
|
|
192
|
+
".mjs": "application/javascript",
|
|
193
|
+
".cjs": "application/javascript",
|
|
194
|
+
".ts": "application/javascript",
|
|
195
|
+
".json": "application/json",
|
|
196
|
+
".svg": "image/svg+xml",
|
|
197
|
+
".png": "image/png",
|
|
198
|
+
".jpg": "image/jpeg",
|
|
199
|
+
".jpeg": "image/jpeg",
|
|
200
|
+
".gif": "image/gif",
|
|
201
|
+
".webp": "image/webp",
|
|
202
|
+
".ico": "image/x-icon",
|
|
203
|
+
".woff": "font/woff",
|
|
204
|
+
".woff2": "font/woff2",
|
|
205
|
+
".ttf": "font/ttf",
|
|
206
|
+
".eot": "application/vnd.ms-fontobject",
|
|
207
|
+
".txt": "text/plain",
|
|
208
|
+
".xml": "application/xml",
|
|
209
|
+
".map": "application/json",
|
|
210
|
+
".webmanifest": "application/manifest+json"
|
|
211
|
+
};
|
|
212
|
+
function getContentType(filePath) {
|
|
213
|
+
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
214
|
+
return CONTENT_TYPE_MAP[ext] ?? "application/octet-stream";
|
|
215
|
+
}
|
|
216
|
+
var BundleUploader = class {
|
|
217
|
+
/**
|
|
218
|
+
* Requests presigned URLs and uploads all files to the CDN in parallel.
|
|
219
|
+
*
|
|
220
|
+
* @param apiClient - Authenticated API client instance
|
|
221
|
+
* @param sessionId - Development session ID (used in the API path)
|
|
222
|
+
* @param files - List of files from BundleBuilder.build()
|
|
223
|
+
* @returns The public CDN base URL for the uploaded bundle
|
|
224
|
+
* @throws If the API request fails or if index.html upload fails
|
|
225
|
+
*/
|
|
226
|
+
async upload(apiClient, sessionId, files) {
|
|
227
|
+
for (const f of files) {
|
|
228
|
+
if (f.relativePath.includes("..") || f.relativePath.startsWith("/")) {
|
|
229
|
+
throw new Error(`Invalid file path: ${f.relativePath}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const payload = {
|
|
233
|
+
files: files.map((f) => ({ path: f.relativePath, size: f.size }))
|
|
234
|
+
};
|
|
235
|
+
const { urls, publicBaseUrl } = await apiClient.post(
|
|
236
|
+
`/api/dev-sessions/${sessionId}/upload-urls`,
|
|
237
|
+
payload
|
|
238
|
+
);
|
|
239
|
+
const uploadPromises = files.map(
|
|
240
|
+
(file) => this.uploadSingleFile(file, urls[file.relativePath])
|
|
241
|
+
);
|
|
242
|
+
const results = await Promise.allSettled(uploadPromises);
|
|
243
|
+
const fileResults = results.map((result, index) => {
|
|
244
|
+
if (result.status === "fulfilled") {
|
|
245
|
+
return result.value;
|
|
246
|
+
}
|
|
247
|
+
const filePath = files[index]?.relativePath ?? `file[${index}]`;
|
|
248
|
+
return {
|
|
249
|
+
path: filePath,
|
|
250
|
+
success: false,
|
|
251
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
const failed = fileResults.filter((r) => !r.success);
|
|
255
|
+
const indexFailed = failed.some(
|
|
256
|
+
(r) => r.path === "index.html" || r.path.endsWith("/index.html")
|
|
257
|
+
);
|
|
258
|
+
if (indexFailed) {
|
|
259
|
+
const indexError = failed.find(
|
|
260
|
+
(r) => r.path === "index.html" || r.path.endsWith("/index.html")
|
|
261
|
+
);
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Critical upload failure: index.html could not be uploaded. ${indexError?.error ?? ""}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
for (const failure of failed) {
|
|
267
|
+
void failure;
|
|
268
|
+
}
|
|
269
|
+
return publicBaseUrl;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Uploads a single file to its presigned URL via HTTP PUT.
|
|
273
|
+
* Sets Content-Type and Content-Length headers as required by S3/R2.
|
|
274
|
+
*/
|
|
275
|
+
async uploadSingleFile(file, presignedUrl) {
|
|
276
|
+
if (!presignedUrl) {
|
|
277
|
+
return {
|
|
278
|
+
path: file.relativePath,
|
|
279
|
+
success: false,
|
|
280
|
+
error: "No presigned URL returned from API"
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const body = await (0, import_promises2.readFile)(file.absolutePath);
|
|
285
|
+
const contentType = getContentType(file.relativePath);
|
|
286
|
+
const response = await fetch(presignedUrl, {
|
|
287
|
+
method: "PUT",
|
|
288
|
+
headers: {
|
|
289
|
+
"Content-Type": contentType,
|
|
290
|
+
"Content-Length": String(file.size)
|
|
291
|
+
},
|
|
292
|
+
body,
|
|
293
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
|
|
294
|
+
});
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
const text = await response.text().catch(() => "");
|
|
297
|
+
return {
|
|
298
|
+
path: file.relativePath,
|
|
299
|
+
success: false,
|
|
300
|
+
error: `HTTP ${response.status}: ${text.slice(0, 200)}`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return { path: file.relativePath, success: true };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
return {
|
|
306
|
+
path: file.relativePath,
|
|
307
|
+
success: false,
|
|
308
|
+
error: err instanceof Error ? err.message : String(err)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Checks upload results and returns paths of any files that failed.
|
|
314
|
+
* Used by the caller for logging warnings.
|
|
315
|
+
*/
|
|
316
|
+
static getFailedPaths(files, results) {
|
|
317
|
+
return results.map((r, i) => ({
|
|
318
|
+
path: files[i]?.relativePath ?? `file[${i}]`,
|
|
319
|
+
ok: r.status === "fulfilled" && r.value.success
|
|
320
|
+
})).filter((r) => !r.ok).map((r) => r.path);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// src/bundle/watcher.ts
|
|
325
|
+
var import_node_fs = require("fs");
|
|
326
|
+
var import_node_events = require("events");
|
|
327
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
328
|
+
var DEBOUNCE_MS = 300;
|
|
329
|
+
var BundleWatcher = class extends import_node_events.EventEmitter {
|
|
330
|
+
watcher = null;
|
|
331
|
+
debounceTimer = null;
|
|
332
|
+
isRebuilding = false;
|
|
333
|
+
pendingRebuild = false;
|
|
334
|
+
pendingChangedFile = "";
|
|
335
|
+
lastChangedFile = "";
|
|
336
|
+
/** Typed override for EventEmitter.on */
|
|
337
|
+
on(event, listener) {
|
|
338
|
+
return super.on(event, listener);
|
|
339
|
+
}
|
|
340
|
+
/** Typed override for EventEmitter.emit */
|
|
341
|
+
emit(event, ...args) {
|
|
342
|
+
return super.emit(event, ...args);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Starts watching `srcDir` for changes.
|
|
346
|
+
* Calls `onRebuild` after each debounced change, serially.
|
|
347
|
+
*
|
|
348
|
+
* @param srcDir - Directory to watch recursively
|
|
349
|
+
* @param onRebuild - Async callback invoked on each change batch
|
|
350
|
+
*/
|
|
351
|
+
start(srcDir, onRebuild) {
|
|
352
|
+
if (this.watcher) {
|
|
353
|
+
throw new Error("BundleWatcher is already running. Call stop() first.");
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
this.watcher = (0, import_node_fs.watch)(srcDir, { recursive: true }, (_, filename) => {
|
|
357
|
+
const changedFile = filename ? import_node_path2.default.normalize(filename) : "(unknown file)";
|
|
358
|
+
this.scheduleRebuild(changedFile, onRebuild);
|
|
359
|
+
});
|
|
360
|
+
this.watcher.on("error", (err) => {
|
|
361
|
+
this.emit("rebuild:error", { error: err });
|
|
362
|
+
});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Cannot watch directory "${srcDir}": ${err instanceof Error ? err.message : String(err)}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Stops watching and clears all pending timers */
|
|
370
|
+
stop() {
|
|
371
|
+
if (this.debounceTimer) {
|
|
372
|
+
clearTimeout(this.debounceTimer);
|
|
373
|
+
this.debounceTimer = null;
|
|
374
|
+
}
|
|
375
|
+
if (this.watcher) {
|
|
376
|
+
this.watcher.close();
|
|
377
|
+
this.watcher = null;
|
|
378
|
+
}
|
|
379
|
+
this.isRebuilding = false;
|
|
380
|
+
this.pendingRebuild = false;
|
|
381
|
+
this.pendingChangedFile = "";
|
|
382
|
+
this.lastChangedFile = "";
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Schedules a rebuild after the debounce window.
|
|
386
|
+
* If a rebuild is already in progress, marks a pending rebuild instead.
|
|
387
|
+
*/
|
|
388
|
+
scheduleRebuild(changedFile, onRebuild) {
|
|
389
|
+
this.lastChangedFile = changedFile;
|
|
390
|
+
if (this.debounceTimer) {
|
|
391
|
+
clearTimeout(this.debounceTimer);
|
|
392
|
+
}
|
|
393
|
+
if (this.isRebuilding) {
|
|
394
|
+
this.pendingRebuild = true;
|
|
395
|
+
this.pendingChangedFile = changedFile;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
this.debounceTimer = setTimeout(() => {
|
|
399
|
+
this.debounceTimer = null;
|
|
400
|
+
void this.executeRebuild(this.lastChangedFile, onRebuild);
|
|
401
|
+
}, DEBOUNCE_MS);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Executes the rebuild callback, then runs any pending rebuild that
|
|
405
|
+
* accumulated while this one was in progress.
|
|
406
|
+
*/
|
|
407
|
+
async executeRebuild(changedFile, onRebuild) {
|
|
408
|
+
this.isRebuilding = true;
|
|
409
|
+
const startTime = Date.now();
|
|
410
|
+
this.emit("rebuild:start", { changedFile });
|
|
411
|
+
try {
|
|
412
|
+
await onRebuild();
|
|
413
|
+
const duration = Date.now() - startTime;
|
|
414
|
+
this.emit("rebuild:complete", { duration });
|
|
415
|
+
} catch (err) {
|
|
416
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
417
|
+
this.emit("rebuild:error", { error });
|
|
418
|
+
} finally {
|
|
419
|
+
this.isRebuilding = false;
|
|
420
|
+
if (this.pendingRebuild) {
|
|
421
|
+
this.pendingRebuild = false;
|
|
422
|
+
const nextFile = this.pendingChangedFile;
|
|
423
|
+
this.pendingChangedFile = "";
|
|
424
|
+
this.debounceTimer = setTimeout(() => {
|
|
425
|
+
this.debounceTimer = null;
|
|
426
|
+
void this.executeRebuild(nextFile, onRebuild);
|
|
427
|
+
}, DEBOUNCE_MS);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
433
|
+
0 && (module.exports = {
|
|
434
|
+
BundleBuilder,
|
|
435
|
+
BundleUploader,
|
|
436
|
+
BundleWatcher,
|
|
437
|
+
formatSize
|
|
438
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { WhataloApiClient } from '../http/index.cjs';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import '../types-DunvRQ0f.cjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BundleBuilder — runs the user-defined build command and collects output files.
|
|
7
|
+
*
|
|
8
|
+
* Uses `spawn` (not `exec`) to avoid shell injection. The build command string
|
|
9
|
+
* is split on whitespace to produce [cmd, ...args].
|
|
10
|
+
*
|
|
11
|
+
* Size limit: 10 MB total across all output files.
|
|
12
|
+
*/
|
|
13
|
+
/** Represents a single file collected from the build output directory */
|
|
14
|
+
interface BuildFile {
|
|
15
|
+
/** Path relative to the output directory root (e.g. "assets/main.js") */
|
|
16
|
+
relativePath: string;
|
|
17
|
+
/** Absolute path on disk */
|
|
18
|
+
absolutePath: string;
|
|
19
|
+
/** File size in bytes */
|
|
20
|
+
size: number;
|
|
21
|
+
}
|
|
22
|
+
/** Result returned by BundleBuilder.build() */
|
|
23
|
+
interface BuildResult {
|
|
24
|
+
/** All files found in the output directory */
|
|
25
|
+
files: BuildFile[];
|
|
26
|
+
/** Sum of all file sizes in bytes */
|
|
27
|
+
totalSize: number;
|
|
28
|
+
/** Wall-clock time the build took in milliseconds */
|
|
29
|
+
duration: number;
|
|
30
|
+
}
|
|
31
|
+
/** Formats bytes as a human-readable string (e.g. "1.4 MB") */
|
|
32
|
+
declare function formatSize(bytes: number): string;
|
|
33
|
+
declare class BundleBuilder {
|
|
34
|
+
/**
|
|
35
|
+
* Runs the user-defined build command and collects the output files.
|
|
36
|
+
*
|
|
37
|
+
* @param buildCommand - Command string from config (e.g. "pnpm build")
|
|
38
|
+
* @param outputDir - Build output directory (relative or absolute)
|
|
39
|
+
* @param cwd - Working directory for the command (defaults to process.cwd())
|
|
40
|
+
* @throws If the build exits with a non-zero code or if total size exceeds 10 MB
|
|
41
|
+
*/
|
|
42
|
+
build(buildCommand: string, outputDir: string, cwd?: string): Promise<BuildResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Ensures index.html uses relative asset URLs so bundles work under
|
|
45
|
+
* session-scoped CDN paths like /dev-bundles/{sessionId}/.
|
|
46
|
+
*/
|
|
47
|
+
private validateIndexHtmlForCdn;
|
|
48
|
+
/**
|
|
49
|
+
* Spawns the build process and waits for it to finish.
|
|
50
|
+
* Captures stderr for error reporting on non-zero exit.
|
|
51
|
+
*/
|
|
52
|
+
private runBuildProcess;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* BundleUploader — requests presigned PUT URLs from the API and uploads
|
|
57
|
+
* all build output files to R2/S3 in parallel.
|
|
58
|
+
*
|
|
59
|
+
* Strategy:
|
|
60
|
+
* - Uses Promise.allSettled so partial failures don't abort the whole upload.
|
|
61
|
+
* - index.html is considered the critical entry point; if it fails, the
|
|
62
|
+
* entire upload is treated as failed (the CDN bundle would be unusable).
|
|
63
|
+
* - Content-Type is derived from the file extension.
|
|
64
|
+
* - Native fetch is used for PUT uploads — no extra dependencies.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/** Result of a single file upload attempt */
|
|
68
|
+
interface FileUploadResult {
|
|
69
|
+
path: string;
|
|
70
|
+
success: boolean;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
declare class BundleUploader {
|
|
74
|
+
/**
|
|
75
|
+
* Requests presigned URLs and uploads all files to the CDN in parallel.
|
|
76
|
+
*
|
|
77
|
+
* @param apiClient - Authenticated API client instance
|
|
78
|
+
* @param sessionId - Development session ID (used in the API path)
|
|
79
|
+
* @param files - List of files from BundleBuilder.build()
|
|
80
|
+
* @returns The public CDN base URL for the uploaded bundle
|
|
81
|
+
* @throws If the API request fails or if index.html upload fails
|
|
82
|
+
*/
|
|
83
|
+
upload(apiClient: WhataloApiClient, sessionId: string, files: BuildFile[]): Promise<string>;
|
|
84
|
+
/**
|
|
85
|
+
* Uploads a single file to its presigned URL via HTTP PUT.
|
|
86
|
+
* Sets Content-Type and Content-Length headers as required by S3/R2.
|
|
87
|
+
*/
|
|
88
|
+
private uploadSingleFile;
|
|
89
|
+
/**
|
|
90
|
+
* Checks upload results and returns paths of any files that failed.
|
|
91
|
+
* Used by the caller for logging warnings.
|
|
92
|
+
*/
|
|
93
|
+
static getFailedPaths(files: BuildFile[], results: PromiseSettledResult<FileUploadResult>[]): string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* BundleWatcher — watches a source directory for file changes and triggers
|
|
98
|
+
* a rebuild callback using the SerialBatchProcessor pattern.
|
|
99
|
+
*
|
|
100
|
+
* Key guarantees:
|
|
101
|
+
* - Debounce: 300ms after the last detected change before triggering rebuild.
|
|
102
|
+
* - Serial execution: if a rebuild is already in progress, the next one is
|
|
103
|
+
* queued (not dropped) and runs immediately after the current one finishes.
|
|
104
|
+
* - At most ONE pending rebuild is queued at a time (further changes during
|
|
105
|
+
* an in-progress rebuild extend the debounce, not stack more rebuilds).
|
|
106
|
+
* - Emits typed events for integration with the dev console output.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
interface RebuildStartEvent {
|
|
110
|
+
/** Path of the file that triggered the rebuild (relative to watched dir) */
|
|
111
|
+
changedFile: string;
|
|
112
|
+
}
|
|
113
|
+
interface RebuildCompleteEvent {
|
|
114
|
+
/** Wall-clock duration of the rebuild in milliseconds */
|
|
115
|
+
duration: number;
|
|
116
|
+
}
|
|
117
|
+
interface RebuildErrorEvent {
|
|
118
|
+
error: Error;
|
|
119
|
+
}
|
|
120
|
+
/** Typed events emitted by BundleWatcher */
|
|
121
|
+
interface BundleWatcherEvents {
|
|
122
|
+
"rebuild:start": (event: RebuildStartEvent) => void;
|
|
123
|
+
"rebuild:complete": (event: RebuildCompleteEvent) => void;
|
|
124
|
+
"rebuild:error": (event: RebuildErrorEvent) => void;
|
|
125
|
+
}
|
|
126
|
+
declare class BundleWatcher extends EventEmitter {
|
|
127
|
+
private watcher;
|
|
128
|
+
private debounceTimer;
|
|
129
|
+
private isRebuilding;
|
|
130
|
+
private pendingRebuild;
|
|
131
|
+
private pendingChangedFile;
|
|
132
|
+
private lastChangedFile;
|
|
133
|
+
/** Typed override for EventEmitter.on */
|
|
134
|
+
on<K extends keyof BundleWatcherEvents>(event: K, listener: BundleWatcherEvents[K]): this;
|
|
135
|
+
/** Typed override for EventEmitter.emit */
|
|
136
|
+
emit<K extends keyof BundleWatcherEvents>(event: K, ...args: Parameters<BundleWatcherEvents[K]>): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Starts watching `srcDir` for changes.
|
|
139
|
+
* Calls `onRebuild` after each debounced change, serially.
|
|
140
|
+
*
|
|
141
|
+
* @param srcDir - Directory to watch recursively
|
|
142
|
+
* @param onRebuild - Async callback invoked on each change batch
|
|
143
|
+
*/
|
|
144
|
+
start(srcDir: string, onRebuild: () => Promise<void>): void;
|
|
145
|
+
/** Stops watching and clears all pending timers */
|
|
146
|
+
stop(): void;
|
|
147
|
+
/**
|
|
148
|
+
* Schedules a rebuild after the debounce window.
|
|
149
|
+
* If a rebuild is already in progress, marks a pending rebuild instead.
|
|
150
|
+
*/
|
|
151
|
+
private scheduleRebuild;
|
|
152
|
+
/**
|
|
153
|
+
* Executes the rebuild callback, then runs any pending rebuild that
|
|
154
|
+
* accumulated while this one was in progress.
|
|
155
|
+
*/
|
|
156
|
+
private executeRebuild;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { type BuildFile, type BuildResult, BundleBuilder, BundleUploader, BundleWatcher, type BundleWatcherEvents, type RebuildCompleteEvent, type RebuildErrorEvent, type RebuildStartEvent, formatSize };
|