@vmz/vmz 0.0.3 → 0.1.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.
Files changed (52) hide show
  1. package/README.md +6 -4
  2. package/dist/build-assemble.d.ts +52 -0
  3. package/dist/build-assemble.js +191 -0
  4. package/dist/cdn-policy.d.ts +196 -0
  5. package/dist/cdn-policy.js +443 -0
  6. package/dist/cli.js +152 -11
  7. package/dist/content-addressed-assets.d.ts +69 -0
  8. package/dist/content-addressed-assets.js +206 -0
  9. package/dist/delivery-profile.d.ts +74 -0
  10. package/dist/delivery-profile.js +279 -0
  11. package/dist/dev-session.js +49 -13
  12. package/dist/document-build.js +9 -4
  13. package/dist/document-designs.js +30 -2
  14. package/dist/document-enrich.js +8 -0
  15. package/dist/embedded-packaging.d.ts +22 -0
  16. package/dist/embedded-packaging.js +113 -0
  17. package/dist/index.d.ts +19 -3
  18. package/dist/index.js +35 -15
  19. package/dist/invocation.d.ts +8 -31
  20. package/dist/invocation.js +12 -33
  21. package/dist/locale-check.d.ts +16 -0
  22. package/dist/locale-check.js +131 -3
  23. package/dist/locale-cmd.js +2 -2
  24. package/dist/locale-route-emit.d.ts +34 -0
  25. package/dist/locale-route-emit.js +134 -0
  26. package/dist/locale-router.d.ts +20 -0
  27. package/dist/locale-router.js +68 -0
  28. package/dist/log.d.ts +2 -2
  29. package/dist/log.js +11 -3
  30. package/dist/pack.d.ts +40 -0
  31. package/dist/pack.js +108 -0
  32. package/dist/plugin-host.d.ts +10 -1
  33. package/dist/plugin-host.js +19 -2
  34. package/dist/port.d.ts +10 -0
  35. package/dist/port.js +46 -0
  36. package/dist/production-observability.d.ts +286 -0
  37. package/dist/production-observability.js +469 -0
  38. package/dist/production-test-pack.d.ts +144 -0
  39. package/dist/production-test-pack.js +447 -0
  40. package/dist/release-cmd.d.ts +8 -0
  41. package/dist/release-cmd.js +126 -0
  42. package/dist/release-pack.d.ts +96 -0
  43. package/dist/release-pack.js +346 -0
  44. package/dist/server-artifact.d.ts +140 -0
  45. package/dist/server-artifact.js +205 -0
  46. package/dist/server-language-backend.d.ts +89 -0
  47. package/dist/server-language-backend.js +121 -0
  48. package/dist/site-delivery.d.ts +134 -0
  49. package/dist/site-delivery.js +345 -0
  50. package/dist/static-emit.d.ts +145 -0
  51. package/dist/static-emit.js +577 -0
  52. package/package.json +13 -13
