@vmz/core 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 +4 -2
- package/dist/client-nav.d.ts +46 -0
- package/dist/client-nav.js +186 -0
- package/dist/dom.d.ts +28 -12
- package/dist/dom.js +1540 -226
- package/dist/serve-host.mjs +546 -90
- package/dist/server.d.ts +1 -2
- package/dist/server.js +103 -18
- package/package.json +6 -2
- package/dist/serve-host.js +0 -736
package/dist/server.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal VMZ runtime — `#server` invoke + RPC/REST HTTP + optional static/SSR.
|
|
3
3
|
*
|
|
4
|
-
* Design: 规划设计/vmz/08-虚拟server.md, 07-REST接口.md
|
|
5
4
|
*
|
|
6
5
|
* Browser-safe: no static `node:*` imports. Node builtins are loaded only inside
|
|
7
6
|
* Node/SSR request handlers so client bundles can `import { callServer }`.
|
|
8
7
|
*/
|
|
9
8
|
/**
|
|
10
|
-
* Map `#server/foo` → filesystem / URL the host can `import
|
|
9
|
+
* Map `#server/foo` → filesystem / URL the host can `import`.
|
|
11
10
|
* Only set this in Node/SSR hosts — browser bundles must omit it so RPC goes HTTP.
|
|
12
11
|
*/
|
|
13
12
|
export declare function setServerModuleResolver(fn: any): void;
|
package/dist/server.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Minimal VMZ runtime — `#server` invoke + RPC/REST HTTP + optional static/SSR.
|
|
4
4
|
*
|
|
5
|
-
* Design: 规划设计/vmz/08-虚拟server.md, 07-REST接口.md
|
|
6
5
|
*
|
|
7
6
|
* Browser-safe: no static `node:*` imports. Node builtins are loaded only inside
|
|
8
7
|
* Node/SSR request handlers so client bundles can `import { callServer }`.
|
|
@@ -11,12 +10,12 @@
|
|
|
11
10
|
/** @typedef {{ verb: string, path: string, moduleId: string, method: string, className?: string }} Route */
|
|
12
11
|
/**
|
|
13
12
|
* @typedef {{
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* distDir?: string,
|
|
14
|
+
* renderIndex?: => Promise<string> | string,
|
|
15
|
+
* renderIndexStream?: (opts?: { signal?: AbortSignal }) => AsyncIterable<string>,
|
|
16
|
+
* renderPage?: (pathname: string) => Promise<string | null> | string | null,
|
|
17
|
+
* renderPageStream?: (pathname: string, opts?: { signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string, method?: string, body?: unknown }) => Promise<AsyncIterable<string> | { status?: number, stream?: AsyncIterable<string>, redirect?: string, headers?: Record<string, string> } | null> | AsyncIterable<string> | null,
|
|
18
|
+
* req?: import('node:http').IncomingMessage,
|
|
20
19
|
* }} NodeRequestOptions
|
|
21
20
|
*/
|
|
22
21
|
const DEFAULT_RPC_PATH = '/__vmz/rpc';
|
|
@@ -25,7 +24,7 @@ let resolveServerModule = null;
|
|
|
25
24
|
/** @type {Route[]} */
|
|
26
25
|
let routes = [];
|
|
27
26
|
/**
|
|
28
|
-
* Map `#server/foo` → filesystem / URL the host can `import
|
|
27
|
+
* Map `#server/foo` → filesystem / URL the host can `import`.
|
|
29
28
|
* Only set this in Node/SSR hosts — browser bundles must omit it so RPC goes HTTP.
|
|
30
29
|
*/
|
|
31
30
|
export function setServerModuleResolver(fn) {
|
|
@@ -128,12 +127,18 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
128
127
|
const result = await callServerLocal(route.moduleId, route.method, []);
|
|
129
128
|
return sendJson(res, 200, result);
|
|
130
129
|
}
|
|
131
|
-
// Static first
|
|
130
|
+
// Static first for assets + DocumentMount (`/d/…`) so docs aren't swallowed by SSR 404 shells.
|
|
131
|
+
// web-static route HTML (`index.html`, `about/index.html`, …) is a CDN/deploy projection only —
|
|
132
|
+
// when Server Host SSR is active, those files must not shadow live render (local/dev ≡ SSR truth).
|
|
132
133
|
if (verb === 'GET' && opts.distDir) {
|
|
133
134
|
const nodePath = await import('node:path');
|
|
134
135
|
const { readFile, stat } = await import('node:fs/promises');
|
|
135
136
|
const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat);
|
|
136
|
-
|
|
137
|
+
const hasSsr = typeof opts.renderPageStream === 'function' ||
|
|
138
|
+
typeof opts.renderPage === 'function' ||
|
|
139
|
+
typeof opts.renderIndexStream === 'function' ||
|
|
140
|
+
typeof opts.renderIndex === 'function';
|
|
141
|
+
if (file && !(hasSsr && isWebStaticHtmlShadow(file, url.pathname, nodePath))) {
|
|
137
142
|
try {
|
|
138
143
|
const body = await readFile(file);
|
|
139
144
|
return sendBytes(res, 200, body, contentType(file, nodePath));
|
|
@@ -148,9 +153,9 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
155
|
}
|
|
151
|
-
if (verb === 'GET' && (opts.renderPageStream || opts.renderPage || opts.renderIndexStream || opts.renderIndex)) {
|
|
156
|
+
if ((verb === 'GET' || verb === 'POST') && (opts.renderPageStream || opts.renderPage || opts.renderIndexStream || opts.renderIndex)) {
|
|
152
157
|
const ac = new AbortController();
|
|
153
|
-
const
|
|
158
|
+
const onClientGone = () => {
|
|
154
159
|
try {
|
|
155
160
|
ac.abort();
|
|
156
161
|
}
|
|
@@ -158,28 +163,57 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
158
163
|
/* ignore */
|
|
159
164
|
}
|
|
160
165
|
};
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
// Do not abort on IncomingMessage `close` — that fires after a POST body is
|
|
167
|
+
// fully read (normal), which would cancel SSR before the first chunk.
|
|
168
|
+
req.on('aborted', onClientGone);
|
|
169
|
+
res.on('close', () => {
|
|
170
|
+
if (!res.writableEnded)
|
|
171
|
+
onClientGone();
|
|
172
|
+
});
|
|
163
173
|
try {
|
|
164
174
|
if (typeof opts.renderPageStream === 'function') {
|
|
175
|
+
/** @type {unknown} */
|
|
176
|
+
let body;
|
|
177
|
+
if (verb === 'POST') {
|
|
178
|
+
const ctype = String(req.headers['content-type'] || '');
|
|
179
|
+
if (ctype.includes('application/json')) {
|
|
180
|
+
body = await readJson(req);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const raw = await readRawBody(req);
|
|
184
|
+
body = parseFormBody(raw, ctype);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
165
187
|
const rendered = await opts.renderPageStream(url.pathname, {
|
|
166
188
|
signal: ac.signal,
|
|
167
189
|
searchParams: url.searchParams,
|
|
168
190
|
cookieHeader: String(req.headers.cookie || ''),
|
|
191
|
+
method: verb,
|
|
192
|
+
body,
|
|
169
193
|
});
|
|
170
194
|
if (rendered) {
|
|
195
|
+
if (typeof rendered === 'object' && rendered.redirect) {
|
|
196
|
+
const status = Number(rendered.status) || 302;
|
|
197
|
+
const headers = {
|
|
198
|
+
Location: String(rendered.redirect),
|
|
199
|
+
...(rendered.headers && typeof rendered.headers === 'object' ? rendered.headers : {}),
|
|
200
|
+
};
|
|
201
|
+
res.writeHead(status, headers);
|
|
202
|
+
res.end();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
171
205
|
const status = rendered && typeof rendered === 'object' && 'status' in rendered ? Number(rendered.status) || 200 : 200;
|
|
172
206
|
const stream = rendered && typeof rendered === 'object' && rendered.stream ? rendered.stream : rendered;
|
|
173
207
|
return await sendHtmlStream(res, status, stream, ac.signal);
|
|
174
208
|
}
|
|
175
209
|
}
|
|
176
|
-
else if (typeof opts.renderPage === 'function') {
|
|
210
|
+
else if (verb === 'GET' && typeof opts.renderPage === 'function') {
|
|
177
211
|
const html = await opts.renderPage(url.pathname);
|
|
178
212
|
if (html != null) {
|
|
179
213
|
return sendHtml(res, 200, html);
|
|
180
214
|
}
|
|
181
215
|
}
|
|
182
|
-
else if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
216
|
+
else if (verb === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
183
217
|
// Legacy index-only SSR (pre multi-page file routes).
|
|
184
218
|
if (typeof opts.renderIndexStream === 'function') {
|
|
185
219
|
return await sendHtmlStream(res, 200, opts.renderIndexStream({ signal: ac.signal }), ac.signal);
|
|
@@ -190,8 +224,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
190
224
|
}
|
|
191
225
|
}
|
|
192
226
|
finally {
|
|
193
|
-
req.off('
|
|
194
|
-
req.off('aborted', onClose);
|
|
227
|
+
req.off('aborted', onClientGone);
|
|
195
228
|
}
|
|
196
229
|
}
|
|
197
230
|
if (!res.headersSent) {
|
|
@@ -235,6 +268,25 @@ function safeDistFile(distDir, pathname, nodePath) {
|
|
|
235
268
|
return null;
|
|
236
269
|
return full;
|
|
237
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* web-static emits per-route HTML beside client assets. That HTML is for CDN / local-static
|
|
273
|
+
* delivery hosts — not for Server Host when SSR is available. DocumentMount stays static.
|
|
274
|
+
* @param {string} file
|
|
275
|
+
* @param {string} pathname
|
|
276
|
+
* @param {typeof import('node:path')} nodePath
|
|
277
|
+
*/
|
|
278
|
+
function isWebStaticHtmlShadow(file, pathname, nodePath) {
|
|
279
|
+
const ext = nodePath.extname(file).toLowerCase();
|
|
280
|
+
if (ext !== '.html' && ext !== '.htm')
|
|
281
|
+
return false;
|
|
282
|
+
let rel = decodeURIComponent(String(pathname || '').split('?')[0] || '/');
|
|
283
|
+
if (!rel.startsWith('/'))
|
|
284
|
+
rel = `/${rel}`;
|
|
285
|
+
// Integrated DocumentMount — keep static-first (see resolveDistStatic comment).
|
|
286
|
+
if (rel === '/d' || rel.startsWith('/d/'))
|
|
287
|
+
return false;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
238
290
|
/**
|
|
239
291
|
* Static resolve for app assets + integrated document mounts:
|
|
240
292
|
* `/d/` → `d/index.html`, `/d/zh-hans/guide` → `d/zh-hans/guide.html`.
|
|
@@ -348,6 +400,39 @@ function readJson(req) {
|
|
|
348
400
|
req.on('error', reject);
|
|
349
401
|
});
|
|
350
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* @param {import('node:http').IncomingMessage} req
|
|
405
|
+
* @returns {Promise<string>}
|
|
406
|
+
*/
|
|
407
|
+
function readRawBody(req) {
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
const chunks = [];
|
|
410
|
+
req.on('data', (c) => chunks.push(c));
|
|
411
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
412
|
+
req.on('error', reject);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* @param {string} raw
|
|
417
|
+
* @param {string} contentType
|
|
418
|
+
* @returns {Record<string, string> | string}
|
|
419
|
+
*/
|
|
420
|
+
function parseFormBody(raw, contentType) {
|
|
421
|
+
if (!raw)
|
|
422
|
+
return {};
|
|
423
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
424
|
+
const out = {};
|
|
425
|
+
for (const [k, v] of new URLSearchParams(raw))
|
|
426
|
+
out[k] = v;
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(raw);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return { raw };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
351
436
|
/**
|
|
352
437
|
* @param {import('node:http').ServerResponse} res
|
|
353
438
|
* @param {number} status
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vmz/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
|
|
6
6
|
"exports": {
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"./http": {
|
|
20
20
|
"types": "./dist/http.d.ts",
|
|
21
21
|
"default": "./dist/http.js"
|
|
22
|
+
},
|
|
23
|
+
"./client-nav": {
|
|
24
|
+
"types": "./dist/client-nav.d.ts",
|
|
25
|
+
"default": "./dist/client-nav.js"
|
|
22
26
|
}
|
|
23
27
|
},
|
|
24
28
|
"files": [
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
"main": "./dist/server.js",
|
|
33
37
|
"types": "./dist/index.d.ts",
|
|
34
38
|
"scripts": {
|
|
35
|
-
"build": "tsc -p tsconfig.json && node ../../../scripts/copy-serve-host-mjs.mjs"
|
|
39
|
+
"build": "tsc -p tsconfig.json && node ../../../scripts/build/copy-serve-host-mjs.mjs"
|
|
36
40
|
},
|
|
37
41
|
"publishConfig": {
|
|
38
42
|
"access": "public"
|