@kohi9noor/hotloop 1.3.0
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/LICENSE +21 -0
- package/dist/cli.js +556 -0
- package/dist/index.js +357 -0
- package/package.json +40 -0
- package/readme.md +187 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kohinoor nimes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/logger.ts
|
|
13
|
+
var timestamp, logger;
|
|
14
|
+
var init_logger = __esm({
|
|
15
|
+
"src/logger.ts"() {
|
|
16
|
+
timestamp = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
|
|
17
|
+
logger = {
|
|
18
|
+
info: (msg) => {
|
|
19
|
+
console.log(
|
|
20
|
+
`\x1B[36m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
success: (msg) => {
|
|
24
|
+
console.log(
|
|
25
|
+
`\x1B[32m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
warn: (msg) => {
|
|
29
|
+
console.warn(
|
|
30
|
+
`\x1B[33m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
error: (msg) => {
|
|
34
|
+
console.error(
|
|
35
|
+
`\x1B[31m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// src/server.ts
|
|
43
|
+
var server_exports = {};
|
|
44
|
+
__export(server_exports, {
|
|
45
|
+
HmrServer: () => HmrServer
|
|
46
|
+
});
|
|
47
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
48
|
+
var HmrServer;
|
|
49
|
+
var init_server = __esm({
|
|
50
|
+
"src/server.ts"() {
|
|
51
|
+
init_logger();
|
|
52
|
+
HmrServer = class {
|
|
53
|
+
wss;
|
|
54
|
+
clients = /* @__PURE__ */ new Set();
|
|
55
|
+
constructor(port) {
|
|
56
|
+
this.wss = new WebSocketServer({ port }, () => {
|
|
57
|
+
logger.info(`HMR Server running on ws://localhost:${port}`);
|
|
58
|
+
});
|
|
59
|
+
this.wss.on("connection", (ws) => {
|
|
60
|
+
this.clients.add(ws);
|
|
61
|
+
ws.on("close", () => {
|
|
62
|
+
this.clients.delete(ws);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
broadcast(message) {
|
|
67
|
+
const data = JSON.stringify(message);
|
|
68
|
+
this.clients.forEach((client) => {
|
|
69
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
70
|
+
client.send(data);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
reload() {
|
|
75
|
+
this.broadcast({ type: "reload" });
|
|
76
|
+
}
|
|
77
|
+
close() {
|
|
78
|
+
this.wss.close();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// src/builder.ts
|
|
85
|
+
var builder_exports = {};
|
|
86
|
+
__export(builder_exports, {
|
|
87
|
+
ExtensionBuilder: () => ExtensionBuilder
|
|
88
|
+
});
|
|
89
|
+
import * as esbuild from "esbuild";
|
|
90
|
+
import * as fs from "fs/promises";
|
|
91
|
+
import * as path from "path";
|
|
92
|
+
var ExtensionBuilder;
|
|
93
|
+
var init_builder = __esm({
|
|
94
|
+
"src/builder.ts"() {
|
|
95
|
+
init_logger();
|
|
96
|
+
ExtensionBuilder = class {
|
|
97
|
+
constructor(cwd = process.cwd()) {
|
|
98
|
+
this.cwd = cwd;
|
|
99
|
+
}
|
|
100
|
+
async build(entries) {
|
|
101
|
+
const start = Date.now();
|
|
102
|
+
try {
|
|
103
|
+
logger.info(`Building ${entries.length} entry(ies)...`);
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const inputPath = path.join(this.cwd, entry.input);
|
|
106
|
+
const outputPath = path.join(this.cwd, entry.output);
|
|
107
|
+
const outputDir = path.dirname(outputPath);
|
|
108
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
109
|
+
await esbuild.build({
|
|
110
|
+
entryPoints: [inputPath],
|
|
111
|
+
bundle: true,
|
|
112
|
+
format: "iife",
|
|
113
|
+
target: "es2020",
|
|
114
|
+
outfile: outputPath,
|
|
115
|
+
sourcemap: false,
|
|
116
|
+
logLevel: "silent"
|
|
117
|
+
});
|
|
118
|
+
logger.success(`${entry.input} \u2192 ${entry.output}`);
|
|
119
|
+
}
|
|
120
|
+
const elapsed = Date.now() - start;
|
|
121
|
+
logger.success(
|
|
122
|
+
`Compilation complete (${entries.length} files) in ${elapsed}ms`
|
|
123
|
+
);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error(`Build failed: ${error}`);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async copy(files) {
|
|
130
|
+
for (const { src, dest } of files) {
|
|
131
|
+
const srcPath = path.join(this.cwd, src);
|
|
132
|
+
const destPath = path.join(this.cwd, dest);
|
|
133
|
+
const destDir = path.dirname(destPath);
|
|
134
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
135
|
+
await fs.copyFile(srcPath, destPath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async writeManifest(options) {
|
|
139
|
+
const srcPath = path.join(this.cwd, options.src);
|
|
140
|
+
const destPath = path.join(this.cwd, options.dest);
|
|
141
|
+
const destDir = path.dirname(destPath);
|
|
142
|
+
const raw = await fs.readFile(srcPath, "utf-8");
|
|
143
|
+
const manifest = JSON.parse(raw);
|
|
144
|
+
if (options.injectHmr) {
|
|
145
|
+
const hmrScript = options.hmrScript || "content/hmr.js";
|
|
146
|
+
const hmrMatches = options.hmrMatches || ["<all_urls>"];
|
|
147
|
+
const hmrRunAt = options.hmrRunAt || "document_start";
|
|
148
|
+
const existing = Array.isArray(manifest.content_scripts) ? manifest.content_scripts : [];
|
|
149
|
+
const alreadyInjected = existing.some((entry) => {
|
|
150
|
+
const scripts = entry.js || [];
|
|
151
|
+
return Array.isArray(scripts) && scripts.includes(hmrScript);
|
|
152
|
+
});
|
|
153
|
+
if (!alreadyInjected) {
|
|
154
|
+
existing.push({
|
|
155
|
+
matches: hmrMatches,
|
|
156
|
+
js: [hmrScript],
|
|
157
|
+
run_at: hmrRunAt
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
manifest.content_scripts = existing;
|
|
161
|
+
}
|
|
162
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
163
|
+
await fs.writeFile(destPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// src/watcher.ts
|
|
170
|
+
var watcher_exports = {};
|
|
171
|
+
__export(watcher_exports, {
|
|
172
|
+
FileWatcher: () => FileWatcher
|
|
173
|
+
});
|
|
174
|
+
import * as fs2 from "fs";
|
|
175
|
+
var FileWatcher;
|
|
176
|
+
var init_watcher = __esm({
|
|
177
|
+
"src/watcher.ts"() {
|
|
178
|
+
init_logger();
|
|
179
|
+
FileWatcher = class {
|
|
180
|
+
constructor(watchDir, onChange) {
|
|
181
|
+
this.watchDir = watchDir;
|
|
182
|
+
this.onChange = onChange;
|
|
183
|
+
}
|
|
184
|
+
watchers = [];
|
|
185
|
+
debounceTimer = null;
|
|
186
|
+
debounceDelay = 300;
|
|
187
|
+
start() {
|
|
188
|
+
const watch2 = (dir) => {
|
|
189
|
+
const watcher = fs2.watch(dir, { recursive: true }, async (_, file) => {
|
|
190
|
+
if (!file) return;
|
|
191
|
+
if (String(file).includes("node_modules")) return;
|
|
192
|
+
if (String(file).includes("dist")) return;
|
|
193
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
194
|
+
this.debounceTimer = setTimeout(async () => {
|
|
195
|
+
try {
|
|
196
|
+
await this.onChange();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logger.error(`Build error: ${error}`);
|
|
199
|
+
}
|
|
200
|
+
}, this.debounceDelay);
|
|
201
|
+
});
|
|
202
|
+
this.watchers.push(watcher);
|
|
203
|
+
};
|
|
204
|
+
watch2(this.watchDir);
|
|
205
|
+
logger.info(`Watching ${this.watchDir} for changes`);
|
|
206
|
+
}
|
|
207
|
+
stop() {
|
|
208
|
+
this.watchers.forEach((w) => w.close());
|
|
209
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// src/config.ts
|
|
216
|
+
var config_exports = {};
|
|
217
|
+
__export(config_exports, {
|
|
218
|
+
loadConfig: () => loadConfig,
|
|
219
|
+
validateConfig: () => validateConfig
|
|
220
|
+
});
|
|
221
|
+
import * as path2 from "path";
|
|
222
|
+
import { pathToFileURL } from "url";
|
|
223
|
+
async function loadConfig(cwd) {
|
|
224
|
+
const configPath = path2.join(cwd, "hotloop.config.js");
|
|
225
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
226
|
+
try {
|
|
227
|
+
const module = await import(configUrl);
|
|
228
|
+
return module.default;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Failed to load hotloop.config.js: ${error instanceof Error ? error.message : String(error)}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function validateConfig(config) {
|
|
236
|
+
if (!config.entries || config.entries.length === 0) {
|
|
237
|
+
throw new Error("No entries configured. Provide at least one entry point.");
|
|
238
|
+
}
|
|
239
|
+
config.entries.forEach((entry) => {
|
|
240
|
+
if (!entry.input || !entry.output) {
|
|
241
|
+
throw new Error("Each entry must have input and output properties.");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
var init_config = __esm({
|
|
246
|
+
"src/config.ts"() {
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// src/index.ts
|
|
251
|
+
init_logger();
|
|
252
|
+
init_server();
|
|
253
|
+
init_builder();
|
|
254
|
+
init_watcher();
|
|
255
|
+
init_config();
|
|
256
|
+
async function runBuild(cwd, options) {
|
|
257
|
+
const { loadConfig: loadConfig2, validateConfig: validateConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
258
|
+
const { HmrServer: HmrServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|
|
259
|
+
const { ExtensionBuilder: ExtensionBuilder2 } = await Promise.resolve().then(() => (init_builder(), builder_exports));
|
|
260
|
+
const { FileWatcher: FileWatcher2 } = await Promise.resolve().then(() => (init_watcher(), watcher_exports));
|
|
261
|
+
const config = await loadConfig2(cwd);
|
|
262
|
+
validateConfig2(config);
|
|
263
|
+
if (config.manifest && options?.injectHmr === false) {
|
|
264
|
+
config.manifest = { ...config.manifest, injectHmr: false };
|
|
265
|
+
}
|
|
266
|
+
const outDir = config.outDir || "dist";
|
|
267
|
+
const buildOutDir = config.buildOutDir || outDir;
|
|
268
|
+
const replaceOutDir = (target) => {
|
|
269
|
+
const normalized = target.replace(/\\/g, "/");
|
|
270
|
+
const prefix = `${outDir.replace(/\\/g, "/")}/`;
|
|
271
|
+
if (normalized.startsWith(prefix)) {
|
|
272
|
+
return normalized.replace(prefix, `${buildOutDir}/`);
|
|
273
|
+
}
|
|
274
|
+
return target;
|
|
275
|
+
};
|
|
276
|
+
if (options?.watch === false && buildOutDir !== outDir) {
|
|
277
|
+
config.entries = config.entries.map((entry) => ({
|
|
278
|
+
...entry,
|
|
279
|
+
output: replaceOutDir(entry.output)
|
|
280
|
+
}));
|
|
281
|
+
if (config.manifest) {
|
|
282
|
+
config.manifest = {
|
|
283
|
+
...config.manifest,
|
|
284
|
+
dest: replaceOutDir(config.manifest.dest)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (config.assets) {
|
|
288
|
+
config.assets = config.assets.map((asset) => ({
|
|
289
|
+
...asset,
|
|
290
|
+
dest: replaceOutDir(asset.dest)
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
if (config.copy) {
|
|
294
|
+
config.copy = config.copy.map((asset) => ({
|
|
295
|
+
...asset,
|
|
296
|
+
dest: replaceOutDir(asset.dest)
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const port = config.port || 3e3;
|
|
301
|
+
const watchDir = config.watchDir || "src";
|
|
302
|
+
const server = options?.watch === false ? null : new HmrServer2(port);
|
|
303
|
+
const builder = new ExtensionBuilder2(cwd);
|
|
304
|
+
await builder.build(config.entries);
|
|
305
|
+
if (config.manifest) {
|
|
306
|
+
await builder.writeManifest(config.manifest);
|
|
307
|
+
}
|
|
308
|
+
if (config.assets) {
|
|
309
|
+
await builder.copy(config.assets);
|
|
310
|
+
}
|
|
311
|
+
if (config.copy) {
|
|
312
|
+
await builder.copy(config.copy);
|
|
313
|
+
}
|
|
314
|
+
if (options?.watch === false) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const watcher = new FileWatcher2(watchDir, async () => {
|
|
318
|
+
config.onBuildStart?.();
|
|
319
|
+
try {
|
|
320
|
+
await builder.build(config.entries);
|
|
321
|
+
if (config.manifest) {
|
|
322
|
+
await builder.writeManifest(config.manifest);
|
|
323
|
+
}
|
|
324
|
+
if (config.assets) {
|
|
325
|
+
await builder.copy(config.assets);
|
|
326
|
+
}
|
|
327
|
+
if (config.copy) {
|
|
328
|
+
await builder.copy(config.copy);
|
|
329
|
+
}
|
|
330
|
+
server?.reload();
|
|
331
|
+
} catch (error) {
|
|
332
|
+
logger.error(`Build failed: ${error}`);
|
|
333
|
+
}
|
|
334
|
+
config.onBuildEnd?.();
|
|
335
|
+
});
|
|
336
|
+
watcher.start();
|
|
337
|
+
process.on("SIGINT", () => {
|
|
338
|
+
logger.info("Shutting down");
|
|
339
|
+
watcher.stop();
|
|
340
|
+
server?.close();
|
|
341
|
+
process.exit(0);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
async function startDevServer(cwd = process.cwd()) {
|
|
345
|
+
await runBuild(cwd, { watch: true, injectHmr: true });
|
|
346
|
+
}
|
|
347
|
+
async function buildOnce(cwd = process.cwd()) {
|
|
348
|
+
await runBuild(cwd, { watch: false, injectHmr: false });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/scaffold.ts
|
|
352
|
+
init_logger();
|
|
353
|
+
import * as fs3 from "fs/promises";
|
|
354
|
+
import * as path3 from "path";
|
|
355
|
+
async function scaffold(options) {
|
|
356
|
+
const { projectName: projectName2, targetDir } = options;
|
|
357
|
+
try {
|
|
358
|
+
logger.info(`Creating project: ${projectName2}`);
|
|
359
|
+
await fs3.mkdir(path3.join(targetDir, "src", "background"), {
|
|
360
|
+
recursive: true
|
|
361
|
+
});
|
|
362
|
+
await fs3.mkdir(path3.join(targetDir, "src", "content"), {
|
|
363
|
+
recursive: true
|
|
364
|
+
});
|
|
365
|
+
await fs3.mkdir(path3.join(targetDir, "src", "ui"), { recursive: true });
|
|
366
|
+
const manifest = {
|
|
367
|
+
manifest_version: 3,
|
|
368
|
+
name: projectName2,
|
|
369
|
+
version: "1.0.0",
|
|
370
|
+
description: "A Chrome extension built with Hotwire",
|
|
371
|
+
permissions: ["tabs"],
|
|
372
|
+
background: {
|
|
373
|
+
service_worker: "background/index.js"
|
|
374
|
+
},
|
|
375
|
+
action: {
|
|
376
|
+
default_popup: "ui/popup.html",
|
|
377
|
+
default_title: projectName2
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
await fs3.writeFile(
|
|
381
|
+
path3.join(targetDir, "src", "manifest.json"),
|
|
382
|
+
JSON.stringify(manifest, null, 2)
|
|
383
|
+
);
|
|
384
|
+
const backgroundScript = `console.log("Background script loaded");
|
|
385
|
+
|
|
386
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
387
|
+
console.log("Extension installed");
|
|
388
|
+
});
|
|
389
|
+
`;
|
|
390
|
+
await fs3.writeFile(
|
|
391
|
+
path3.join(targetDir, "src", "background", "index.ts"),
|
|
392
|
+
backgroundScript
|
|
393
|
+
);
|
|
394
|
+
const popupHtml = `<!DOCTYPE html>
|
|
395
|
+
<html lang="en">
|
|
396
|
+
<head>
|
|
397
|
+
<meta charset="UTF-8">
|
|
398
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
399
|
+
<title>${projectName2}</title>
|
|
400
|
+
<link rel="stylesheet" href="popup.css">
|
|
401
|
+
</head>
|
|
402
|
+
<body>
|
|
403
|
+
<h1>${projectName2}</h1>
|
|
404
|
+
<p>Extension ready</p>
|
|
405
|
+
<script src="popup.js"></script>
|
|
406
|
+
</body>
|
|
407
|
+
</html>
|
|
408
|
+
`;
|
|
409
|
+
await fs3.writeFile(
|
|
410
|
+
path3.join(targetDir, "src", "ui", "popup.html"),
|
|
411
|
+
popupHtml
|
|
412
|
+
);
|
|
413
|
+
const popupCss = `body {
|
|
414
|
+
width: 400px;
|
|
415
|
+
padding: 16px;
|
|
416
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
417
|
+
background: #f5f5f5;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
h1 {
|
|
421
|
+
margin: 0 0 12px 0;
|
|
422
|
+
font-size: 18px;
|
|
423
|
+
color: #333;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
p {
|
|
427
|
+
margin: 0;
|
|
428
|
+
color: #666;
|
|
429
|
+
font-size: 14px;
|
|
430
|
+
}
|
|
431
|
+
`;
|
|
432
|
+
await fs3.writeFile(
|
|
433
|
+
path3.join(targetDir, "src", "ui", "popup.css"),
|
|
434
|
+
popupCss
|
|
435
|
+
);
|
|
436
|
+
const popupScript = `document.addEventListener("DOMContentLoaded", () => {
|
|
437
|
+
console.log("Popup loaded");
|
|
438
|
+
});
|
|
439
|
+
`;
|
|
440
|
+
await fs3.writeFile(
|
|
441
|
+
path3.join(targetDir, "src", "ui", "popup.ts"),
|
|
442
|
+
popupScript
|
|
443
|
+
);
|
|
444
|
+
const configFile = `export default {
|
|
445
|
+
port: 3000,
|
|
446
|
+
watchDir: "src",
|
|
447
|
+
outDir: "dist",
|
|
448
|
+
buildOutDir: "build",
|
|
449
|
+
entries: [
|
|
450
|
+
{ input: "src/background/index.ts", output: "dist/background/index.js" },
|
|
451
|
+
{ input: "src/ui/popup.ts", output: "dist/ui/popup.js" },
|
|
452
|
+
],
|
|
453
|
+
manifest: {
|
|
454
|
+
src: "src/manifest.json",
|
|
455
|
+
dest: "dist/manifest.json",
|
|
456
|
+
injectHmr: true,
|
|
457
|
+
hmrScript: "content/hmr.js",
|
|
458
|
+
hmrMatches: ["<all_urls>"],
|
|
459
|
+
hmrRunAt: "document_start",
|
|
460
|
+
},
|
|
461
|
+
assets: [
|
|
462
|
+
{ src: "src/ui/popup.html", dest: "dist/ui/popup.html" },
|
|
463
|
+
{ src: "src/ui/popup.css", dest: "dist/ui/popup.css" },
|
|
464
|
+
],
|
|
465
|
+
};
|
|
466
|
+
`;
|
|
467
|
+
await fs3.writeFile(path3.join(targetDir, "hotwire.config.js"), configFile);
|
|
468
|
+
const packageJson = {
|
|
469
|
+
name: projectName2,
|
|
470
|
+
version: "1.0.0",
|
|
471
|
+
description: `A Chrome extension: ${projectName2}`,
|
|
472
|
+
type: "module",
|
|
473
|
+
scripts: {
|
|
474
|
+
dev: "hotwire dev",
|
|
475
|
+
build: "hotwire build"
|
|
476
|
+
},
|
|
477
|
+
devDependencies: {
|
|
478
|
+
"@types/chrome": "^0.1.37",
|
|
479
|
+
typescript: "^5.9.3"
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
await fs3.writeFile(
|
|
483
|
+
path3.join(targetDir, "package.json"),
|
|
484
|
+
JSON.stringify(packageJson, null, 2)
|
|
485
|
+
);
|
|
486
|
+
const tsconfig = {
|
|
487
|
+
compilerOptions: {
|
|
488
|
+
target: "ES2020",
|
|
489
|
+
module: "ESNext",
|
|
490
|
+
lib: ["ES2020"],
|
|
491
|
+
skipLibCheck: true,
|
|
492
|
+
esModuleInterop: true,
|
|
493
|
+
resolveJsonModule: true,
|
|
494
|
+
strict: true
|
|
495
|
+
},
|
|
496
|
+
include: ["src"]
|
|
497
|
+
};
|
|
498
|
+
await fs3.writeFile(
|
|
499
|
+
path3.join(targetDir, "tsconfig.json"),
|
|
500
|
+
JSON.stringify(tsconfig, null, 2)
|
|
501
|
+
);
|
|
502
|
+
const gitignore = `node_modules/
|
|
503
|
+
dist/
|
|
504
|
+
build/
|
|
505
|
+
*.log
|
|
506
|
+
.DS_Store
|
|
507
|
+
`;
|
|
508
|
+
await fs3.writeFile(path3.join(targetDir, ".gitignore"), gitignore);
|
|
509
|
+
logger.success(`Project created at ${targetDir}`);
|
|
510
|
+
logger.info("Next steps:");
|
|
511
|
+
logger.info(` cd ${projectName2}`);
|
|
512
|
+
logger.info(` npm install`);
|
|
513
|
+
logger.info(` npm run dev`);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
logger.error(`Scaffolding failed: ${error}`);
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/cli.ts
|
|
521
|
+
init_logger();
|
|
522
|
+
import * as path4 from "path";
|
|
523
|
+
import * as fs4 from "fs/promises";
|
|
524
|
+
var command = process.argv[2];
|
|
525
|
+
var projectName = process.argv[3];
|
|
526
|
+
if (command === "dev") {
|
|
527
|
+
logger.info("Starting Dev Server");
|
|
528
|
+
startDevServer(process.cwd());
|
|
529
|
+
} else if (command === "build") {
|
|
530
|
+
logger.info("Building extension (no HMR)");
|
|
531
|
+
buildOnce(process.cwd());
|
|
532
|
+
} else if (command === "init") {
|
|
533
|
+
if (!projectName) {
|
|
534
|
+
logger.error("Project name required: hotwire init <project-name>");
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
const targetDir = path4.join(process.cwd(), projectName);
|
|
538
|
+
fs4.mkdir(targetDir, { recursive: true }).then(() => scaffold({ projectName, targetDir })).catch((error) => {
|
|
539
|
+
logger.error(`Init failed: ${error}`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
});
|
|
542
|
+
} else {
|
|
543
|
+
console.log(`
|
|
544
|
+
Hotloop - Hot Module Reloading for Chrome Extensions
|
|
545
|
+
|
|
546
|
+
Usage:
|
|
547
|
+
hotloop init <name> Create a new project
|
|
548
|
+
hotloop dev Start the dev server
|
|
549
|
+
hotloop build Build once (no HMR)
|
|
550
|
+
|
|
551
|
+
Examples:
|
|
552
|
+
hotloop init my-extension
|
|
553
|
+
hotloop dev
|
|
554
|
+
hotloop build
|
|
555
|
+
`);
|
|
556
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/logger.ts
|
|
12
|
+
var timestamp, logger;
|
|
13
|
+
var init_logger = __esm({
|
|
14
|
+
"src/logger.ts"() {
|
|
15
|
+
timestamp = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
|
|
16
|
+
logger = {
|
|
17
|
+
info: (msg) => {
|
|
18
|
+
console.log(
|
|
19
|
+
`\x1B[36m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
success: (msg) => {
|
|
23
|
+
console.log(
|
|
24
|
+
`\x1B[32m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
warn: (msg) => {
|
|
28
|
+
console.warn(
|
|
29
|
+
`\x1B[33m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
error: (msg) => {
|
|
33
|
+
console.error(
|
|
34
|
+
`\x1B[31m[hotloop]\x1B[0m \x1B[90m${timestamp()}\x1B[0m ${msg}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// src/server.ts
|
|
42
|
+
var server_exports = {};
|
|
43
|
+
__export(server_exports, {
|
|
44
|
+
HmrServer: () => HmrServer
|
|
45
|
+
});
|
|
46
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
47
|
+
var HmrServer;
|
|
48
|
+
var init_server = __esm({
|
|
49
|
+
"src/server.ts"() {
|
|
50
|
+
init_logger();
|
|
51
|
+
HmrServer = class {
|
|
52
|
+
wss;
|
|
53
|
+
clients = /* @__PURE__ */ new Set();
|
|
54
|
+
constructor(port) {
|
|
55
|
+
this.wss = new WebSocketServer({ port }, () => {
|
|
56
|
+
logger.info(`HMR Server running on ws://localhost:${port}`);
|
|
57
|
+
});
|
|
58
|
+
this.wss.on("connection", (ws) => {
|
|
59
|
+
this.clients.add(ws);
|
|
60
|
+
ws.on("close", () => {
|
|
61
|
+
this.clients.delete(ws);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
broadcast(message) {
|
|
66
|
+
const data = JSON.stringify(message);
|
|
67
|
+
this.clients.forEach((client) => {
|
|
68
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
69
|
+
client.send(data);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
reload() {
|
|
74
|
+
this.broadcast({ type: "reload" });
|
|
75
|
+
}
|
|
76
|
+
close() {
|
|
77
|
+
this.wss.close();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// src/builder.ts
|
|
84
|
+
var builder_exports = {};
|
|
85
|
+
__export(builder_exports, {
|
|
86
|
+
ExtensionBuilder: () => ExtensionBuilder
|
|
87
|
+
});
|
|
88
|
+
import * as esbuild from "esbuild";
|
|
89
|
+
import * as fs from "fs/promises";
|
|
90
|
+
import * as path from "path";
|
|
91
|
+
var ExtensionBuilder;
|
|
92
|
+
var init_builder = __esm({
|
|
93
|
+
"src/builder.ts"() {
|
|
94
|
+
init_logger();
|
|
95
|
+
ExtensionBuilder = class {
|
|
96
|
+
constructor(cwd = process.cwd()) {
|
|
97
|
+
this.cwd = cwd;
|
|
98
|
+
}
|
|
99
|
+
async build(entries) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
try {
|
|
102
|
+
logger.info(`Building ${entries.length} entry(ies)...`);
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const inputPath = path.join(this.cwd, entry.input);
|
|
105
|
+
const outputPath = path.join(this.cwd, entry.output);
|
|
106
|
+
const outputDir = path.dirname(outputPath);
|
|
107
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
108
|
+
await esbuild.build({
|
|
109
|
+
entryPoints: [inputPath],
|
|
110
|
+
bundle: true,
|
|
111
|
+
format: "iife",
|
|
112
|
+
target: "es2020",
|
|
113
|
+
outfile: outputPath,
|
|
114
|
+
sourcemap: false,
|
|
115
|
+
logLevel: "silent"
|
|
116
|
+
});
|
|
117
|
+
logger.success(`${entry.input} \u2192 ${entry.output}`);
|
|
118
|
+
}
|
|
119
|
+
const elapsed = Date.now() - start;
|
|
120
|
+
logger.success(
|
|
121
|
+
`Compilation complete (${entries.length} files) in ${elapsed}ms`
|
|
122
|
+
);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
logger.error(`Build failed: ${error}`);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async copy(files) {
|
|
129
|
+
for (const { src, dest } of files) {
|
|
130
|
+
const srcPath = path.join(this.cwd, src);
|
|
131
|
+
const destPath = path.join(this.cwd, dest);
|
|
132
|
+
const destDir = path.dirname(destPath);
|
|
133
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
134
|
+
await fs.copyFile(srcPath, destPath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async writeManifest(options) {
|
|
138
|
+
const srcPath = path.join(this.cwd, options.src);
|
|
139
|
+
const destPath = path.join(this.cwd, options.dest);
|
|
140
|
+
const destDir = path.dirname(destPath);
|
|
141
|
+
const raw = await fs.readFile(srcPath, "utf-8");
|
|
142
|
+
const manifest = JSON.parse(raw);
|
|
143
|
+
if (options.injectHmr) {
|
|
144
|
+
const hmrScript = options.hmrScript || "content/hmr.js";
|
|
145
|
+
const hmrMatches = options.hmrMatches || ["<all_urls>"];
|
|
146
|
+
const hmrRunAt = options.hmrRunAt || "document_start";
|
|
147
|
+
const existing = Array.isArray(manifest.content_scripts) ? manifest.content_scripts : [];
|
|
148
|
+
const alreadyInjected = existing.some((entry) => {
|
|
149
|
+
const scripts = entry.js || [];
|
|
150
|
+
return Array.isArray(scripts) && scripts.includes(hmrScript);
|
|
151
|
+
});
|
|
152
|
+
if (!alreadyInjected) {
|
|
153
|
+
existing.push({
|
|
154
|
+
matches: hmrMatches,
|
|
155
|
+
js: [hmrScript],
|
|
156
|
+
run_at: hmrRunAt
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
manifest.content_scripts = existing;
|
|
160
|
+
}
|
|
161
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
162
|
+
await fs.writeFile(destPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// src/watcher.ts
|
|
169
|
+
var watcher_exports = {};
|
|
170
|
+
__export(watcher_exports, {
|
|
171
|
+
FileWatcher: () => FileWatcher
|
|
172
|
+
});
|
|
173
|
+
import * as fs2 from "fs";
|
|
174
|
+
var FileWatcher;
|
|
175
|
+
var init_watcher = __esm({
|
|
176
|
+
"src/watcher.ts"() {
|
|
177
|
+
init_logger();
|
|
178
|
+
FileWatcher = class {
|
|
179
|
+
constructor(watchDir, onChange) {
|
|
180
|
+
this.watchDir = watchDir;
|
|
181
|
+
this.onChange = onChange;
|
|
182
|
+
}
|
|
183
|
+
watchers = [];
|
|
184
|
+
debounceTimer = null;
|
|
185
|
+
debounceDelay = 300;
|
|
186
|
+
start() {
|
|
187
|
+
const watch2 = (dir) => {
|
|
188
|
+
const watcher = fs2.watch(dir, { recursive: true }, async (_, file) => {
|
|
189
|
+
if (!file) return;
|
|
190
|
+
if (String(file).includes("node_modules")) return;
|
|
191
|
+
if (String(file).includes("dist")) return;
|
|
192
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
193
|
+
this.debounceTimer = setTimeout(async () => {
|
|
194
|
+
try {
|
|
195
|
+
await this.onChange();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.error(`Build error: ${error}`);
|
|
198
|
+
}
|
|
199
|
+
}, this.debounceDelay);
|
|
200
|
+
});
|
|
201
|
+
this.watchers.push(watcher);
|
|
202
|
+
};
|
|
203
|
+
watch2(this.watchDir);
|
|
204
|
+
logger.info(`Watching ${this.watchDir} for changes`);
|
|
205
|
+
}
|
|
206
|
+
stop() {
|
|
207
|
+
this.watchers.forEach((w) => w.close());
|
|
208
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// src/config.ts
|
|
215
|
+
var config_exports = {};
|
|
216
|
+
__export(config_exports, {
|
|
217
|
+
loadConfig: () => loadConfig,
|
|
218
|
+
validateConfig: () => validateConfig
|
|
219
|
+
});
|
|
220
|
+
import * as path2 from "path";
|
|
221
|
+
import { pathToFileURL } from "url";
|
|
222
|
+
async function loadConfig(cwd) {
|
|
223
|
+
const configPath = path2.join(cwd, "hotloop.config.js");
|
|
224
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
225
|
+
try {
|
|
226
|
+
const module = await import(configUrl);
|
|
227
|
+
return module.default;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Failed to load hotloop.config.js: ${error instanceof Error ? error.message : String(error)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function validateConfig(config) {
|
|
235
|
+
if (!config.entries || config.entries.length === 0) {
|
|
236
|
+
throw new Error("No entries configured. Provide at least one entry point.");
|
|
237
|
+
}
|
|
238
|
+
config.entries.forEach((entry) => {
|
|
239
|
+
if (!entry.input || !entry.output) {
|
|
240
|
+
throw new Error("Each entry must have input and output properties.");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
var init_config = __esm({
|
|
245
|
+
"src/config.ts"() {
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// src/index.ts
|
|
250
|
+
init_logger();
|
|
251
|
+
init_server();
|
|
252
|
+
init_builder();
|
|
253
|
+
init_watcher();
|
|
254
|
+
init_config();
|
|
255
|
+
async function runBuild(cwd, options) {
|
|
256
|
+
const { loadConfig: loadConfig2, validateConfig: validateConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
|
|
257
|
+
const { HmrServer: HmrServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|
|
258
|
+
const { ExtensionBuilder: ExtensionBuilder2 } = await Promise.resolve().then(() => (init_builder(), builder_exports));
|
|
259
|
+
const { FileWatcher: FileWatcher2 } = await Promise.resolve().then(() => (init_watcher(), watcher_exports));
|
|
260
|
+
const config = await loadConfig2(cwd);
|
|
261
|
+
validateConfig2(config);
|
|
262
|
+
if (config.manifest && options?.injectHmr === false) {
|
|
263
|
+
config.manifest = { ...config.manifest, injectHmr: false };
|
|
264
|
+
}
|
|
265
|
+
const outDir = config.outDir || "dist";
|
|
266
|
+
const buildOutDir = config.buildOutDir || outDir;
|
|
267
|
+
const replaceOutDir = (target) => {
|
|
268
|
+
const normalized = target.replace(/\\/g, "/");
|
|
269
|
+
const prefix = `${outDir.replace(/\\/g, "/")}/`;
|
|
270
|
+
if (normalized.startsWith(prefix)) {
|
|
271
|
+
return normalized.replace(prefix, `${buildOutDir}/`);
|
|
272
|
+
}
|
|
273
|
+
return target;
|
|
274
|
+
};
|
|
275
|
+
if (options?.watch === false && buildOutDir !== outDir) {
|
|
276
|
+
config.entries = config.entries.map((entry) => ({
|
|
277
|
+
...entry,
|
|
278
|
+
output: replaceOutDir(entry.output)
|
|
279
|
+
}));
|
|
280
|
+
if (config.manifest) {
|
|
281
|
+
config.manifest = {
|
|
282
|
+
...config.manifest,
|
|
283
|
+
dest: replaceOutDir(config.manifest.dest)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (config.assets) {
|
|
287
|
+
config.assets = config.assets.map((asset) => ({
|
|
288
|
+
...asset,
|
|
289
|
+
dest: replaceOutDir(asset.dest)
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
if (config.copy) {
|
|
293
|
+
config.copy = config.copy.map((asset) => ({
|
|
294
|
+
...asset,
|
|
295
|
+
dest: replaceOutDir(asset.dest)
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const port = config.port || 3e3;
|
|
300
|
+
const watchDir = config.watchDir || "src";
|
|
301
|
+
const server = options?.watch === false ? null : new HmrServer2(port);
|
|
302
|
+
const builder = new ExtensionBuilder2(cwd);
|
|
303
|
+
await builder.build(config.entries);
|
|
304
|
+
if (config.manifest) {
|
|
305
|
+
await builder.writeManifest(config.manifest);
|
|
306
|
+
}
|
|
307
|
+
if (config.assets) {
|
|
308
|
+
await builder.copy(config.assets);
|
|
309
|
+
}
|
|
310
|
+
if (config.copy) {
|
|
311
|
+
await builder.copy(config.copy);
|
|
312
|
+
}
|
|
313
|
+
if (options?.watch === false) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const watcher = new FileWatcher2(watchDir, async () => {
|
|
317
|
+
config.onBuildStart?.();
|
|
318
|
+
try {
|
|
319
|
+
await builder.build(config.entries);
|
|
320
|
+
if (config.manifest) {
|
|
321
|
+
await builder.writeManifest(config.manifest);
|
|
322
|
+
}
|
|
323
|
+
if (config.assets) {
|
|
324
|
+
await builder.copy(config.assets);
|
|
325
|
+
}
|
|
326
|
+
if (config.copy) {
|
|
327
|
+
await builder.copy(config.copy);
|
|
328
|
+
}
|
|
329
|
+
server?.reload();
|
|
330
|
+
} catch (error) {
|
|
331
|
+
logger.error(`Build failed: ${error}`);
|
|
332
|
+
}
|
|
333
|
+
config.onBuildEnd?.();
|
|
334
|
+
});
|
|
335
|
+
watcher.start();
|
|
336
|
+
process.on("SIGINT", () => {
|
|
337
|
+
logger.info("Shutting down");
|
|
338
|
+
watcher.stop();
|
|
339
|
+
server?.close();
|
|
340
|
+
process.exit(0);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async function startDevServer(cwd = process.cwd()) {
|
|
344
|
+
await runBuild(cwd, { watch: true, injectHmr: true });
|
|
345
|
+
}
|
|
346
|
+
async function buildOnce(cwd = process.cwd()) {
|
|
347
|
+
await runBuild(cwd, { watch: false, injectHmr: false });
|
|
348
|
+
}
|
|
349
|
+
export {
|
|
350
|
+
ExtensionBuilder,
|
|
351
|
+
FileWatcher,
|
|
352
|
+
HmrServer,
|
|
353
|
+
buildOnce,
|
|
354
|
+
loadConfig,
|
|
355
|
+
startDevServer,
|
|
356
|
+
validateConfig
|
|
357
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kohi9noor/hotloop",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Minimal, framework-free development tool for small-scale browser extensions. TypeScript, HMR, and Node module support—nothing else.",
|
|
5
|
+
"author": "kohi9noor",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"hotloop": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/kohi9noor/hotloop"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"browser-extension",
|
|
18
|
+
"chrome-extension",
|
|
19
|
+
"hmr",
|
|
20
|
+
"hot-module-reloading",
|
|
21
|
+
"typescript",
|
|
22
|
+
"esbuild",
|
|
23
|
+
"development-tool"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --external:ws --external:esbuild --external:fs --external:fs/promises --external:path --external:child_process --external:url --outdir=dist && esbuild src/cli.ts --bundle --platform=node --format=esm --external:ws --external:esbuild --external:fs --external:fs/promises --external:path --external:child_process --external:url --outfile=dist/cli.js",
|
|
27
|
+
"prepare": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ws": "^8.19.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^25.3.2",
|
|
37
|
+
"esbuild": "^0.27.3",
|
|
38
|
+
"typescript": "^5.9.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Hotloop
|
|
2
|
+
|
|
3
|
+
Minimal development tooling for browser extensions.
|
|
4
|
+
|
|
5
|
+
TypeScript. HMR. Node modules.
|
|
6
|
+
No frameworks. No plugin circus.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Why Hotloop Exists
|
|
11
|
+
|
|
12
|
+
Most extension tooling assumes you're building a mini web app.
|
|
13
|
+
|
|
14
|
+
They integrate Vite pipelines, plugin systems, framework layers, and opinionated structures. That’s great for complex extensions with routing, heavy UI, or framework-driven architecture.
|
|
15
|
+
|
|
16
|
+
But what if you're not?
|
|
17
|
+
|
|
18
|
+
What if:
|
|
19
|
+
|
|
20
|
+
- You're building a small extension with 2–5 focused features
|
|
21
|
+
- You want clean architecture
|
|
22
|
+
- You want TypeScript
|
|
23
|
+
- You want fast rebuilds
|
|
24
|
+
- You want node module support
|
|
25
|
+
- But you **don’t** want React, Vue, or framework assumptions
|
|
26
|
+
|
|
27
|
+
Hotloop is built for that.
|
|
28
|
+
|
|
29
|
+
It gives you modern development essentials without pushing you into a frontend framework ecosystem.
|
|
30
|
+
|
|
31
|
+
It doesn’t try to define your architecture.
|
|
32
|
+
It just improves your development loop.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Philosophy
|
|
37
|
+
|
|
38
|
+
Hotloop is built around one idea:
|
|
39
|
+
|
|
40
|
+
You should be able to write vanilla extension code
|
|
41
|
+
with proper engineering discipline
|
|
42
|
+
without importing half the frontend world.
|
|
43
|
+
|
|
44
|
+
You get:
|
|
45
|
+
|
|
46
|
+
- TypeScript for safety
|
|
47
|
+
- esbuild for speed
|
|
48
|
+
- HMR for fast iteration
|
|
49
|
+
- Node module support
|
|
50
|
+
- A single config file
|
|
51
|
+
- No framework lock-in
|
|
52
|
+
|
|
53
|
+
That’s it.
|
|
54
|
+
|
|
55
|
+
If you want routing systems, plugin ecosystems, or integrated framework support, tools like WXT or CRXJS are excellent choices.
|
|
56
|
+
|
|
57
|
+
Hotloop intentionally stays smaller.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Comparison
|
|
62
|
+
|
|
63
|
+
| Feature | Hotloop | WXT | CRXJS |
|
|
64
|
+
| --------------------- | ---------------- | -------------------------- | ----------------------- |
|
|
65
|
+
| TypeScript Support | ✅ | ✅ | ✅ |
|
|
66
|
+
| Hot Module Reloading | ✅ | ✅ | ✅ |
|
|
67
|
+
| Node Module Support | ✅ | ✅ | ✅ |
|
|
68
|
+
| Build System | esbuild | Vite | Vite |
|
|
69
|
+
| Framework Assumptions | ❌ | Optional | Optional |
|
|
70
|
+
| Plugin Ecosystem | ❌ | ✅ | ✅ |
|
|
71
|
+
| Config Complexity | Minimal | Moderate | Moderate |
|
|
72
|
+
| Best For | Small extensions | Structured / scalable apps | Chrome-focused projects |
|
|
73
|
+
|
|
74
|
+
Hotloop does less on purpose.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Getting Started
|
|
79
|
+
|
|
80
|
+
### Create a New Project
|
|
81
|
+
|
|
82
|
+
Scaffold a new extension project using `npx`:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx hotloop init my-extension
|
|
86
|
+
cd my-extension
|
|
87
|
+
npm install
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then start development:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm run dev
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Install in an Existing Project
|
|
97
|
+
|
|
98
|
+
Install as a dev dependency:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm install --save-dev hotloop
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Add scripts to your `package.json`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"scripts": {
|
|
109
|
+
"dev": "hotloop dev",
|
|
110
|
+
"build": "hotloop build"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Run:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm run dev
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Build for production:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm run build
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Load the `dist` directory as an unpacked extension.
|
|
128
|
+
|
|
129
|
+
Configuration
|
|
130
|
+
|
|
131
|
+
Create a hotloop.config.js in your project root:
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
entries: [
|
|
135
|
+
{ input: "src/background/index.ts", output: "dist/background/index.js" },
|
|
136
|
+
{ input: "src/content/index.ts", output: "dist/content/index.js" },
|
|
137
|
+
{ input: "src/ui/popup.ts", output: "dist/ui/popup.js" },
|
|
138
|
+
],
|
|
139
|
+
|
|
140
|
+
manifest: {
|
|
141
|
+
src: "src/manifest.json",
|
|
142
|
+
dest: "dist/manifest.json",
|
|
143
|
+
|
|
144
|
+
// Injects HMR client only in development mode
|
|
145
|
+
hmrMatches: ["<all_urls>"],
|
|
146
|
+
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
copy: [
|
|
150
|
+
{ src: "src/ui/popup.html", dest: "dist/ui/popup.html" },
|
|
151
|
+
{ src: "src/ui/popup.css", dest: "dist/ui/popup.css" },
|
|
152
|
+
],
|
|
153
|
+
|
|
154
|
+
port: 8000,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
That’s the entire configuration surface.
|
|
158
|
+
|
|
159
|
+
No plugin arrays.
|
|
160
|
+
No layered overrides.
|
|
161
|
+
No framework presets.
|
|
162
|
+
|
|
163
|
+
## Features
|
|
164
|
+
|
|
165
|
+
**Entry Points** – Bundle background scripts, content scripts, and UI scripts independently.
|
|
166
|
+
|
|
167
|
+
**Manifest Handling** – Copies and optionally injects development scripts during dev mode.
|
|
168
|
+
|
|
169
|
+
**Hot Module Reloading** – WebSocket-based HMR improves iteration speed without full extension reload cycles.
|
|
170
|
+
|
|
171
|
+
**Node Modules** – Import npm packages compatible with browser environments.
|
|
172
|
+
|
|
173
|
+
**Fast Builds** – Powered by esbuild for near-instant rebuilds.
|
|
174
|
+
|
|
175
|
+
## What Hotloop Is Not
|
|
176
|
+
|
|
177
|
+
- Not a UI framework
|
|
178
|
+
- Not a plugin platform
|
|
179
|
+
- Not an opinionated architecture
|
|
180
|
+
- Not a meta-framework
|
|
181
|
+
|
|
182
|
+
It’s a dev loop optimizer for vanilla extensions.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
187
|
+
|