@vmz/vmz 0.0.2 → 0.0.4
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 +19 -13
- package/dist/application-cmd.d.ts +1 -2
- package/dist/application-cmd.js +9 -10
- package/dist/bundler-adapter.d.ts +2 -3
- package/dist/bundler-adapter.js +2 -3
- package/dist/cdn-policy.d.ts +178 -0
- package/dist/cdn-policy.js +344 -0
- package/dist/cli.d.ts +10 -2
- package/dist/cli.js +179 -16
- package/dist/content-addressed-assets.d.ts +69 -0
- package/dist/content-addressed-assets.js +206 -0
- package/dist/dev-session.d.ts +2 -2
- package/dist/dev-session.js +51 -16
- package/dist/document-build.js +1 -2
- package/dist/document-check.d.ts +1 -1
- package/dist/document-check.js +4 -4
- package/dist/document-cmd.d.ts +1 -2
- package/dist/document-cmd.js +2 -3
- package/dist/document-designs.js +31 -3
- package/dist/document-enrich.js +2 -3
- package/dist/document-evidence.d.ts +4 -4
- package/dist/document-evidence.js +20 -12
- package/dist/document-integrate.d.ts +0 -1
- package/dist/document-integrate.js +0 -1
- package/dist/document-interactive.d.ts +11 -11
- package/dist/document-interactive.js +12 -13
- package/dist/document-locale.d.ts +1 -1
- package/dist/document-locale.js +1 -1
- package/dist/document-markdown.d.ts +1 -2
- package/dist/document-markdown.js +13 -7
- package/dist/document-scan.d.ts +4 -4
- package/dist/document-scan.js +4 -4
- package/dist/document-schema.d.ts +28 -29
- package/dist/document-schema.js +28 -29
- package/dist/explain-cmd.js +3 -3
- package/dist/index.d.ts +349 -789
- package/dist/index.js +109 -85
- package/dist/invocation.d.ts +68 -0
- package/dist/invocation.js +169 -0
- package/dist/locale-check.d.ts +19 -3
- package/dist/locale-check.js +130 -6
- package/dist/locale-cmd.js +6 -7
- package/dist/locale-delivery.d.ts +38 -38
- package/dist/locale-delivery.js +39 -40
- package/dist/locale-router.d.ts +39 -40
- package/dist/locale-router.js +39 -40
- package/dist/locale-runtime.d.ts +39 -39
- package/dist/locale-runtime.js +44 -45
- package/dist/locale-schema.d.ts +1 -2
- package/dist/locale-schema.js +1 -2
- package/dist/locale-tooling.d.ts +13 -13
- package/dist/locale-tooling.js +14 -15
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/packages.d.ts +1 -2
- package/dist/packages.js +1 -2
- package/dist/plugin-host.d.ts +11 -3
- package/dist/plugin-host.js +21 -5
- 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 +158 -0
- package/dist/production-test-pack.js +452 -0
- package/dist/refactor-cmd.d.ts +1 -1
- package/dist/refactor-cmd.js +6 -6
- 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 +337 -0
- package/dist/resolve-native-cli.d.ts +14 -0
- package/dist/resolve-native-cli.js +84 -0
- package/dist/resolve.d.ts +1 -2
- package/dist/resolve.js +1 -2
- package/dist/site-delivery.d.ts +134 -0
- package/dist/site-delivery.js +345 -0
- package/dist/static-emit.d.ts +136 -0
- package/dist/static-emit.js +463 -0
- package/dist/test-cmd.d.ts +2 -2
- package/dist/test-cmd.js +37 -17
- package/dist/watch-diff.d.ts +1 -1
- package/dist/watch-diff.js +1 -1
- package/package.json +32 -17
package/README.md
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@vmz/vmz` (bin: `vmz`)
|
|
2
|
+
|
|
3
|
+
CLI / N-API toolchain host. Runtime mental entry is **`@vmz/core`**; optional official UI is **`@vmz/ui`**; plugins are
|
|
4
|
+
**`@vmz/plugin-*`**. Shelved umbrella name: `@vmz/vmz`.
|
|
2
5
|
|
|
3
6
|
When a project reaches the point where it has a browser surface, SSR, server work, documents, tests, and several
|
|
4
7
|
deployment concerns, the usual workflow becomes a chain of tools that each see a different version of the application. A
|
|
@@ -22,27 +25,30 @@ With VMZ, a normal workflow is expected to answer all of these from the same sou
|
|
|
22
25
|
- **Test:** what did a user interaction actually cause?
|
|
23
26
|
- **Explain:** why did the compiler make that decision?
|
|
24
27
|
|
|
25
|
-
Use it when you are adopting VMZ as the application model.
|
|
26
|
-
|
|
27
|
-
toolchains.
|
|
28
|
+
Use it when you are adopting VMZ as the application model. The CLI targets VMZ-native source and semantics; Vue, React,
|
|
29
|
+
and legacy VDOM ecosystems keep their own toolchains.
|
|
28
30
|
|
|
29
31
|
## One tool, several views of the same program
|
|
30
32
|
|
|
31
|
-
| Workflow
|
|
32
|
-
|
|
33
|
-
| Development | What changed, and which application regions are affected?
|
|
34
|
-
| Checking
|
|
35
|
-
| Building
|
|
36
|
-
| Testing
|
|
37
|
-
| Documents
|
|
33
|
+
| Workflow | The question it should answer |
|
|
34
|
+
|-------------|--------------------------------------------------------------------|
|
|
35
|
+
| Development | What changed, and which application regions are affected? |
|
|
36
|
+
| Checking | Which state, route, server, or lifetime boundary cannot be proven? |
|
|
37
|
+
| Building | What belongs in browser, SSR, resume, and server output? |
|
|
38
|
+
| Testing | Did the application behave correctly and avoid unrelated work? |
|
|
39
|
+
| Documents | Are project documents connected, localized, and deployable? |
|
|
38
40
|
|
|
39
41
|
### Designed for the npm world
|
|
40
42
|
|
|
41
|
-
VMZ does not ask users to abandon JavaScript packaging. Node remains the npm, plugin, development-server, and
|
|
43
|
+
VMZ does not ask users to abandon JavaScript packaging. Node remains the npm, plugin, development-server, and
|
|
44
|
+
orchestration host. A long-lived N-API bridge connects that ecosystem to Rust and oxc without reducing semantic analysis
|
|
45
|
+
to a sequence of tiny file transforms.
|
|
42
46
|
|
|
43
47
|
### More than pass or fail
|
|
44
48
|
|
|
45
|
-
The interesting future of the CLI is explanation. A useful compiler should expose the source span, graph edge, owner,
|
|
49
|
+
The interesting future of the CLI is explanation. A useful compiler should expose the source span, graph edge, owner,
|
|
50
|
+
deployment boundary, and fallback reason behind a decision. That is a better developer experience than adding more
|
|
51
|
+
colored output to an opaque build. 🧭
|
|
46
52
|
|
|
47
53
|
## License
|
|
48
54
|
|
package/dist/application-cmd.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
/**
|
|
3
|
-
* `vmz application` — Application Collection / Mount
|
|
4
|
-
* Design: `规划设计/vmz/22`
|
|
3
|
+
* `vmz application` — Application Collection / Mount .
|
|
5
4
|
*/
|
|
6
5
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
6
|
import path from 'node:path';
|
|
@@ -42,18 +41,18 @@ export async function cmdApplication(argv) {
|
|
|
42
41
|
return 1;
|
|
43
42
|
}
|
|
44
43
|
function printHelp() {
|
|
45
|
-
console.log(`vmz application — Application Collection / Mount
|
|
44
|
+
console.log(`vmz application — Application Collection / Mount
|
|
46
45
|
|
|
47
46
|
Usage:
|
|
48
47
|
vmz application check [host] Validate descriptors + applications.config.json5
|
|
49
48
|
vmz application list [host] List resolved ApplicationIds / collections / mounts
|
|
50
49
|
vmz application schemas Print frozen protocol catalog JSON
|
|
51
|
-
vmz application relocatable [pkg]
|
|
52
|
-
vmz application relocate <manifest.json>
|
|
53
|
-
vmz application artifacts [host]
|
|
54
|
-
vmz application isolation [host]
|
|
55
|
-
vmz application composition [host]
|
|
56
|
-
vmz application dev [host]
|
|
50
|
+
vmz application relocatable [pkg] ApplicationBase / non_relocatable_url proof
|
|
51
|
+
vmz application relocate <manifest.json> apply ApplicationBase to relocation manifest
|
|
52
|
+
vmz application artifacts [host] ApplicationArtifact + MountTable boundary
|
|
53
|
+
vmz application isolation [host] isolation namespaces + failure containment
|
|
54
|
+
vmz application composition [host] catalog consumption + cross-app Link hrefs
|
|
55
|
+
vmz application dev [host] sessions / affected / proxy / mounted tests / deploy
|
|
57
56
|
|
|
58
57
|
Options:
|
|
59
58
|
--json [file] Emit report JSON to stdout or file
|
|
@@ -125,7 +124,7 @@ function emitJson(args, json, printHuman) {
|
|
|
125
124
|
}
|
|
126
125
|
function cmdSchemas() {
|
|
127
126
|
const native = loadNative();
|
|
128
|
-
console.log(native.queryApplicationProtocolCatalog
|
|
127
|
+
console.log(native.queryApplicationProtocolCatalog);
|
|
129
128
|
return 0;
|
|
130
129
|
}
|
|
131
130
|
/**
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bundler adapter (
|
|
2
|
+
* Bundler adapter (session) — consumes Deployment IR; does not invent VMZ semantics.
|
|
3
3
|
*
|
|
4
|
-
* Design: `规划设计/vmz/14` — VPG/Deployment IR → bundler executes pack/minify/assets.
|
|
5
4
|
* Vite/Rolldown may call these helpers; they must not reverse the arrow.
|
|
6
5
|
*/
|
|
7
6
|
/**
|
|
@@ -54,7 +53,7 @@ export declare function createVitePluginVmzAdapter(options?: {}): {
|
|
|
54
53
|
configResolved(): void;
|
|
55
54
|
};
|
|
56
55
|
/**
|
|
57
|
-
* Thin Rolldown plugin factory (
|
|
56
|
+
* Thin Rolldown plugin factory (deployment) — same contract as Vite adapter: read Deployment IR only.
|
|
58
57
|
* @param {{ outDir?: string, root?: string }} [options]
|
|
59
58
|
*/
|
|
60
59
|
export declare function createRolldownPluginVmzAdapter(options?: {}): {
|
package/dist/bundler-adapter.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
/**
|
|
3
|
-
* Bundler adapter (
|
|
3
|
+
* Bundler adapter (session) — consumes Deployment IR; does not invent VMZ semantics.
|
|
4
4
|
*
|
|
5
|
-
* Design: `规划设计/vmz/14` — VPG/Deployment IR → bundler executes pack/minify/assets.
|
|
6
5
|
* Vite/Rolldown may call these helpers; they must not reverse the arrow.
|
|
7
6
|
*/
|
|
8
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -90,7 +89,7 @@ export function createVitePluginVmzAdapter(options = {}) {
|
|
|
90
89
|
};
|
|
91
90
|
}
|
|
92
91
|
/**
|
|
93
|
-
* Thin Rolldown plugin factory (
|
|
92
|
+
* Thin Rolldown plugin factory (deployment) — same contract as Vite adapter: read Deployment IR only.
|
|
94
93
|
* @param {{ outDir?: string, root?: string }} [options]
|
|
95
94
|
*/
|
|
96
95
|
export function createRolldownPluginVmzAdapter(options = {}) {
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
export declare const CDN_POLICY_MANIFEST_SCHEMA = "vmz.cdn.policy_manifest.v0";
|
|
6
|
+
export declare const CDN_ADAPTER_PROJECTION_SCHEMA = "vmz.cdn.adapter_projection.v0";
|
|
7
|
+
/** HTML: revalidate. Hashed/static assets: long immutable. */
|
|
8
|
+
export declare const CACHE_HTML = "public, max-age=0, must-revalidate";
|
|
9
|
+
export declare const CACHE_ASSET_IMMUTABLE = "public, max-age=31536000, immutable";
|
|
10
|
+
export declare const CACHE_META = "public, max-age=3600";
|
|
11
|
+
/**
|
|
12
|
+
* Build CDNPolicyManifest from StaticDeliveryManifest (+ optional redirects).
|
|
13
|
+
* @param {Record<string, any>} staticManifest
|
|
14
|
+
* @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildCdnPolicyManifest(staticManifest: any, opts?: {}): {
|
|
17
|
+
schema: string;
|
|
18
|
+
applicationId: any;
|
|
19
|
+
deliveryProfile: string;
|
|
20
|
+
origin: string;
|
|
21
|
+
spaFallback: boolean;
|
|
22
|
+
staticManifestDigest: any;
|
|
23
|
+
redirects: any[];
|
|
24
|
+
headers: ({
|
|
25
|
+
match: string;
|
|
26
|
+
headers: {
|
|
27
|
+
'cache-control': string;
|
|
28
|
+
'x-robots-tag'?: undefined;
|
|
29
|
+
};
|
|
30
|
+
} | {
|
|
31
|
+
match: string;
|
|
32
|
+
headers: {
|
|
33
|
+
'cache-control': string;
|
|
34
|
+
'x-robots-tag': string;
|
|
35
|
+
};
|
|
36
|
+
})[];
|
|
37
|
+
errorDocuments: any;
|
|
38
|
+
routes: any;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Write CDN policy + adapter projections under dist/_vmz.
|
|
42
|
+
* @param {string} distDir
|
|
43
|
+
* @param {Record<string, any>} staticManifest
|
|
44
|
+
* @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
|
|
45
|
+
*/
|
|
46
|
+
export declare function emitCdnPolicy(distDir: any, staticManifest: any, opts?: {}): {
|
|
47
|
+
policy: {
|
|
48
|
+
schema: string;
|
|
49
|
+
applicationId: any;
|
|
50
|
+
deliveryProfile: string;
|
|
51
|
+
origin: string;
|
|
52
|
+
spaFallback: boolean;
|
|
53
|
+
staticManifestDigest: any;
|
|
54
|
+
redirects: any[];
|
|
55
|
+
headers: ({
|
|
56
|
+
match: string;
|
|
57
|
+
headers: {
|
|
58
|
+
'cache-control': string;
|
|
59
|
+
'x-robots-tag'?: undefined;
|
|
60
|
+
};
|
|
61
|
+
} | {
|
|
62
|
+
match: string;
|
|
63
|
+
headers: {
|
|
64
|
+
'cache-control': string;
|
|
65
|
+
'x-robots-tag': string;
|
|
66
|
+
};
|
|
67
|
+
})[];
|
|
68
|
+
errorDocuments: any;
|
|
69
|
+
routes: any;
|
|
70
|
+
};
|
|
71
|
+
adapters: {
|
|
72
|
+
'local-static': {
|
|
73
|
+
schema: string;
|
|
74
|
+
adapterId: string;
|
|
75
|
+
policyDigest: any;
|
|
76
|
+
host: string;
|
|
77
|
+
spaFallback: boolean;
|
|
78
|
+
redirects: any;
|
|
79
|
+
headers: any;
|
|
80
|
+
errorDocuments: any;
|
|
81
|
+
files: {
|
|
82
|
+
_headers?: undefined;
|
|
83
|
+
_redirects?: undefined;
|
|
84
|
+
};
|
|
85
|
+
} | {
|
|
86
|
+
schema: string;
|
|
87
|
+
adapterId: string;
|
|
88
|
+
policyDigest: any;
|
|
89
|
+
host: string;
|
|
90
|
+
spaFallback: boolean;
|
|
91
|
+
redirects: any;
|
|
92
|
+
headers: any;
|
|
93
|
+
errorDocuments: any;
|
|
94
|
+
files: {
|
|
95
|
+
_headers: string;
|
|
96
|
+
_redirects: string;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
netlify: {
|
|
100
|
+
schema: string;
|
|
101
|
+
adapterId: string;
|
|
102
|
+
policyDigest: any;
|
|
103
|
+
host: string;
|
|
104
|
+
spaFallback: boolean;
|
|
105
|
+
redirects: any;
|
|
106
|
+
headers: any;
|
|
107
|
+
errorDocuments: any;
|
|
108
|
+
files: {
|
|
109
|
+
_headers?: undefined;
|
|
110
|
+
_redirects?: undefined;
|
|
111
|
+
};
|
|
112
|
+
} | {
|
|
113
|
+
schema: string;
|
|
114
|
+
adapterId: string;
|
|
115
|
+
policyDigest: any;
|
|
116
|
+
host: string;
|
|
117
|
+
spaFallback: boolean;
|
|
118
|
+
redirects: any;
|
|
119
|
+
headers: any;
|
|
120
|
+
errorDocuments: any;
|
|
121
|
+
files: {
|
|
122
|
+
_headers: string;
|
|
123
|
+
_redirects: string;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Project vendor-neutral policy to an adapter. Must preserve redirect targets and forbid SPA fallback.
|
|
130
|
+
* @param {Record<string, any>} policy
|
|
131
|
+
* @param {'local-static' | 'netlify'} adapterId
|
|
132
|
+
*/
|
|
133
|
+
export declare function projectCdnAdapter(policy: any, adapterId: any): {
|
|
134
|
+
schema: string;
|
|
135
|
+
adapterId: string;
|
|
136
|
+
policyDigest: any;
|
|
137
|
+
host: string;
|
|
138
|
+
spaFallback: boolean;
|
|
139
|
+
redirects: any;
|
|
140
|
+
headers: any;
|
|
141
|
+
errorDocuments: any;
|
|
142
|
+
files: {
|
|
143
|
+
_headers?: undefined;
|
|
144
|
+
_redirects?: undefined;
|
|
145
|
+
};
|
|
146
|
+
} | {
|
|
147
|
+
schema: string;
|
|
148
|
+
adapterId: string;
|
|
149
|
+
policyDigest: any;
|
|
150
|
+
host: string;
|
|
151
|
+
spaFallback: boolean;
|
|
152
|
+
redirects: any;
|
|
153
|
+
headers: any;
|
|
154
|
+
errorDocuments: any;
|
|
155
|
+
files: {
|
|
156
|
+
_headers: string;
|
|
157
|
+
_redirects: string;
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Local static host that applies CDNPolicyManifest (redirects, cache headers, 404 doc).
|
|
162
|
+
* @param {string} distDir
|
|
163
|
+
* @param {Record<string, any>} policy
|
|
164
|
+
* @param {{ host?: string, port?: number }} [opts]
|
|
165
|
+
* @returns {Promise<{ host: string, port: number, baseUrl: string, close: () => Promise<void> }>}
|
|
166
|
+
*/
|
|
167
|
+
export declare function listenLocalStaticHost(distDir: any, policy: any, opts?: {}): Promise<unknown>;
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} distDir
|
|
170
|
+
* @param {Record<string, any>} policy
|
|
171
|
+
*/
|
|
172
|
+
export declare function createLocalStaticHandler(distDir: any, policy: any): (req: any, res: any) => void;
|
|
173
|
+
/**
|
|
174
|
+
* Minimal glob matcher for CDN header rules (html / js|css|mjs / exact / prefix).
|
|
175
|
+
* @param {string} pattern
|
|
176
|
+
* @param {string} pathname
|
|
177
|
+
*/
|
|
178
|
+
export declare function matchGlob(pattern: any, pathname: any): any;
|
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
export const CDN_POLICY_MANIFEST_SCHEMA = 'vmz.cdn.policy_manifest.v0';
|
|
11
|
+
export const CDN_ADAPTER_PROJECTION_SCHEMA = 'vmz.cdn.adapter_projection.v0';
|
|
12
|
+
/** HTML: revalidate. Hashed/static assets: long immutable. */
|
|
13
|
+
export const CACHE_HTML = 'public, max-age=0, must-revalidate';
|
|
14
|
+
export const CACHE_ASSET_IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
15
|
+
export const CACHE_META = 'public, max-age=3600';
|
|
16
|
+
/**
|
|
17
|
+
* Build CDNPolicyManifest from StaticDeliveryManifest (+ optional redirects).
|
|
18
|
+
* @param {Record<string, any>} staticManifest
|
|
19
|
+
* @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
|
|
20
|
+
*/
|
|
21
|
+
export function buildCdnPolicyManifest(staticManifest, opts = {}) {
|
|
22
|
+
const origin = String(staticManifest.origin || '');
|
|
23
|
+
const redirects = [{ from: '/home', to: '/', status: 301, reason: 'canonical-alias' }, ...(opts.redirects || [])];
|
|
24
|
+
const headers = [
|
|
25
|
+
{ match: '**/*.html', headers: { 'cache-control': CACHE_HTML } },
|
|
26
|
+
{
|
|
27
|
+
match: '**/404.html',
|
|
28
|
+
headers: { 'cache-control': CACHE_HTML, 'x-robots-tag': 'noindex, nofollow' },
|
|
29
|
+
},
|
|
30
|
+
// Content-addressed immutable objects (A3 assets/<hash>).
|
|
31
|
+
{ match: '**/assets/**', headers: { 'cache-control': CACHE_ASSET_IMMUTABLE } },
|
|
32
|
+
{ match: '**/*.{js,css,mjs}', headers: { 'cache-control': CACHE_ASSET_IMMUTABLE } },
|
|
33
|
+
{ match: '**/sitemap.xml', headers: { 'cache-control': CACHE_META } },
|
|
34
|
+
{ match: '**/robots.txt', headers: { 'cache-control': CACHE_META } },
|
|
35
|
+
];
|
|
36
|
+
const errorDocuments = Array.isArray(staticManifest.errorDocuments) ? staticManifest.errorDocuments : [{ status: 404, path: '404.html' }];
|
|
37
|
+
const body = {
|
|
38
|
+
schema: CDN_POLICY_MANIFEST_SCHEMA,
|
|
39
|
+
applicationId: staticManifest.applicationId || null,
|
|
40
|
+
deliveryProfile: 'web-static',
|
|
41
|
+
origin,
|
|
42
|
+
spaFallback: false,
|
|
43
|
+
staticManifestDigest: staticManifest.manifestDigest || null,
|
|
44
|
+
redirects,
|
|
45
|
+
headers,
|
|
46
|
+
errorDocuments,
|
|
47
|
+
routes: (staticManifest.routes || []).map((r) => ({
|
|
48
|
+
routeId: r.routeId,
|
|
49
|
+
path: r.path,
|
|
50
|
+
htmlPath: r.htmlPath,
|
|
51
|
+
canonical: r.seo?.canonical || null,
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
body.policyDigest = sha256Hex(canonicalJson(body));
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Write CDN policy + adapter projections under dist/_vmz.
|
|
59
|
+
* @param {string} distDir
|
|
60
|
+
* @param {Record<string, any>} staticManifest
|
|
61
|
+
* @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
|
|
62
|
+
*/
|
|
63
|
+
export function emitCdnPolicy(distDir, staticManifest, opts = {}) {
|
|
64
|
+
const policy = buildCdnPolicyManifest(staticManifest, opts);
|
|
65
|
+
const vmzDir = path.join(distDir, '_vmz');
|
|
66
|
+
fs.mkdirSync(vmzDir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(vmzDir, 'cdn-policy-manifest.json'), `${JSON.stringify(policy, null, 2)}\n`, 'utf8');
|
|
68
|
+
const local = projectCdnAdapter(policy, 'local-static');
|
|
69
|
+
const netlify = projectCdnAdapter(policy, 'netlify');
|
|
70
|
+
const adaptersDir = path.join(vmzDir, 'adapters');
|
|
71
|
+
fs.mkdirSync(path.join(adaptersDir, 'local-static'), { recursive: true });
|
|
72
|
+
fs.mkdirSync(path.join(adaptersDir, 'netlify'), { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(adaptersDir, 'local-static', 'projection.json'), `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
74
|
+
fs.writeFileSync(path.join(adaptersDir, 'netlify', 'projection.json'), `${JSON.stringify(netlify, null, 2)}\n`, 'utf8');
|
|
75
|
+
fs.writeFileSync(path.join(adaptersDir, 'netlify', '_headers'), String(netlify.files['_headers'] || ''), 'utf8');
|
|
76
|
+
fs.writeFileSync(path.join(adaptersDir, 'netlify', '_redirects'), String(netlify.files['_redirects'] || ''), 'utf8');
|
|
77
|
+
return { policy, adapters: { 'local-static': local, netlify } };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Project vendor-neutral policy to an adapter. Must preserve redirect targets and forbid SPA fallback.
|
|
81
|
+
* @param {Record<string, any>} policy
|
|
82
|
+
* @param {'local-static' | 'netlify'} adapterId
|
|
83
|
+
*/
|
|
84
|
+
export function projectCdnAdapter(policy, adapterId) {
|
|
85
|
+
if (policy.spaFallback) {
|
|
86
|
+
throw new Error('projectCdnAdapter: spaFallback=true is forbidden');
|
|
87
|
+
}
|
|
88
|
+
if (adapterId === 'local-static') {
|
|
89
|
+
return {
|
|
90
|
+
schema: CDN_ADAPTER_PROJECTION_SCHEMA,
|
|
91
|
+
adapterId: 'local-static',
|
|
92
|
+
policyDigest: policy.policyDigest,
|
|
93
|
+
host: 'vmz-local-static',
|
|
94
|
+
spaFallback: false,
|
|
95
|
+
redirects: policy.redirects,
|
|
96
|
+
headers: policy.headers,
|
|
97
|
+
errorDocuments: policy.errorDocuments,
|
|
98
|
+
files: {},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (adapterId === 'netlify') {
|
|
102
|
+
const headerLines = [];
|
|
103
|
+
for (const rule of policy.headers || []) {
|
|
104
|
+
const glob = netlifyGlob(rule.match);
|
|
105
|
+
headerLines.push(glob);
|
|
106
|
+
for (const [k, v] of Object.entries(rule.headers || {})) {
|
|
107
|
+
headerLines.push(` ${headerCase(k)}: ${v}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const redirectLines = [];
|
|
111
|
+
for (const r of policy.redirects || []) {
|
|
112
|
+
redirectLines.push(`${r.from} ${r.to} ${Number(r.status) || 301}`);
|
|
113
|
+
}
|
|
114
|
+
// Explicit only — never `/* /index.html 200`
|
|
115
|
+
const redirectsText = redirectLines.join('\n') + (redirectLines.length ? '\n' : '');
|
|
116
|
+
if (/\*\s+\/index\.html/.test(redirectsText)) {
|
|
117
|
+
throw new Error('netlify adapter refused SPA fallback redirect');
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
schema: CDN_ADAPTER_PROJECTION_SCHEMA,
|
|
121
|
+
adapterId: 'netlify',
|
|
122
|
+
policyDigest: policy.policyDigest,
|
|
123
|
+
host: 'netlify',
|
|
124
|
+
spaFallback: false,
|
|
125
|
+
redirects: policy.redirects,
|
|
126
|
+
headers: policy.headers,
|
|
127
|
+
errorDocuments: policy.errorDocuments,
|
|
128
|
+
files: {
|
|
129
|
+
_headers: headerLines.join('\n') + '\n',
|
|
130
|
+
_redirects: redirectsText,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`unknown CDN adapter ${adapterId}`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Local static host that applies CDNPolicyManifest (redirects, cache headers, 404 doc).
|
|
138
|
+
* @param {string} distDir
|
|
139
|
+
* @param {Record<string, any>} policy
|
|
140
|
+
* @param {{ host?: string, port?: number }} [opts]
|
|
141
|
+
* @returns {Promise<{ host: string, port: number, baseUrl: string, close: () => Promise<void> }>}
|
|
142
|
+
*/
|
|
143
|
+
export function listenLocalStaticHost(distDir, policy, opts = {}) {
|
|
144
|
+
const host = opts.host || '127.0.0.1';
|
|
145
|
+
const port = Number(opts.port || 0);
|
|
146
|
+
const handler = createLocalStaticHandler(distDir, policy);
|
|
147
|
+
const server = http.createServer(handler);
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
server.listen(port, host, () => {
|
|
150
|
+
const addr = server.address();
|
|
151
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
152
|
+
resolve({
|
|
153
|
+
host,
|
|
154
|
+
port: actualPort,
|
|
155
|
+
baseUrl: `http://${host}:${actualPort}`,
|
|
156
|
+
close: () => new Promise((res, rej) => {
|
|
157
|
+
server.close((err) => (err ? rej(err) : res()));
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
server.on('error', reject);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} distDir
|
|
166
|
+
* @param {Record<string, any>} policy
|
|
167
|
+
*/
|
|
168
|
+
export function createLocalStaticHandler(distDir, policy) {
|
|
169
|
+
const root = path.resolve(distDir);
|
|
170
|
+
return (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
173
|
+
let pathname = decodeURIComponent(url.pathname || '/');
|
|
174
|
+
const redirect = matchRedirect(policy.redirects || [], pathname);
|
|
175
|
+
if (redirect) {
|
|
176
|
+
const status = Number(redirect.status) || 301;
|
|
177
|
+
const headers = applyHeaderRules(policy.headers || [], pathname, {
|
|
178
|
+
Location: redirect.to,
|
|
179
|
+
});
|
|
180
|
+
res.writeHead(status, headers);
|
|
181
|
+
res.end();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
let rel = pathname;
|
|
185
|
+
if (rel.endsWith('/'))
|
|
186
|
+
rel += 'index.html';
|
|
187
|
+
if (rel === '/')
|
|
188
|
+
rel = '/index.html';
|
|
189
|
+
const file = safeJoin(root, rel.replace(/^\//, ''));
|
|
190
|
+
if (file && fs.existsSync(file) && fs.statSync(file).isFile()) {
|
|
191
|
+
const body = fs.readFileSync(file);
|
|
192
|
+
const type = contentType(file);
|
|
193
|
+
const headers = applyHeaderRules(policy.headers || [], rel, {
|
|
194
|
+
'content-type': type,
|
|
195
|
+
});
|
|
196
|
+
res.writeHead(200, headers);
|
|
197
|
+
res.end(body);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const errDoc = (policy.errorDocuments || []).find((e) => Number(e.status) === 404);
|
|
201
|
+
const errPath = errDoc?.path || '404.html';
|
|
202
|
+
const abs404 = path.join(root, errPath);
|
|
203
|
+
if (fs.existsSync(abs404)) {
|
|
204
|
+
const body = fs.readFileSync(abs404);
|
|
205
|
+
const headers = applyHeaderRules(policy.headers || [], `/${errPath}`, {
|
|
206
|
+
'content-type': 'text/html; charset=utf-8',
|
|
207
|
+
});
|
|
208
|
+
res.writeHead(404, headers);
|
|
209
|
+
res.end(body);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
213
|
+
res.end('not found');
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
217
|
+
res.end(err instanceof Error ? err.message : String(err));
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* @param {Array<{ from: string, to: string, status?: number }>} redirects
|
|
223
|
+
* @param {string} pathname
|
|
224
|
+
*/
|
|
225
|
+
function matchRedirect(redirects, pathname) {
|
|
226
|
+
const p = pathname.replace(/\/+$/, '') || '/';
|
|
227
|
+
for (const r of redirects) {
|
|
228
|
+
const from = String(r.from || '').replace(/\/+$/, '') || '/';
|
|
229
|
+
if (from === p || from === pathname)
|
|
230
|
+
return r;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* @param {Array<{ match: string, headers: Record<string, string> }>} rules
|
|
236
|
+
* @param {string} pathname
|
|
237
|
+
* @param {Record<string, string>} base
|
|
238
|
+
*/
|
|
239
|
+
function applyHeaderRules(rules, pathname, base = {}) {
|
|
240
|
+
/** @type {Record<string, string>} */
|
|
241
|
+
const out = { ...base };
|
|
242
|
+
for (const rule of rules) {
|
|
243
|
+
if (matchGlob(rule.match, pathname)) {
|
|
244
|
+
Object.assign(out, rule.headers || {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Minimal glob matcher for CDN header rules (html / js|css|mjs / exact / prefix).
|
|
251
|
+
* @param {string} pattern
|
|
252
|
+
* @param {string} pathname
|
|
253
|
+
*/
|
|
254
|
+
export function matchGlob(pattern, pathname) {
|
|
255
|
+
const p = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
256
|
+
const pat = String(pattern || '');
|
|
257
|
+
if (pat === '**/*.html')
|
|
258
|
+
return p.endsWith('.html');
|
|
259
|
+
if (pat === '**/404.html')
|
|
260
|
+
return p === '/404.html' || p.endsWith('/404.html');
|
|
261
|
+
if (pat === '**/assets/**')
|
|
262
|
+
return p === '/assets' || p.startsWith('/assets/');
|
|
263
|
+
if (pat === '**/*.{js,css,mjs}')
|
|
264
|
+
return /\.(js|css|mjs)$/.test(p);
|
|
265
|
+
if (pat === '**/sitemap.xml')
|
|
266
|
+
return p.endsWith('/sitemap.xml') || p === '/sitemap.xml';
|
|
267
|
+
if (pat === '**/robots.txt')
|
|
268
|
+
return p.endsWith('/robots.txt') || p === '/robots.txt';
|
|
269
|
+
if (pat.endsWith('/**')) {
|
|
270
|
+
const prefix = pat.slice(0, -3);
|
|
271
|
+
// Only treat as path prefix when pattern is absolute-ish (starts with / or bare segment).
|
|
272
|
+
if (prefix.startsWith('/')) {
|
|
273
|
+
return p === prefix || p.startsWith(prefix + '/');
|
|
274
|
+
}
|
|
275
|
+
if (!prefix.includes('*')) {
|
|
276
|
+
return p === `/${prefix}` || p.startsWith(`/${prefix}/`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return p === pat || p === `/${pat.replace(/^\//, '')}`;
|
|
280
|
+
}
|
|
281
|
+
function netlifyGlob(match) {
|
|
282
|
+
if (match === '**/*.html')
|
|
283
|
+
return '/*.html';
|
|
284
|
+
if (match === '**/404.html')
|
|
285
|
+
return '/404.html';
|
|
286
|
+
if (match === '**/assets/**')
|
|
287
|
+
return '/assets/*';
|
|
288
|
+
if (match === '**/*.{js,css,mjs}')
|
|
289
|
+
return '/*.{js,css,mjs}';
|
|
290
|
+
if (match === '**/sitemap.xml')
|
|
291
|
+
return '/sitemap.xml';
|
|
292
|
+
if (match === '**/robots.txt')
|
|
293
|
+
return '/robots.txt';
|
|
294
|
+
return match.startsWith('/') ? match : `/${match}`;
|
|
295
|
+
}
|
|
296
|
+
function headerCase(name) {
|
|
297
|
+
return String(name)
|
|
298
|
+
.split('-')
|
|
299
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
300
|
+
.join('-');
|
|
301
|
+
}
|
|
302
|
+
function contentType(file) {
|
|
303
|
+
if (file.endsWith('.html'))
|
|
304
|
+
return 'text/html; charset=utf-8';
|
|
305
|
+
if (file.endsWith('.js') || file.endsWith('.mjs'))
|
|
306
|
+
return 'text/javascript; charset=utf-8';
|
|
307
|
+
if (file.endsWith('.css'))
|
|
308
|
+
return 'text/css; charset=utf-8';
|
|
309
|
+
if (file.endsWith('.xml'))
|
|
310
|
+
return 'application/xml';
|
|
311
|
+
if (file.endsWith('.txt'))
|
|
312
|
+
return 'text/plain; charset=utf-8';
|
|
313
|
+
if (file.endsWith('.json'))
|
|
314
|
+
return 'application/json; charset=utf-8';
|
|
315
|
+
return 'application/octet-stream';
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* @param {string} root
|
|
319
|
+
* @param {string} rel
|
|
320
|
+
*/
|
|
321
|
+
function safeJoin(root, rel) {
|
|
322
|
+
const full = path.resolve(root, rel);
|
|
323
|
+
const normRoot = path.resolve(root);
|
|
324
|
+
if (full !== normRoot && !full.startsWith(normRoot + path.sep))
|
|
325
|
+
return null;
|
|
326
|
+
return full;
|
|
327
|
+
}
|
|
328
|
+
function sha256Hex(data) {
|
|
329
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
330
|
+
}
|
|
331
|
+
function canonicalJson(value) {
|
|
332
|
+
return JSON.stringify(sortKeys(value));
|
|
333
|
+
}
|
|
334
|
+
function sortKeys(value) {
|
|
335
|
+
if (Array.isArray(value))
|
|
336
|
+
return value.map(sortKeys);
|
|
337
|
+
if (value && typeof value === 'object') {
|
|
338
|
+
const out = {};
|
|
339
|
+
for (const k of Object.keys(value).sort())
|
|
340
|
+
out[k] = sortKeys(value[k]);
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
return value;
|
|
344
|
+
}
|