-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.mjs
More file actions
200 lines (181 loc) · 7.01 KB
/
Copy pathbuild.mjs
File metadata and controls
200 lines (181 loc) · 7.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// Build script for the CrestApps Soft Phone extension.
//
// Emits per-browser bundles into dist/chrome and dist/firefox using esbuild.
// Conventions borrowed from the author's CloudSolutionsExtension: a template
// manifest with placeholders substituted at build time, and archiver-based zip
// packaging for store upload. Toolchain modernized to esbuild + TypeScript.
//
// Usage:
// node build.mjs build both targets (dev)
// node build.mjs --target=chrome build one target
// node build.mjs --watch rebuild on change (both targets)
// node build.mjs --zip build both, then zip dist/<target> -> dist/<name>-<target>.zip
// node build.mjs --prod minify
import * as esbuild from 'esbuild';
import { readFile, writeFile, mkdir, rm, cp, readdir } from 'node:fs/promises';
import { existsSync, createWriteStream } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import archiver from 'archiver';
const root = path.dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const flag = (name) => args.includes(`--${name}`);
const opt = (name, def) => {
const hit = args.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.split('=')[1] : def;
};
const WATCH = flag('watch');
const ZIP = flag('zip');
const PROD = flag('prod') || flag('production');
const TARGETS = (() => {
const t = opt('target', 'both');
return t === 'both' ? ['chrome', 'firefox'] : [t];
})();
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
// Version source of truth: the VERSION env (set from the release tag in CI, e.g.
// "v1.0.0") overrides package.json; the leading "v" is stripped. Locally, falls
// back to package.json.
const VERSION = (process.env.VERSION || pkg.version).replace(/^v/, '');
// A short, sortable build stamp (local time) so you can confirm which build a
// browser actually loaded — surfaced in the background console + options footer.
const BUILD_STAMP = new Date()
.toISOString()
.replace('T', ' ')
.replace(/\..+/, '')
.slice(0, 16);
// Entry points bundled per target. Offscreen is Chrome-only (Firefox has no
// offscreen API), so it is excluded from the Firefox bundle — otherwise AMO flags
// the unsupported `chrome.offscreen` references it would ship.
const ENTRIES_COMMON = {
'background': 'src/background/index.ts',
'options': 'src/options/options.ts',
'spike': 'src/spike/spike.ts',
'collapsed': 'src/collapsed/collapsed.ts',
'welcome': 'src/welcome/welcome.ts',
'incoming': 'src/incoming/incoming.ts',
};
const ENTRIES_CHROME_ONLY = {
'offscreen': 'src/offscreen/connection.ts',
};
const entriesFor = (target) =>
target === 'chrome' ? { ...ENTRIES_COMMON, ...ENTRIES_CHROME_ONLY } : ENTRIES_COMMON;
// Content scripts must be classic scripts (IIFE), not ESM modules, and cannot
// share split chunks. Built in a separate esbuild pass.
const CONTENT_ENTRIES = {
'content-bridge': 'src/content/bridge.ts',
};
// Static files copied verbatim into each target's dist. [src, destRelative].
// offscreen.html is Chrome-only (see ENTRIES) — never shipped to Firefox.
const STATIC_CHROME_ONLY = [['src/offscreen/offscreen.html', 'offscreen.html']];
const STATIC_COMMON = [
['src/options/options.html', 'options.html'],
['src/spike/spike.html', 'spike.html'],
['src/welcome/welcome.html', 'welcome.html'],
['src/incoming/incoming.html', 'incoming.html'],
['src/collapsed/collapsed.html', 'collapsed.html'],
['assets/icons', 'icons'],
['assets/ringtone.wav', 'ringtone.wav'],
];
const staticFor = (target) =>
target === 'chrome' ? [...STATIC_CHROME_ONLY, ...STATIC_COMMON] : STATIC_COMMON;
async function buildManifest(target, outDir) {
const tmplPath = path.join(root, 'manifests', `manifest.${target}.json`);
let raw = await readFile(tmplPath, 'utf8');
raw = raw.replace(/\{\{VERSION\}\}/g, VERSION);
const manifest = JSON.parse(raw);
await writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
}
async function copyStatic(outDir, target) {
for (const [from, to] of staticFor(target)) {
const src = path.join(root, from);
if (!existsSync(src)) continue; // assets may be placeholders during early phases
await cp(src, path.join(outDir, to), { recursive: true });
}
}
async function buildTarget(target, ctxStore) {
const outDir = path.join(root, 'dist', target);
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
const buildOptions = {
entryPoints: Object.fromEntries(
Object.entries(entriesFor(target)).map(([name, file]) => [name, path.join(root, file)]),
),
bundle: true,
format: 'esm',
splitting: true, // dedupe SignalR into a shared chunk; keep it lazy in the SW
target: ['chrome110', 'firefox115'],
outdir: outDir,
sourcemap: !PROD,
minify: PROD,
logLevel: 'info',
define: {
__BROWSER__: JSON.stringify(target),
__VERSION__: JSON.stringify(VERSION),
__BUILD__: JSON.stringify(BUILD_STAMP),
},
};
const contentOptions = {
entryPoints: Object.fromEntries(
Object.entries(CONTENT_ENTRIES).map(([name, file]) => [name, path.join(root, file)]),
),
bundle: true,
format: 'iife',
target: ['chrome110', 'firefox115'],
outdir: outDir,
sourcemap: !PROD,
minify: PROD,
logLevel: 'info',
define: buildOptions.define,
};
if (WATCH) {
const ctx = await esbuild.context(buildOptions);
const cctx = await esbuild.context(contentOptions);
await ctx.watch();
await cctx.watch();
ctxStore.push(ctx, cctx);
} else {
await esbuild.build(buildOptions);
await esbuild.build(contentOptions);
}
await buildManifest(target, outDir);
await copyStatic(outDir, target);
console.log(`[build] ${target} -> ${path.relative(root, outDir)}`);
}
function zipDir(dir, outFile) {
return new Promise((resolve, reject) => {
const output = createWriteStream(outFile);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => resolve(archive.pointer()));
archive.on('error', reject);
archive.pipe(output);
archive.directory(dir, false);
archive.finalize();
});
}
// Remove stale zips so an old-version .zip can never linger in dist/ and be
// loaded by mistake after a plain `npm run build`.
if (!WATCH) {
const distDir = path.join(root, 'dist');
if (existsSync(distDir)) {
for (const f of await readdir(distDir)) {
if (f.endsWith('.zip')) await rm(path.join(distDir, f), { force: true });
}
}
}
const ctxStore = [];
for (const target of TARGETS) {
await buildTarget(target, ctxStore);
}
if (ZIP && !WATCH) {
for (const target of TARGETS) {
const dir = path.join(root, 'dist', target);
const outFile = path.join(root, 'dist', `${pkg.name}-${target}-v${VERSION}.zip`);
const bytes = await zipDir(dir, outFile);
console.log(`[zip] ${path.relative(root, outFile)} (${bytes} bytes)`);
}
}
if (WATCH) {
console.log('[build] watching for changes… (Ctrl+C to stop)');
} else {
console.log('[build] done.');
}