@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.
- package/README.md +6 -4
- package/dist/build-assemble.d.ts +52 -0
- package/dist/build-assemble.js +191 -0
- package/dist/cdn-policy.d.ts +196 -0
- package/dist/cdn-policy.js +443 -0
- package/dist/cli.js +152 -11
- package/dist/content-addressed-assets.d.ts +69 -0
- package/dist/content-addressed-assets.js +206 -0
- package/dist/delivery-profile.d.ts +74 -0
- package/dist/delivery-profile.js +279 -0
- package/dist/dev-session.js +49 -13
- package/dist/document-build.js +9 -4
- package/dist/document-designs.js +30 -2
- package/dist/document-enrich.js +8 -0
- package/dist/embedded-packaging.d.ts +22 -0
- package/dist/embedded-packaging.js +113 -0
- package/dist/index.d.ts +19 -3
- package/dist/index.js +35 -15
- package/dist/invocation.d.ts +8 -31
- package/dist/invocation.js +12 -33
- package/dist/locale-check.d.ts +16 -0
- package/dist/locale-check.js +131 -3
- package/dist/locale-cmd.js +2 -2
- package/dist/locale-route-emit.d.ts +34 -0
- package/dist/locale-route-emit.js +134 -0
- package/dist/locale-router.d.ts +20 -0
- package/dist/locale-router.js +68 -0
- package/dist/log.d.ts +2 -2
- package/dist/log.js +11 -3
- package/dist/pack.d.ts +40 -0
- package/dist/pack.js +108 -0
- package/dist/plugin-host.d.ts +10 -1
- package/dist/plugin-host.js +19 -2
- package/dist/port.d.ts +10 -0
- package/dist/port.js +46 -0
- package/dist/production-observability.d.ts +286 -0
- package/dist/production-observability.js +469 -0
- package/dist/production-test-pack.d.ts +144 -0
- package/dist/production-test-pack.js +447 -0
- package/dist/release-cmd.d.ts +8 -0
- package/dist/release-cmd.js +126 -0
- package/dist/release-pack.d.ts +96 -0
- package/dist/release-pack.js +346 -0
- package/dist/server-artifact.d.ts +140 -0
- package/dist/server-artifact.js +205 -0
- package/dist/server-language-backend.d.ts +89 -0
- package/dist/server-language-backend.js +121 -0
- package/dist/site-delivery.d.ts +134 -0
- package/dist/site-delivery.js +345 -0
- package/dist/static-emit.d.ts +145 -0
- package/dist/static-emit.js +577 -0
- package/package.json +13 -13
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Language DSL backends (lang on `<script server>`).
|
|
3
|
+
* Author surface is a VMZ DSL flavor — not full target-language source.
|
|
4
|
+
*/
|
|
5
|
+
/** @typedef {'ts' | 'rust' | 'python' | 'java'} ServerLangId */
|
|
6
|
+
export declare const SERVER_LANG_IDS: readonly string[];
|
|
7
|
+
/** @type {Record<string, ServerLangId>} */
|
|
8
|
+
export declare const SERVER_LANG_ALIASES: Readonly<{
|
|
9
|
+
ts: "ts";
|
|
10
|
+
typescript: "ts";
|
|
11
|
+
rust: "rust";
|
|
12
|
+
python: "python";
|
|
13
|
+
java: "java";
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* langId: ServerLangId,
|
|
18
|
+
* aliases: string[],
|
|
19
|
+
* compatibleRuntimes: string[],
|
|
20
|
+
* implemented: boolean,
|
|
21
|
+
* artifactRoot: string,
|
|
22
|
+
* }} ServerLanguageBackendMeta
|
|
23
|
+
*/
|
|
24
|
+
/** @type {Record<ServerLangId, ServerLanguageBackendMeta>} */
|
|
25
|
+
export declare const SERVER_LANGUAGE_BACKENDS: Readonly<{
|
|
26
|
+
ts: {
|
|
27
|
+
langId: string;
|
|
28
|
+
aliases: string[];
|
|
29
|
+
compatibleRuntimes: string[];
|
|
30
|
+
implemented: boolean;
|
|
31
|
+
artifactRoot: string;
|
|
32
|
+
};
|
|
33
|
+
rust: {
|
|
34
|
+
langId: string;
|
|
35
|
+
aliases: string[];
|
|
36
|
+
compatibleRuntimes: string[];
|
|
37
|
+
implemented: boolean;
|
|
38
|
+
artifactRoot: string;
|
|
39
|
+
};
|
|
40
|
+
python: {
|
|
41
|
+
langId: string;
|
|
42
|
+
aliases: string[];
|
|
43
|
+
compatibleRuntimes: string[];
|
|
44
|
+
implemented: boolean;
|
|
45
|
+
artifactRoot: string;
|
|
46
|
+
};
|
|
47
|
+
java: {
|
|
48
|
+
langId: string;
|
|
49
|
+
aliases: string[];
|
|
50
|
+
compatibleRuntimes: string[];
|
|
51
|
+
implemented: boolean;
|
|
52
|
+
artifactRoot: string;
|
|
53
|
+
};
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve author `lang` attr (or null/undefined for default TS).
|
|
57
|
+
* @param {string | null | undefined} raw
|
|
58
|
+
* @returns {{
|
|
59
|
+
* ok: true, langId: ServerLangId, backend: ServerLanguageBackendMeta
|
|
60
|
+
* } | {
|
|
61
|
+
* ok: false, code: string, message: string
|
|
62
|
+
* }}
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveServerLanguage(raw: any): {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
code: string;
|
|
67
|
+
message: string;
|
|
68
|
+
langId?: undefined;
|
|
69
|
+
backend?: undefined;
|
|
70
|
+
} | {
|
|
71
|
+
ok: boolean;
|
|
72
|
+
langId: any;
|
|
73
|
+
backend: any;
|
|
74
|
+
code?: undefined;
|
|
75
|
+
message?: undefined;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* @param {ServerLangId} langId
|
|
79
|
+
* @param {string | null | undefined} serverRuntime
|
|
80
|
+
*/
|
|
81
|
+
export declare function assertLangRuntimePair(langId: any, serverRuntime: any): {
|
|
82
|
+
ok: boolean;
|
|
83
|
+
code: string;
|
|
84
|
+
message: string;
|
|
85
|
+
} | {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
code?: undefined;
|
|
88
|
+
message?: undefined;
|
|
89
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Language DSL backends (lang on `<script server>`).
|
|
3
|
+
* Author surface is a VMZ DSL flavor — not full target-language source.
|
|
4
|
+
*/
|
|
5
|
+
// @ts-nocheck
|
|
6
|
+
/** @typedef {'ts' | 'rust' | 'python' | 'java'} ServerLangId */
|
|
7
|
+
export const SERVER_LANG_IDS = Object.freeze(['ts', 'rust', 'python', 'java']);
|
|
8
|
+
/** @type {Record<string, ServerLangId>} */
|
|
9
|
+
export const SERVER_LANG_ALIASES = Object.freeze({
|
|
10
|
+
ts: 'ts',
|
|
11
|
+
typescript: 'ts',
|
|
12
|
+
rust: 'rust',
|
|
13
|
+
python: 'python',
|
|
14
|
+
java: 'java',
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* langId: ServerLangId,
|
|
19
|
+
* aliases: string[],
|
|
20
|
+
* compatibleRuntimes: string[],
|
|
21
|
+
* implemented: boolean,
|
|
22
|
+
* artifactRoot: string,
|
|
23
|
+
* }} ServerLanguageBackendMeta
|
|
24
|
+
*/
|
|
25
|
+
/** @type {Record<ServerLangId, ServerLanguageBackendMeta>} */
|
|
26
|
+
export const SERVER_LANGUAGE_BACKENDS = Object.freeze({
|
|
27
|
+
ts: {
|
|
28
|
+
langId: 'ts',
|
|
29
|
+
aliases: ['ts', 'typescript'],
|
|
30
|
+
compatibleRuntimes: ['node', 'worker', 'deno', 'bun'],
|
|
31
|
+
implemented: true,
|
|
32
|
+
artifactRoot: 'dist/#server',
|
|
33
|
+
},
|
|
34
|
+
rust: {
|
|
35
|
+
langId: 'rust',
|
|
36
|
+
aliases: ['rust'],
|
|
37
|
+
compatibleRuntimes: ['rust-host'],
|
|
38
|
+
implemented: true,
|
|
39
|
+
artifactRoot: 'target/vmz/server-rust',
|
|
40
|
+
},
|
|
41
|
+
python: {
|
|
42
|
+
langId: 'python',
|
|
43
|
+
aliases: ['python'],
|
|
44
|
+
compatibleRuntimes: ['python-host'],
|
|
45
|
+
implemented: false,
|
|
46
|
+
artifactRoot: 'target/vmz/server-python',
|
|
47
|
+
},
|
|
48
|
+
java: {
|
|
49
|
+
langId: 'java',
|
|
50
|
+
aliases: ['java'],
|
|
51
|
+
compatibleRuntimes: ['jvm-host'],
|
|
52
|
+
implemented: false,
|
|
53
|
+
artifactRoot: 'target/vmz/server-java',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* Resolve author `lang` attr (or null/undefined for default TS).
|
|
58
|
+
* @param {string | null | undefined} raw
|
|
59
|
+
* @returns {{
|
|
60
|
+
* ok: true, langId: ServerLangId, backend: ServerLanguageBackendMeta
|
|
61
|
+
* } | {
|
|
62
|
+
* ok: false, code: string, message: string
|
|
63
|
+
* }}
|
|
64
|
+
*/
|
|
65
|
+
export function resolveServerLanguage(raw) {
|
|
66
|
+
const trimmed = raw == null ? '' : String(raw).trim();
|
|
67
|
+
if (!trimmed) {
|
|
68
|
+
return { ok: true, langId: 'ts', backend: SERVER_LANGUAGE_BACKENDS.ts };
|
|
69
|
+
}
|
|
70
|
+
const langId = SERVER_LANG_ALIASES[trimmed];
|
|
71
|
+
if (!langId) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
code: 'vmz::server::unknown_language',
|
|
75
|
+
message: `unknown script language \`${trimmed}\`; use ts|typescript|rust|python|java`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const backend = SERVER_LANGUAGE_BACKENDS[langId];
|
|
79
|
+
if (!backend.implemented) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
code: 'vmz::server::language_backend_unimplemented',
|
|
83
|
+
message: `\`<script server lang="${langId}">\` is registered but not implemented yet`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, langId, backend };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @param {ServerLangId} langId
|
|
90
|
+
* @param {string | null | undefined} serverRuntime
|
|
91
|
+
*/
|
|
92
|
+
export function assertLangRuntimePair(langId, serverRuntime) {
|
|
93
|
+
const backend = SERVER_LANGUAGE_BACKENDS[langId];
|
|
94
|
+
if (!backend) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code: 'vmz::server::unknown_language',
|
|
98
|
+
message: `unknown langId ${langId}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (langId === 'ts') {
|
|
102
|
+
// TS stays compatible with existing node/worker defaults; rust-host rejects ts.
|
|
103
|
+
if (serverRuntime === 'rust-host' || serverRuntime === 'python-host' || serverRuntime === 'jvm-host') {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
code: 'vmz::server::lang_runtime_mismatch',
|
|
107
|
+
message: `lang=ts is incompatible with serverRuntime=${serverRuntime}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
const rt = String(serverRuntime || '').trim();
|
|
113
|
+
if (!backend.compatibleRuntimes.includes(rt)) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: 'vmz::server::lang_runtime_mismatch',
|
|
117
|
+
message: `lang=${langId} requires serverRuntime in [${backend.compatibleRuntimes.join('|')}] (got ${rt || '(none)'})`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { ok: true };
|
|
121
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3-site: SiteDeliveryContract — embedded | filesystem | remote selection + release fallback.
|
|
3
|
+
* Authoring via defineConfig({ delivery }) / defineSite(...); pure data only.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SITE_DELIVERY_CONTRACT_SCHEMA = "vmz.site.delivery_contract.v0";
|
|
6
|
+
export declare const SITE_DELIVERY_RESOLUTION_SCHEMA = "vmz.site.delivery_resolution.v0";
|
|
7
|
+
/**
|
|
8
|
+
* Pure-data helper for `defineConfig({ delivery: defineSite(...) })`.
|
|
9
|
+
* Not a second config entry — CLI never auto-discovers `vmz.site.ts`.
|
|
10
|
+
* @param {Record<string, unknown>} delivery
|
|
11
|
+
*/
|
|
12
|
+
export declare function defineSite(delivery: any): any;
|
|
13
|
+
/**
|
|
14
|
+
* Normalize authoring delivery → frozen SiteDeliveryContract.
|
|
15
|
+
* @param {unknown} raw
|
|
16
|
+
* @param {{ siteId?: string, projectRoot?: string }} [opts]
|
|
17
|
+
* @returns {{ ok: true, contract: Record<string, any> } | { ok: false, diagnostics: Array<{ code: string, message: string }> }}
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeSiteDelivery(raw: any, opts?: {}): {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
diagnostics: any[];
|
|
22
|
+
contract?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
contract: {
|
|
26
|
+
schema: string;
|
|
27
|
+
schemaVersion: string;
|
|
28
|
+
siteId: any;
|
|
29
|
+
artifact: string;
|
|
30
|
+
expectedCompatibility: any;
|
|
31
|
+
sources: any[];
|
|
32
|
+
resolutionPolicy: {
|
|
33
|
+
mode: string;
|
|
34
|
+
fallback: any;
|
|
35
|
+
fileLevelMix: boolean;
|
|
36
|
+
};
|
|
37
|
+
failurePolicy: any;
|
|
38
|
+
updatePolicy: any;
|
|
39
|
+
rollbackPolicy: any;
|
|
40
|
+
securityPolicy: any;
|
|
41
|
+
activation: string;
|
|
42
|
+
};
|
|
43
|
+
diagnostics?: undefined;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Probe one physical source for release-level readiness (not per-URL).
|
|
47
|
+
* @param {{
|
|
48
|
+
* available?: boolean,
|
|
49
|
+
* artifactDigest?: string | null,
|
|
50
|
+
* integrityOk?: boolean,
|
|
51
|
+
* signatureOk?: boolean,
|
|
52
|
+
* objectClosureOk?: boolean,
|
|
53
|
+
* mixedDigestObjects?: boolean,
|
|
54
|
+
* error?: string | null,
|
|
55
|
+
* }} [probe]
|
|
56
|
+
*/
|
|
57
|
+
export declare function normalizeSourceProbe(probe?: {}): {
|
|
58
|
+
available: boolean;
|
|
59
|
+
artifactDigest: any;
|
|
60
|
+
integrityOk: boolean;
|
|
61
|
+
signatureOk: boolean;
|
|
62
|
+
objectClosureOk: boolean;
|
|
63
|
+
mixedDigestObjects: boolean;
|
|
64
|
+
error: any;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Resolve one complete release per SiteDeliveryContract (no file-level mix).
|
|
68
|
+
* @param {Record<string, any>} contract
|
|
69
|
+
* @param {Record<string, ReturnType<typeof normalizeSourceProbe>>} probes by sourceId
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveSiteRelease(contract: any, probes?: {}): {
|
|
72
|
+
schema: string;
|
|
73
|
+
status: string;
|
|
74
|
+
selectedSourceId: any;
|
|
75
|
+
selectedKind: any;
|
|
76
|
+
selectedDigest: any;
|
|
77
|
+
fallbackReason: string;
|
|
78
|
+
attempted: any[];
|
|
79
|
+
fileLevelMix: boolean;
|
|
80
|
+
activation: string;
|
|
81
|
+
contractDigest: any;
|
|
82
|
+
} | {
|
|
83
|
+
schema: string;
|
|
84
|
+
status: string;
|
|
85
|
+
selectedSourceId: any;
|
|
86
|
+
selectedKind: any;
|
|
87
|
+
selectedDigest: any;
|
|
88
|
+
fallbackReason: string;
|
|
89
|
+
attempted: any[];
|
|
90
|
+
fileLevelMix: boolean;
|
|
91
|
+
activation: string;
|
|
92
|
+
contractDigest: any;
|
|
93
|
+
resolutionDigest: any;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Read a packed release directory probe (expects _vmz/release-envelope.json + object closure).
|
|
97
|
+
* @param {string} releaseDir absolute path to a release snapshot (contains dist/ or is dist/)
|
|
98
|
+
*/
|
|
99
|
+
export declare function probeReleaseDirectory(releaseDir: any): {
|
|
100
|
+
available: boolean;
|
|
101
|
+
artifactDigest: any;
|
|
102
|
+
integrityOk: boolean;
|
|
103
|
+
signatureOk: boolean;
|
|
104
|
+
objectClosureOk: boolean;
|
|
105
|
+
mixedDigestObjects: boolean;
|
|
106
|
+
error: any;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Emit normalized contract (+ optional resolution) under dist/_vmz.
|
|
110
|
+
* @param {string} outDir
|
|
111
|
+
* @param {unknown} deliveryRaw
|
|
112
|
+
* @param {{ siteId?: string, probes?: Record<string, any> }} [opts]
|
|
113
|
+
*/
|
|
114
|
+
export declare function emitSiteDelivery(outDir: any, deliveryRaw: any, opts?: {}): {
|
|
115
|
+
contract: {
|
|
116
|
+
schema: string;
|
|
117
|
+
schemaVersion: string;
|
|
118
|
+
siteId: any;
|
|
119
|
+
artifact: string;
|
|
120
|
+
expectedCompatibility: any;
|
|
121
|
+
sources: any[];
|
|
122
|
+
resolutionPolicy: {
|
|
123
|
+
mode: string;
|
|
124
|
+
fallback: any;
|
|
125
|
+
fileLevelMix: boolean;
|
|
126
|
+
};
|
|
127
|
+
failurePolicy: any;
|
|
128
|
+
updatePolicy: any;
|
|
129
|
+
rollbackPolicy: any;
|
|
130
|
+
securityPolicy: any;
|
|
131
|
+
activation: string;
|
|
132
|
+
};
|
|
133
|
+
resolution: any;
|
|
134
|
+
};
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3-site: SiteDeliveryContract — embedded | filesystem | remote selection + release fallback.
|
|
3
|
+
* Authoring via defineConfig({ delivery }) / defineSite(...); pure data only.
|
|
4
|
+
*/
|
|
5
|
+
// @ts-nocheck
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
export const SITE_DELIVERY_CONTRACT_SCHEMA = 'vmz.site.delivery_contract.v0';
|
|
10
|
+
export const SITE_DELIVERY_RESOLUTION_SCHEMA = 'vmz.site.delivery_resolution.v0';
|
|
11
|
+
/**
|
|
12
|
+
* Pure-data helper for `defineConfig({ delivery: defineSite(...) })`.
|
|
13
|
+
* Not a second config entry — CLI never auto-discovers `vmz.site.ts`.
|
|
14
|
+
* @param {Record<string, unknown>} delivery
|
|
15
|
+
*/
|
|
16
|
+
export function defineSite(delivery) {
|
|
17
|
+
return delivery;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Normalize authoring delivery → frozen SiteDeliveryContract.
|
|
21
|
+
* @param {unknown} raw
|
|
22
|
+
* @param {{ siteId?: string, projectRoot?: string }} [opts]
|
|
23
|
+
* @returns {{ ok: true, contract: Record<string, any> } | { ok: false, diagnostics: Array<{ code: string, message: string }> }}
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeSiteDelivery(raw, opts = {}) {
|
|
26
|
+
/** @type {Array<{ code: string, message: string }>} */
|
|
27
|
+
const diagnostics = [];
|
|
28
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
diagnostics: [{ code: 'site.delivery.invalid', message: 'delivery must be a plain object' }],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const d = /** @type {Record<string, any>} */ (raw);
|
|
35
|
+
if (typeof d.artifact !== 'string' || !d.artifact.trim()) {
|
|
36
|
+
diagnostics.push({ code: 'site.delivery.artifact', message: 'delivery.artifact is required' });
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(d.sources) || d.sources.length < 1) {
|
|
39
|
+
diagnostics.push({ code: 'site.delivery.sources', message: 'delivery.sources must be a non-empty array' });
|
|
40
|
+
}
|
|
41
|
+
const sources = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const [i, s] of (d.sources || []).entries()) {
|
|
44
|
+
if (!s || typeof s !== 'object') {
|
|
45
|
+
diagnostics.push({ code: 'site.delivery.source', message: `sources[${i}] must be an object` });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const id = String(s.id || '').trim();
|
|
49
|
+
const kind = String(s.kind || '').trim();
|
|
50
|
+
if (!id) {
|
|
51
|
+
diagnostics.push({ code: 'site.delivery.sourceId', message: `sources[${i}].id is required` });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (seen.has(id)) {
|
|
55
|
+
diagnostics.push({ code: 'site.delivery.sourceId.dup', message: `duplicate source id ${id}` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
seen.add(id);
|
|
59
|
+
if (!['embedded', 'filesystem', 'remote'].includes(kind)) {
|
|
60
|
+
diagnostics.push({
|
|
61
|
+
code: 'site.delivery.kind',
|
|
62
|
+
message: `sources[${i}].kind must be embedded|filesystem|remote`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (typeof s.directory === 'function' || typeof s.baseUrl === 'function') {
|
|
67
|
+
diagnostics.push({
|
|
68
|
+
code: 'site.delivery.executable',
|
|
69
|
+
message: `sources[${i}] must be pure data (no functions)`,
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (kind === 'filesystem' && typeof s.directory !== 'string') {
|
|
74
|
+
diagnostics.push({
|
|
75
|
+
code: 'site.delivery.filesystem',
|
|
76
|
+
message: `sources[${i}] filesystem requires directory string`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (kind === 'remote' && typeof s.baseUrl !== 'string') {
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
code: 'site.delivery.remote',
|
|
82
|
+
message: `sources[${i}] remote requires baseUrl string`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (kind === 'embedded' && s.artifact == null && d.artifact == null) {
|
|
86
|
+
diagnostics.push({
|
|
87
|
+
code: 'site.delivery.embedded',
|
|
88
|
+
message: `sources[${i}] embedded requires artifact id`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
sources.push({
|
|
92
|
+
sourceId: id,
|
|
93
|
+
kind,
|
|
94
|
+
priority: typeof s.priority === 'number' ? s.priority : i,
|
|
95
|
+
trust: s.trust || 'signed-release',
|
|
96
|
+
directory: kind === 'filesystem' ? String(s.directory) : null,
|
|
97
|
+
baseUrl: kind === 'remote' ? String(s.baseUrl).replace(/\/$/, '') : null,
|
|
98
|
+
timeoutMs: kind === 'remote' ? Number(s.timeoutMs || 1500) : null,
|
|
99
|
+
artifact: s.artifact != null ? String(s.artifact) : kind === 'embedded' ? String(d.artifact) : null,
|
|
100
|
+
integrity: s.integrity || null,
|
|
101
|
+
signaturePolicy: s.signaturePolicy || s.trust || 'signed-release',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const resolution = d.resolution && typeof d.resolution === 'object' ? d.resolution : {};
|
|
105
|
+
const mode = String(resolution.mode || 'release');
|
|
106
|
+
if (mode !== 'release') {
|
|
107
|
+
diagnostics.push({
|
|
108
|
+
code: 'site.delivery.resolution.mode',
|
|
109
|
+
message: `resolution.mode must be 'release' (got ${mode})`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let fallback = Array.isArray(resolution.fallback) ? resolution.fallback.map(String) : sources.map((s) => s.sourceId);
|
|
113
|
+
for (const id of fallback) {
|
|
114
|
+
if (!seen.has(id)) {
|
|
115
|
+
diagnostics.push({
|
|
116
|
+
code: 'site.delivery.fallback.unknown',
|
|
117
|
+
message: `resolution.fallback references unknown source ${id}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Preserve author order — do not reorder by discovery/speed.
|
|
122
|
+
const activation = String(d.activation || 'atomic');
|
|
123
|
+
if (activation !== 'atomic') {
|
|
124
|
+
diagnostics.push({
|
|
125
|
+
code: 'site.delivery.activation',
|
|
126
|
+
message: `activation must be 'atomic' in v0`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (diagnostics.length)
|
|
130
|
+
return { ok: false, diagnostics };
|
|
131
|
+
const contractBody = {
|
|
132
|
+
schema: SITE_DELIVERY_CONTRACT_SCHEMA,
|
|
133
|
+
schemaVersion: '0',
|
|
134
|
+
siteId: opts.siteId || d.siteId || d.artifact,
|
|
135
|
+
artifact: String(d.artifact),
|
|
136
|
+
expectedCompatibility: d.expectedCompatibility || {
|
|
137
|
+
runtime: 'vmz',
|
|
138
|
+
deliveryProfiles: ['web-static', 'filesystem'],
|
|
139
|
+
},
|
|
140
|
+
sources,
|
|
141
|
+
resolutionPolicy: {
|
|
142
|
+
mode: 'release',
|
|
143
|
+
fallback,
|
|
144
|
+
fileLevelMix: false,
|
|
145
|
+
},
|
|
146
|
+
failurePolicy: d.failure || d.failurePolicy || { onAllSourcesFailed: 'embedded-or-fail' },
|
|
147
|
+
updatePolicy: d.update || d.updatePolicy || { backgroundRemote: true },
|
|
148
|
+
rollbackPolicy: d.rollback || d.rollbackPolicy || { retainPrevious: true },
|
|
149
|
+
securityPolicy: d.security || d.securityPolicy || { requireSignature: true, forbidSecretsInContract: true },
|
|
150
|
+
activation: 'atomic',
|
|
151
|
+
};
|
|
152
|
+
contractBody.contractDigest = sha256Hex(canonicalJson(contractBody));
|
|
153
|
+
return { ok: true, contract: contractBody };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Probe one physical source for release-level readiness (not per-URL).
|
|
157
|
+
* @param {{
|
|
158
|
+
* available?: boolean,
|
|
159
|
+
* artifactDigest?: string | null,
|
|
160
|
+
* integrityOk?: boolean,
|
|
161
|
+
* signatureOk?: boolean,
|
|
162
|
+
* objectClosureOk?: boolean,
|
|
163
|
+
* mixedDigestObjects?: boolean,
|
|
164
|
+
* error?: string | null,
|
|
165
|
+
* }} [probe]
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeSourceProbe(probe = {}) {
|
|
168
|
+
return {
|
|
169
|
+
available: probe.available !== false,
|
|
170
|
+
artifactDigest: probe.artifactDigest ?? null,
|
|
171
|
+
integrityOk: probe.integrityOk !== false,
|
|
172
|
+
signatureOk: probe.signatureOk !== false,
|
|
173
|
+
objectClosureOk: probe.objectClosureOk !== false,
|
|
174
|
+
mixedDigestObjects: Boolean(probe.mixedDigestObjects),
|
|
175
|
+
error: probe.error || null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve one complete release per SiteDeliveryContract (no file-level mix).
|
|
180
|
+
* @param {Record<string, any>} contract
|
|
181
|
+
* @param {Record<string, ReturnType<typeof normalizeSourceProbe>>} probes by sourceId
|
|
182
|
+
*/
|
|
183
|
+
export function resolveSiteRelease(contract, probes = {}) {
|
|
184
|
+
const attempted = [];
|
|
185
|
+
const fallback = contract.resolutionPolicy?.fallback || contract.sources.map((s) => s.sourceId);
|
|
186
|
+
for (const sourceId of fallback) {
|
|
187
|
+
const source = (contract.sources || []).find((s) => s.sourceId === sourceId);
|
|
188
|
+
if (!source) {
|
|
189
|
+
attempted.push({ sourceId, result: 'skip', reason: 'unknown-source' });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const probe = normalizeSourceProbe(probes[sourceId]);
|
|
193
|
+
if (!probe.available) {
|
|
194
|
+
attempted.push({ sourceId, result: 'reject', reason: probe.error || 'unavailable' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (probe.mixedDigestObjects) {
|
|
198
|
+
attempted.push({
|
|
199
|
+
sourceId,
|
|
200
|
+
result: 'reject',
|
|
201
|
+
reason: 'file-level-mix-forbidden',
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!probe.objectClosureOk) {
|
|
206
|
+
attempted.push({ sourceId, result: 'reject', reason: 'object-closure-incomplete' });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (contract.securityPolicy?.requireSignature !== false && !probe.signatureOk) {
|
|
210
|
+
attempted.push({ sourceId, result: 'reject', reason: 'signature-failed' });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!probe.integrityOk) {
|
|
214
|
+
attempted.push({ sourceId, result: 'reject', reason: 'integrity-failed' });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!probe.artifactDigest) {
|
|
218
|
+
attempted.push({ sourceId, result: 'reject', reason: 'missing-artifact-digest' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
attempted.push({ sourceId, result: 'accept', reason: null });
|
|
222
|
+
const resolution = {
|
|
223
|
+
schema: SITE_DELIVERY_RESOLUTION_SCHEMA,
|
|
224
|
+
status: 'activated',
|
|
225
|
+
selectedSourceId: sourceId,
|
|
226
|
+
selectedKind: source.kind,
|
|
227
|
+
selectedDigest: probe.artifactDigest,
|
|
228
|
+
fallbackReason: attempted.some((a) => a.result === 'reject')
|
|
229
|
+
? attempted
|
|
230
|
+
.filter((a) => a.result === 'reject')
|
|
231
|
+
.map((a) => `${a.sourceId}:${a.reason}`)
|
|
232
|
+
.join(';')
|
|
233
|
+
: null,
|
|
234
|
+
attempted,
|
|
235
|
+
fileLevelMix: false,
|
|
236
|
+
activation: 'atomic',
|
|
237
|
+
contractDigest: contract.contractDigest,
|
|
238
|
+
};
|
|
239
|
+
resolution.resolutionDigest = sha256Hex(canonicalJson(resolution));
|
|
240
|
+
return resolution;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
schema: SITE_DELIVERY_RESOLUTION_SCHEMA,
|
|
244
|
+
status: 'failed',
|
|
245
|
+
selectedSourceId: null,
|
|
246
|
+
selectedKind: null,
|
|
247
|
+
selectedDigest: null,
|
|
248
|
+
fallbackReason: 'all-sources-rejected',
|
|
249
|
+
attempted,
|
|
250
|
+
fileLevelMix: false,
|
|
251
|
+
activation: 'atomic',
|
|
252
|
+
contractDigest: contract.contractDigest,
|
|
253
|
+
resolutionDigest: null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Read a packed release directory probe (expects _vmz/release-envelope.json + object closure).
|
|
258
|
+
* @param {string} releaseDir absolute path to a release snapshot (contains dist/ or is dist/)
|
|
259
|
+
*/
|
|
260
|
+
export function probeReleaseDirectory(releaseDir) {
|
|
261
|
+
const root = path.resolve(releaseDir);
|
|
262
|
+
const dist = fs.existsSync(path.join(root, 'dist')) ? path.join(root, 'dist') : root;
|
|
263
|
+
const envelopePath = path.join(dist, '_vmz', 'release-envelope.json');
|
|
264
|
+
if (!fs.existsSync(envelopePath)) {
|
|
265
|
+
return normalizeSourceProbe({
|
|
266
|
+
available: false,
|
|
267
|
+
error: 'missing-release-envelope',
|
|
268
|
+
objectClosureOk: false,
|
|
269
|
+
signatureOk: false,
|
|
270
|
+
integrityOk: false,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
let envelope;
|
|
274
|
+
try {
|
|
275
|
+
envelope = JSON.parse(fs.readFileSync(envelopePath, 'utf8'));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return normalizeSourceProbe({
|
|
279
|
+
available: false,
|
|
280
|
+
error: 'invalid-release-envelope',
|
|
281
|
+
objectClosureOk: false,
|
|
282
|
+
signatureOk: false,
|
|
283
|
+
integrityOk: false,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const digest = envelope.artifactDigest || null;
|
|
287
|
+
const required = ['index.html', '_vmz/release-envelope.json'];
|
|
288
|
+
let closureOk = true;
|
|
289
|
+
for (const rel of required) {
|
|
290
|
+
if (!fs.existsSync(path.join(dist, rel))) {
|
|
291
|
+
closureOk = false;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Detect naive file-level mix: CURRENT HTML digest stamp mismatch with envelope (test hook).
|
|
296
|
+
const mixMarker = path.join(dist, '_vmz', 'MIXED_DIGEST');
|
|
297
|
+
const mixed = fs.existsSync(mixMarker);
|
|
298
|
+
return normalizeSourceProbe({
|
|
299
|
+
available: true,
|
|
300
|
+
artifactDigest: digest,
|
|
301
|
+
integrityOk: Boolean(digest) && !mixed,
|
|
302
|
+
signatureOk: envelope.signatureOk !== false,
|
|
303
|
+
objectClosureOk: closureOk && !mixed,
|
|
304
|
+
mixedDigestObjects: mixed,
|
|
305
|
+
error: mixed ? 'mixed-digest' : null,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Emit normalized contract (+ optional resolution) under dist/_vmz.
|
|
310
|
+
* @param {string} outDir
|
|
311
|
+
* @param {unknown} deliveryRaw
|
|
312
|
+
* @param {{ siteId?: string, probes?: Record<string, any> }} [opts]
|
|
313
|
+
*/
|
|
314
|
+
export function emitSiteDelivery(outDir, deliveryRaw, opts = {}) {
|
|
315
|
+
const norm = normalizeSiteDelivery(deliveryRaw, { siteId: opts.siteId });
|
|
316
|
+
if (!norm.ok) {
|
|
317
|
+
throw new Error(`emitSiteDelivery: ${norm.diagnostics.map((d) => d.message).join('; ')}`);
|
|
318
|
+
}
|
|
319
|
+
const vmzDir = path.join(outDir, '_vmz');
|
|
320
|
+
fs.mkdirSync(vmzDir, { recursive: true });
|
|
321
|
+
fs.writeFileSync(path.join(vmzDir, 'site-delivery-contract.json'), `${JSON.stringify(norm.contract, null, 2)}\n`, 'utf8');
|
|
322
|
+
let resolution = null;
|
|
323
|
+
if (opts.probes) {
|
|
324
|
+
resolution = resolveSiteRelease(norm.contract, opts.probes);
|
|
325
|
+
fs.writeFileSync(path.join(vmzDir, 'site-delivery-resolution.json'), `${JSON.stringify(resolution, null, 2)}\n`, 'utf8');
|
|
326
|
+
}
|
|
327
|
+
return { contract: norm.contract, resolution };
|
|
328
|
+
}
|
|
329
|
+
function sha256Hex(data) {
|
|
330
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
331
|
+
}
|
|
332
|
+
function canonicalJson(value) {
|
|
333
|
+
return JSON.stringify(sortKeys(value));
|
|
334
|
+
}
|
|
335
|
+
function sortKeys(value) {
|
|
336
|
+
if (Array.isArray(value))
|
|
337
|
+
return value.map(sortKeys);
|
|
338
|
+
if (value && typeof value === 'object') {
|
|
339
|
+
const out = {};
|
|
340
|
+
for (const k of Object.keys(value).sort())
|
|
341
|
+
out[k] = sortKeys(value[k]);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|