@@ -0,0 +1,443 @@
1
+ /**
2
+ * A3-cdn: vendor-neutral CDN policy (cache / redirect / error) + local static host.
3
+ * Provider adapters only project the same contract — they must not change RouteId/canonical/CSP.
4
+ */
5
+ // @ts-nocheck
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import http from 'node:http';
9
+ import path from 'node:path';
10
+ import { assertLocaleCacheKey, localeAwareCacheKey } from './locale-router.js';
11
+ export const CDN_POLICY_MANIFEST_SCHEMA = 'vmz.cdn.policy_manifest.v0';
12
+ export const CDN_ADAPTER_PROJECTION_SCHEMA = 'vmz.cdn.adapter_projection.v0';
13
+ /** HTML: revalidate. Hashed/static assets: long immutable. */
14
+ export const CACHE_HTML = 'public, max-age=0, must-revalidate';
15
+ export const CACHE_ASSET_IMMUTABLE = 'public, max-age=31536000, immutable';
16
+ export const CACHE_META = 'public, max-age=3600';
17
+ /**
18
+ * Build CDNPolicyManifest from StaticDeliveryManifest (+ optional redirects / locale artifact).
19
+ * Locale-prefixed HTML gets LocaleId-encoded cache keys; Accept-Language must not steal body.
20
+ * @param {Record<string, any>} staticManifest
21
+ * @param {{
22
+ * redirects?: Array<{ from: string, to: string, status?: number, reason?: string }>,
23
+ * localeArtifact?: Record<string, any> | null,
24
+ * }} [opts]
25
+ */
26
+ export function buildCdnPolicyManifest(staticManifest, opts = {}) {
27
+ const origin = String(staticManifest.origin || '');
28
+ const localeArt = opts.localeArtifact || null;
29
+ const localeRedirects = buildOmitPrefixRedirects(staticManifest, localeArt);
30
+ const redirects = [
31
+ { from: '/home', to: '/', status: 301, reason: 'canonical-alias' },
32
+ ...localeRedirects,
33
+ ...(opts.redirects || []),
34
+ ];
35
+ const headers = [
36
+ { match: '**/*.html', headers: { 'cache-control': CACHE_HTML } },
37
+ {
38
+ match: '**/404.html',
39
+ headers: { 'cache-control': CACHE_HTML, 'x-robots-tag': 'noindex, nofollow' },
40
+ },
41
+ // Content-addressed immutable objects (A3 assets/<hash>).
42
+ { match: '**/assets/**', headers: { 'cache-control': CACHE_ASSET_IMMUTABLE } },
43
+ { match: '**/*.{js,css,mjs}', headers: { 'cache-control': CACHE_ASSET_IMMUTABLE } },
44
+ { match: '**/sitemap.xml', headers: { 'cache-control': CACHE_META } },
45
+ { match: '**/robots.txt', headers: { 'cache-control': CACHE_META } },
46
+ ];
47
+ const errorDocuments = Array.isArray(staticManifest.errorDocuments) ? staticManifest.errorDocuments : [{ status: 404, path: '404.html' }];
48
+ /** @type {any[]} */
49
+ const routes = [];
50
+ /** @type {any[]} */
51
+ const cacheKeyDiagnostics = [];
52
+ for (const r of staticManifest.routes || []) {
53
+ const localeId = r.localeId || null;
54
+ const alternates = Array.isArray(r.seo?.alternates) ? r.seo.alternates : [];
55
+ const cacheKey = localeId
56
+ ? localeAwareCacheKey({ routeId: String(r.routeId), localeId: String(localeId), path: String(r.path) })
57
+ : `route=${r.routeId}|path=${r.path}`;
58
+ // When LocaleId is bound, prove the key would still be safe even if Vary: Accept-Language were set.
59
+ if (localeId) {
60
+ const assert = assertLocaleCacheKey({
61
+ cacheKey,
62
+ varyAcceptLanguage: true,
63
+ localeId: String(localeId),
64
+ });
65
+ if (!assert.ok) {
66
+ cacheKeyDiagnostics.push(...(assert.diagnostics || []));
67
+ }
68
+ }
69
+ routes.push({
70
+ routeId: r.routeId,
71
+ path: r.path,
72
+ htmlPath: r.htmlPath,
73
+ canonical: r.seo?.canonical || null,
74
+ localeId,
75
+ cacheKey,
76
+ varyAcceptLanguage: false,
77
+ hreflang: alternates.map((a) => ({
78
+ hreflang: a.hreflang,
79
+ href: a.href,
80
+ localeId: a.localeId || null,
81
+ })),
82
+ });
83
+ }
84
+ if (cacheKeyDiagnostics.length) {
85
+ const msg = cacheKeyDiagnostics.map((d) => d.message || d.code).join('; ');
86
+ throw new Error(`buildCdnPolicyManifest: locale cache key contract failed: ${msg}`);
87
+ }
88
+ const localeRoutes = routes.filter((r) => r.localeId);
89
+ const body = {
90
+ schema: CDN_POLICY_MANIFEST_SCHEMA,
91
+ applicationId: staticManifest.applicationId || null,
92
+ deliveryProfile: 'web-static',
93
+ origin,
94
+ spaFallback: false,
95
+ staticManifestDigest: staticManifest.manifestDigest || null,
96
+ redirects,
97
+ headers,
98
+ errorDocuments,
99
+ routes,
100
+ localeCache: {
101
+ strategy: 'path-locale',
102
+ varyAcceptLanguage: false,
103
+ defaultLocale: localeArt?.defaultLocale || null,
104
+ routeCount: localeRoutes.length,
105
+ locales: [...new Set(localeRoutes.map((r) => r.localeId))],
106
+ },
107
+ };
108
+ body.policyDigest = sha256Hex(canonicalJson(body));
109
+ return body;
110
+ }
111
+ /**
112
+ * Omit-prefix: /{defaultLocale}/… → canonical unprefixed path (CDN redirect, not Accept-Language).
113
+ * @param {Record<string, any>} staticManifest
114
+ * @param {Record<string, any> | null} localeArt
115
+ */
116
+ function buildOmitPrefixRedirects(staticManifest, localeArt) {
117
+ const routing = localeArt?.routing || {};
118
+ const defaultLocale = localeArt?.defaultLocale || routing.defaultLocale;
119
+ if (!defaultLocale || routing.defaultPrefix !== 'omit')
120
+ return [];
121
+ /** @type {Array<{ from: string, to: string, status: number, reason: string }>} */
122
+ const out = [];
123
+ const seen = new Set();
124
+ for (const r of staticManifest.routes || []) {
125
+ if (r.localeId !== defaultLocale)
126
+ continue;
127
+ const canonical = String(r.path || '/');
128
+ const from = canonical === '/' ? `/${defaultLocale}` : `/${defaultLocale}${canonical.startsWith('/') ? canonical : `/${canonical}`}`;
129
+ if (seen.has(from))
130
+ continue;
131
+ seen.add(from);
132
+ out.push({
133
+ from,
134
+ to: canonical,
135
+ status: 301,
136
+ reason: 'locale-omit-prefix-default',
137
+ });
138
+ }
139
+ return out;
140
+ }
141
+ /**
142
+ * Write CDN policy + adapter projections under dist/_vmz.
143
+ * @param {string} distDir
144
+ * @param {Record<string, any>} staticManifest
145
+ * @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
146
+ */
147
+ export function emitCdnPolicy(distDir, staticManifest, opts = {}) {
148
+ const localeArtifact = loadLocaleArtifact(distDir);
149
+ const policy = buildCdnPolicyManifest(staticManifest, { ...opts, localeArtifact });
150
+ const vmzDir = path.join(distDir, '_vmz');
151
+ fs.mkdirSync(vmzDir, { recursive: true });
152
+ fs.writeFileSync(path.join(vmzDir, 'cdn-policy-manifest.json'), `${JSON.stringify(policy, null, 2)}\n`, 'utf8');
153
+ const local = projectCdnAdapter(policy, 'local-static');
154
+ const netlify = projectCdnAdapter(policy, 'netlify');
155
+ const adaptersDir = path.join(vmzDir, 'adapters');
156
+ fs.mkdirSync(path.join(adaptersDir, 'local-static'), { recursive: true });
157
+ fs.mkdirSync(path.join(adaptersDir, 'netlify'), { recursive: true });
158
+ fs.writeFileSync(path.join(adaptersDir, 'local-static', 'projection.json'), `${JSON.stringify(local, null, 2)}\n`, 'utf8');
159
+ fs.writeFileSync(path.join(adaptersDir, 'netlify', 'projection.json'), `${JSON.stringify(netlify, null, 2)}\n`, 'utf8');
160
+ fs.writeFileSync(path.join(adaptersDir, 'netlify', '_headers'), String(netlify.files['_headers'] || ''), 'utf8');
161
+ fs.writeFileSync(path.join(adaptersDir, 'netlify', '_redirects'), String(netlify.files['_redirects'] || ''), 'utf8');
162
+ return { policy, adapters: { 'local-static': local, netlify } };
163
+ }
164
+ /**
165
+ * @param {string} distDir
166
+ */
167
+ function loadLocaleArtifact(distDir) {
168
+ const p = path.join(distDir, '_vmz', 'locale-route-realization.json');
169
+ if (!fs.existsSync(p))
170
+ return null;
171
+ try {
172
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ /**
179
+ * Project vendor-neutral policy to an adapter. Must preserve redirect targets and forbid SPA fallback.
180
+ * @param {Record<string, any>} policy
181
+ * @param {'local-static' | 'netlify'} adapterId
182
+ */
183
+ export function projectCdnAdapter(policy, adapterId) {
184
+ if (policy.spaFallback) {
185
+ throw new Error('projectCdnAdapter: spaFallback=true is forbidden');
186
+ }
187
+ if (adapterId === 'local-static') {
188
+ return {
189
+ schema: CDN_ADAPTER_PROJECTION_SCHEMA,
190
+ adapterId: 'local-static',
191
+ policyDigest: policy.policyDigest,
192
+ host: 'vmz-local-static',
193
+ spaFallback: false,
194
+ redirects: policy.redirects,
195
+ headers: policy.headers,
196
+ errorDocuments: policy.errorDocuments,
197
+ files: {},
198
+ };
199
+ }
200
+ if (adapterId === 'netlify') {
201
+ const headerLines = [];
202
+ for (const rule of policy.headers || []) {
203
+ const glob = netlifyGlob(rule.match);
204
+ headerLines.push(glob);
205
+ for (const [k, v] of Object.entries(rule.headers || {})) {
206
+ headerLines.push(` ${headerCase(k)}: ${v}`);
207
+ }
208
+ }
209
+ const redirectLines = [];
210
+ for (const r of policy.redirects || []) {
211
+ redirectLines.push(`${r.from} ${r.to} ${Number(r.status) || 301}`);
212
+ }
213
+ // Explicit only — never `/* /index.html 200`
214
+ const redirectsText = redirectLines.join('\n') + (redirectLines.length ? '\n' : '');
215
+ if (/\*\s+\/index\.html/.test(redirectsText)) {
216
+ throw new Error('netlify adapter refused SPA fallback redirect');
217
+ }
218
+ return {
219
+ schema: CDN_ADAPTER_PROJECTION_SCHEMA,
220
+ adapterId: 'netlify',
221
+ policyDigest: policy.policyDigest,
222
+ host: 'netlify',
223
+ spaFallback: false,
224
+ redirects: policy.redirects,
225
+ headers: policy.headers,
226
+ errorDocuments: policy.errorDocuments,
227
+ files: {
228
+ _headers: headerLines.join('\n') + '\n',
229
+ _redirects: redirectsText,
230
+ },
231
+ };
232
+ }
233
+ throw new Error(`unknown CDN adapter ${adapterId}`);
234
+ }
235
+ /**
236
+ * Local static host that applies CDNPolicyManifest (redirects, cache headers, 404 doc).
237
+ * @param {string} distDir
238
+ * @param {Record<string, any>} policy
239
+ * @param {{ host?: string, port?: number }} [opts]
240
+ * @returns {Promise<{ host: string, port: number, baseUrl: string, close: () => Promise<void> }>}
241
+ */
242
+ export function listenLocalStaticHost(distDir, policy, opts = {}) {
243
+ const host = opts.host || '127.0.0.1';
244
+ const port = Number(opts.port || 0);
245
+ const handler = createLocalStaticHandler(distDir, policy);
246
+ const server = http.createServer(handler);
247
+ return new Promise((resolve, reject) => {
248
+ server.listen(port, host, () => {
249
+ const addr = server.address();
250
+ const actualPort = typeof addr === 'object' && addr ? addr.port : port;
251
+ resolve({
252
+ host,
253
+ port: actualPort,
254
+ baseUrl: `http://${host}:${actualPort}`,
255
+ close: () => new Promise((res, rej) => {
256
+ server.close((err) => (err ? rej(err) : res()));
257
+ }),
258
+ });
259
+ });
260
+ server.on('error', reject);
261
+ });
262
+ }
263
+ /**
264
+ * @param {string} distDir
265
+ * @param {Record<string, any>} policy
266
+ */
267
+ export function createLocalStaticHandler(distDir, policy) {
268
+ const root = path.resolve(distDir);
269
+ return (req, res) => {
270
+ try {
271
+ const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
272
+ let pathname = decodeURIComponent(url.pathname || '/');
273
+ const redirect = matchRedirect(policy.redirects || [], pathname);
274
+ if (redirect) {
275
+ const status = Number(redirect.status) || 301;
276
+ const headers = applyHeaderRules(policy.headers || [], pathname, {
277
+ Location: redirect.to,
278
+ });
279
+ res.writeHead(status, headers);
280
+ res.end();
281
+ return;
282
+ }
283
+ let rel = pathname;
284
+ if (rel.endsWith('/'))
285
+ rel += 'index.html';
286
+ if (rel === '/')
287
+ rel = '/index.html';
288
+ const file = safeJoin(root, rel.replace(/^\//, ''));
289
+ if (file && fs.existsSync(file) && fs.statSync(file).isFile()) {
290
+ const body = fs.readFileSync(file);
291
+ const type = contentType(file);
292
+ const headers = applyHeaderRules(policy.headers || [], rel, {
293
+ 'content-type': type,
294
+ });
295
+ res.writeHead(200, headers);
296
+ res.end(body);
297
+ return;
298
+ }
299
+ const errDoc = (policy.errorDocuments || []).find((e) => Number(e.status) === 404);
300
+ const errPath = errDoc?.path || '404.html';
301
+ const abs404 = path.join(root, errPath);
302
+ if (fs.existsSync(abs404)) {
303
+ const body = fs.readFileSync(abs404);
304
+ const headers = applyHeaderRules(policy.headers || [], `/${errPath}`, {
305
+ 'content-type': 'text/html; charset=utf-8',
306
+ });
307
+ res.writeHead(404, headers);
308
+ res.end(body);
309
+ return;
310
+ }
311
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
312
+ res.end('not found');
313
+ }
314
+ catch (err) {
315
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
316
+ res.end(err instanceof Error ? err.message : String(err));
317
+ }
318
+ };
319
+ }
320
+ /**
321
+ * @param {Array<{ from: string, to: string, status?: number }>} redirects
322
+ * @param {string} pathname
323
+ */
324
+ function matchRedirect(redirects, pathname) {
325
+ const p = pathname.replace(/\/+$/, '') || '/';
326
+ for (const r of redirects) {
327
+ const from = String(r.from || '').replace(/\/+$/, '') || '/';
328
+ if (from === p || from === pathname)
329
+ return r;
330
+ }
331
+ return null;
332
+ }
333
+ /**
334
+ * @param {Array<{ match: string, headers: Record<string, string> }>} rules
335
+ * @param {string} pathname
336
+ * @param {Record<string, string>} base
337
+ */
338
+ function applyHeaderRules(rules, pathname, base = {}) {
339
+ /** @type {Record<string, string>} */
340
+ const out = { ...base };
341
+ for (const rule of rules) {
342
+ if (matchGlob(rule.match, pathname)) {
343
+ Object.assign(out, rule.headers || {});
344
+ }
345
+ }
346
+ return out;
347
+ }
348
+ /**
349
+ * Minimal glob matcher for CDN header rules (html / js|css|mjs / exact / prefix).
350
+ * @param {string} pattern
351
+ * @param {string} pathname
352
+ */
353
+ export function matchGlob(pattern, pathname) {
354
+ const p = pathname.startsWith('/') ? pathname : `/${pathname}`;
355
+ const pat = String(pattern || '');
356
+ if (pat === '**/*.html')
357
+ return p.endsWith('.html');
358
+ if (pat === '**/404.html')
359
+ return p === '/404.html' || p.endsWith('/404.html');
360
+ if (pat === '**/assets/**')
361
+ return p === '/assets' || p.startsWith('/assets/');
362
+ if (pat === '**/*.{js,css,mjs}')
363
+ return /\.(js|css|mjs)$/.test(p);
364
+ if (pat === '**/sitemap.xml')
365
+ return p.endsWith('/sitemap.xml') || p === '/sitemap.xml';
366
+ if (pat === '**/robots.txt')
367
+ return p.endsWith('/robots.txt') || p === '/robots.txt';
368
+ if (pat.endsWith('/**')) {
369
+ const prefix = pat.slice(0, -3);
370
+ // Only treat as path prefix when pattern is absolute-ish (starts with / or bare segment).
371
+ if (prefix.startsWith('/')) {
372
+ return p === prefix || p.startsWith(prefix + '/');
373
+ }
374
+ if (!prefix.includes('*')) {
375
+ return p === `/${prefix}` || p.startsWith(`/${prefix}/`);
376
+ }
377
+ }
378
+ return p === pat || p === `/${pat.replace(/^\//, '')}`;
379
+ }
380
+ function netlifyGlob(match) {
381
+ if (match === '**/*.html')
382
+ return '/*.html';
383
+ if (match === '**/404.html')
384
+ return '/404.html';
385
+ if (match === '**/assets/**')
386
+ return '/assets/*';
387
+ if (match === '**/*.{js,css,mjs}')
388
+ return '/*.{js,css,mjs}';
389
+ if (match === '**/sitemap.xml')
390
+ return '/sitemap.xml';
391
+ if (match === '**/robots.txt')
392
+ return '/robots.txt';
393
+ return match.startsWith('/') ? match : `/${match}`;
394
+ }
395
+ function headerCase(name) {
396
+ return String(name)
397
+ .split('-')
398
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
399
+ .join('-');
400
+ }
401
+ function contentType(file) {
402
+ if (file.endsWith('.html'))
403
+ return 'text/html; charset=utf-8';
404
+ if (file.endsWith('.js') || file.endsWith('.mjs'))
405
+ return 'text/javascript; charset=utf-8';
406
+ if (file.endsWith('.css'))
407
+ return 'text/css; charset=utf-8';
408
+ if (file.endsWith('.xml'))
409
+ return 'application/xml';
410
+ if (file.endsWith('.txt'))
411
+ return 'text/plain; charset=utf-8';
412
+ if (file.endsWith('.json'))
413
+ return 'application/json; charset=utf-8';
414
+ return 'application/octet-stream';
415
+ }
416
+ /**
417
+ * @param {string} root
418
+ * @param {string} rel
419
+ */
420
+ function safeJoin(root, rel) {
421
+ const full = path.resolve(root, rel);
422
+ const normRoot = path.resolve(root);
423
+ if (full !== normRoot && !full.startsWith(normRoot + path.sep))
424
+ return null;
425
+ return full;
426
+ }
427
+ function sha256Hex(data) {
428
+ return crypto.createHash('sha256').update(data).digest('hex');
429
+ }
430
+ function canonicalJson(value) {
431
+ return JSON.stringify(sortKeys(value));
432
+ }
433
+ function sortKeys(value) {
434
+ if (Array.isArray(value))
435
+ return value.map(sortKeys);
436
+ if (value && typeof value === 'object') {
437
+ const out = {};
438
+ for (const k of Object.keys(value).sort())
439
+ out[k] = sortKeys(value[k]);
440
+ return out;
441
+ }
442
+ return value;
443
+ }