Repository navigation
Expand file tree
/
Copy pathbuild.mjs
More file actions
319 lines (282 loc) · 12.7 KB
/
Copy pathbuild.mjs
File metadata and controls
319 lines (282 loc) · 12.7 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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
import { transform } from 'esbuild';
import { minify as minifyHtml } from 'html-minifier-terser';
import { promises as fs } from 'fs';
import { execSync } from 'child_process';
import path from 'path';
import crypto from 'crypto';
const root = process.cwd();
const dist = path.join(root, 'dist');
const sha8 = (buf) => crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
const sha256b64 = (buf) => 'sha256-' + crypto.createHash('sha256').update(buf).digest('base64');
const toPosix = (p) => p.split(path.sep).join('/');
function gitCommit() {
const env = process.env.CF_PAGES_COMMIT_SHA || process.env.GITHUB_SHA || process.env.COMMIT_SHA;
if (env) return env.trim();
try { return execSync('git rev-parse HEAD').toString().trim(); } catch (_) { return 'unknown'; }
}
function gitCommitTime() {
try { return execSync('git log -1 --format=%cI').toString().trim(); } catch (_) { return ''; }
}
async function walk(dir) {
const out = [];
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...await walk(full));
else out.push(full);
}
return out;
}
function hashedName(rel, content) {
const ext = path.extname(rel);
const stem = rel.slice(0, -ext.length);
return `${stem}.${sha8(content)}${ext}`;
}
const htmlMinifyOptions = {
collapseWhitespace: true,
conservativeCollapse: true,
removeComments: true,
removeRedundantAttributes: true,
minifyCSS: true,
minifyJS: true,
};
// Keyed like the runtime lookup (makeKey in i18n/strings.mjs), not raw English; returns a build-summary line.
async function emitI18nPacks() {
let languages;
let sources;
let counts;
let packEntry;
let makeKey;
try {
const strings = await import('./i18n/strings.mjs');
({ packEntry, makeKey } = strings);
({ sources, counts } = await strings.loadSources());
languages = await (await import('./i18n/languages.mjs')).loadLanguages();
} catch (err) {
return `i18n packs: skipped (${err.message})`;
}
// Only strings still in the app.
const live = new Set(sources);
const liveKeys = new Set(sources.map((s) => makeKey(s).key));
let written = 0;
let bytes = 0;
let covered = 0;
for (const lang of languages) {
let cache;
try {
cache = JSON.parse(await fs.readFile(path.join(root, 'i18n', 'cache', `${lang.code}.json`), 'utf8'));
} catch {
continue; // No cache for this language yet.
}
const pack = {};
let have = 0;
for (const [source, translated] of Object.entries(cache)) {
if (!live.has(source) || typeof translated !== 'string' || !translated) continue;
// Null when the translation can't be templated back; those stay live-translated.
const entry = packEntry(source, translated);
if (!entry) continue;
const [key, value] = entry;
if (pack[key] != null) continue; // sources that collapse to one key
pack[key] = value;
have++;
}
if (have === 0) continue;
const body = JSON.stringify(pack);
await emit(path.join('i18n', `${lang.code}.json`), body);
written++;
bytes += body.length;
covered += have / liveKeys.size;
}
// Name the catalog source so a pack missing the newest copy shows in the build log.
const from = counts.dartKind === 'none'
? 'markup only — no Flutter catalog found, so app strings are not covered'
: `${counts.total} strings (${counts.dartKind} catalog + markup)`;
if (written === 0) return `i18n packs: none (run \`npm run i18n\`) — ${from}`;
// Coverage, not "complete": untemplatable entries are dropped on purpose.
return `i18n packs: ${written} languages from ${from}, `
+ `${((covered / written) * 100).toFixed(0)}% of strings covered, `
+ `${(bytes / written / 1024).toFixed(0)} KB each on average`;
}
async function emit(rel, code) {
const dest = path.join(dist, rel);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.writeFile(dest, code);
}
async function run() {
await fs.rm(dist, { recursive: true, force: true });
await fs.mkdir(dist, { recursive: true });
// rel-from-root path -> hashed rel-from-root path
const assetMap = new Map();
// public path ('/js/app.<hash>.js') -> 'sha256-<base64>' of the served bytes
const manifestFiles = {};
// Hash data/ first so '/data/...' references in JS get rewritten to hashed names.
for (const file of await walk(path.join(root, 'data'))) {
if (!file.endsWith('.json')) continue;
const rel = toPosix(path.relative(root, file));
const code = JSON.stringify(JSON.parse(await fs.readFile(file, 'utf8')));
const hashed = hashedName(rel, code);
await emit(hashed, code);
assetMap.set(rel, hashed);
manifestFiles['/' + hashed] = sha256b64(Buffer.from(code));
}
for (const file of await walk(path.join(root, 'images'))) {
const rel = toPosix(path.relative(root, file));
const content = await fs.readFile(file);
const hashed = hashedName(rel, content);
await emit(hashed, content);
assetMap.set(rel, hashed);
}
// Leaves first so '/js/...' references are rewritten to hashed names before hashing the referrer.
const jsWave = (rel) => {
if (rel === 'js/nostr-tools.js' || rel.startsWith('js/vendor/')) return 0;
// Worker dependencies before workers, workers before their referrers.
if (rel === 'js/modules/syntax-highlight.js' || rel === 'js/geo-decode.js'
|| rel === 'js/modules/message-format.js' || rel === 'js/modules/media-notes.js'
|| rel === 'js/modules/call-link-icon.js') return 1;
if (rel === 'js/verify-worker.js' || rel === 'js/highlight-worker.js'
|| rel === 'js/geo-decode-worker.js' || rel === 'js/format-worker.js') return 2;
return 3;
};
const jsFiles = (await walk(path.join(root, 'js')))
.filter((f) => f.endsWith('.js'))
.sort((a, b) => jsWave(toPosix(path.relative(root, a))) - jsWave(toPosix(path.relative(root, b))));
for (const file of jsFiles) {
const rel = toPosix(path.relative(root, file));
let src = await fs.readFile(file, 'utf8');
for (const [orig, hashed] of assetMap) src = src.split('/' + orig).join('/' + hashed);
const { code } = await transform(src, { loader: 'js', minify: true, legalComments: 'none' });
const hashed = hashedName(rel, code);
await emit(hashed, code);
assetMap.set(rel, hashed);
manifestFiles['/' + hashed] = sha256b64(Buffer.from(code));
}
for (const file of await walk(path.join(root, 'css'))) {
if (!file.endsWith('.css')) continue;
const rel = toPosix(path.relative(root, file));
const src = await fs.readFile(file, 'utf8');
const { code, warnings } = await transform(src, { loader: 'css', minify: true, legalComments: 'none' });
if (warnings.length) {
const w = warnings[0];
throw new Error(`${rel}:${w.location ? w.location.line : '?'} ${w.text}`);
}
const hashed = hashedName(rel, code);
await emit(hashed, code);
assetMap.set(rel, hashed);
manifestFiles['/' + hashed] = sha256b64(Buffer.from(code));
}
// Longest keys first so shorter paths can't partially shadow longer ones.
const replacements = [...assetMap.entries()].sort((a, b) => b[0].length - a[0].length);
const rewriteHtml = (html) => {
for (const [orig, hashed] of replacements) html = html.split(orig).join(hashed);
return html;
};
const indexHtml = rewriteHtml(await fs.readFile(path.join(root, 'index.html'), 'utf8'));
const indexOut = await minifyHtml(indexHtml, htmlMinifyOptions);
await emit('index.html', indexOut);
manifestFiles['/index.html'] = sha256b64(Buffer.from(indexOut));
const notFoundHtml = rewriteHtml(await fs.readFile(path.join(root, '404.html'), 'utf8'));
const notFoundOut = await minifyHtml(notFoundHtml, htmlMinifyOptions);
await emit('404.html', notFoundOut);
await emit('robots.txt', await fs.readFile(path.join(root, 'robots.txt')));
// The app is one page; the site's real sitemap is on the apex domain.
await emit('sitemap.xml', await fs.readFile(path.join(root, 'sitemap.xml')));
// Markdown pointer file for AI agents and automated readers.
await emit('llms.txt', await fs.readFile(path.join(root, 'llms.txt')));
// Retired /static/*.html pages redirect to the apex domain for shipped app builds.
await emit('_redirects', await fs.readFile(path.join(root, '_redirects')));
await emit('_routes.json', await fs.readFile(path.join(root, '_routes.json')));
// Vulnerability-disclosure pointer (RFC 9116).
await emit('.well-known/security.txt', await fs.readFile(path.join(root, '.well-known', 'security.txt')));
for (const name of ['apple-app-site-association', 'assetlinks.json']) {
await emit(`.well-known/${name}`, await fs.readFile(path.join(root, '.well-known', name)));
}
// NYMCHAT_VERSION from js/app.js, served so native apps can show the live version.
const appJsSource = await fs.readFile(path.join(root, 'js', 'app.js'), 'utf8');
const versionMatch = appJsSource.match(/NYMCHAT_VERSION\s*=\s*['"]([^'"]+)['"]/);
const appVersion = versionMatch ? versionMatch[1] : 'unknown';
await emit('version.json', JSON.stringify({ version: appVersion }));
const packSummary = await emitI18nPacks();
// Per-build cache version so each deploy gets a fresh cache and old ones are pruned on activate.
const swVersion = sha8([...assetMap.values()].sort().join('|'));
// Critical shell assets to precache on SW install (hashed names).
const criticalSources = [
'css/styles-core.css', 'css/styles-shell.css', 'css/styles-chat.css',
'css/styles-components.css', 'css/styles-themes-responsive.css', 'css/styles-columns.css',
'css/no-inline.css',
'js/defer-css.js', 'js/accounts.js', 'js/theme-init.js', 'js/setup-modal-init.js',
'js/languages.js', 'js/first-lang.js',
'js/modules/call-link-icon.js', 'js/modules/menu-dots-icon.js',
'js/modules/inline-bindings.js', 'js/modules/dialog.js', 'js/modules/sheets.js',
'js/nostr-tools.js',
'js/app.js', 'js/vendor/ml-kem.js', 'js/nym-crypto.js', 'js/modules/crypto-pool.js',
'js/modules/pq.js',
'js/modules/persistence.js', 'js/modules/key-vault.js', 'js/modules/panic.js',
'js/modules/remote-panic.js', 'js/modules/remote-panic-ui.js',
'js/modules/ai-consent.js',
'js/modules/attest.js', 'js/modules/filter-packs.js', 'js/modules/content-filter.js', 'js/modules/event-details.js',
'js/modules/wrap-outbox.js', 'js/modules/d1-cursor.js', 'js/modules/relay-add.js', 'js/modules/relay-block.js',
'js/modules/relays.js', 'js/modules/nostr-core.js', 'js/modules/nym-suffix.js', 'js/modules/users.js',
'js/modules/channels.js', 'js/modules/syntax-highlight.js', 'js/modules/messages.js',
'js/modules/pms.js', 'js/modules/groups.js', 'js/modules/ui-context.js',
'js/modules/init.js', 'js/modules/build-verify.js', 'js/modules/canary-verify.js',
'js/modules/columns.js', 'js/modules/threads.js', 'js/modules/key-backup.js',
'js/modules/passkey-backup.js',
];
const precache = criticalSources
.map((rel) => assetMap.get(rel))
.filter(Boolean)
.map((hashed) => '/' + hashed);
const swSrc = await fs.readFile(path.join(root, 'sw.js'), 'utf8');
const swOut = swSrc
.replace('__CACHE_VERSION__', swVersion)
.replace('__PRECACHE_ASSETS__', JSON.stringify(precache));
await emit('sw.js', swOut);
manifestFiles['/sw.js'] = sha256b64(Buffer.from(swOut));
// Reproducible: bundleHash covers only hashed assets and builtAt is the commit time.
const bundleHash = crypto.createHash('sha256')
.update(Object.keys(manifestFiles).sort().map((p) => p + ':' + manifestFiles[p]).join('\n'))
.digest('hex');
const commit = gitCommit();
await emit('bundle-hash.txt', bundleHash + '\n');
await emit('build-manifest.json', JSON.stringify({
app: 'nymchat',
commit,
builtAt: gitCommitTime(),
algo: 'sha256',
bundleHash,
files: manifestFiles,
}, null, 2));
// Immutable caching for hashed assets, no-cache for entry.
const headers = await fs.readFile(path.join(root, '_headers'), 'utf8');
const cacheRules = `
/js/*
Cache-Control: public, max-age=31536000, immutable
/css/*
Cache-Control: public, max-age=31536000, immutable
/data/*
Cache-Control: public, max-age=31536000, immutable
/images/*
Cache-Control: public, max-age=31536000, immutable
/i18n/*
Cache-Control: public, max-age=86400
/index.html
Cache-Control: no-cache
/404.html
Cache-Control: no-cache
/
Cache-Control: no-cache
/sw.js
Cache-Control: no-cache
/build-manifest.json
Cache-Control: no-cache
/version.json
Cache-Control: public, max-age=300
/llms.txt
Cache-Control: public, max-age=3600
`;
await emit('_headers', headers.replace(/\s*$/, '') + cacheRules);
console.log(`Built ${assetMap.size} assets to dist/.`);
console.log(packSummary);
console.log(`Build hash: ${bundleHash}`);
console.log(`Commit: ${commit}`);
}
run().catch((e) => { console.error(e); process.exit(1); });