@vmz/core 0.0.3 → 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/dist/client-nav.d.ts +46 -0
- package/dist/client-nav.js +186 -0
- package/dist/dom.d.ts +21 -2
- package/dist/dom.js +1502 -180
- package/dist/serve-host.mjs +544 -88
- package/dist/server.js +97 -11
- package/package.json +5 -1
package/dist/server.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* renderIndex?: => Promise<string> | string,
|
|
15
15
|
* renderIndexStream?: (opts?: { signal?: AbortSignal }) => AsyncIterable<string>,
|
|
16
16
|
* renderPage?: (pathname: string) => Promise<string | null> | string | null,
|
|
17
|
-
* renderPageStream?: (pathname: string, opts?: { signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }) => Promise<AsyncIterable<string> | null> | AsyncIterable<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
18
|
* req?: import('node:http').IncomingMessage,
|
|
19
19
|
* }} NodeRequestOptions
|
|
20
20
|
*/
|
|
@@ -127,12 +127,18 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
127
127
|
const result = await callServerLocal(route.moduleId, route.method, []);
|
|
128
128
|
return sendJson(res, 200, result);
|
|
129
129
|
}
|
|
130
|
-
// 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).
|
|
131
133
|
if (verb === 'GET' && opts.distDir) {
|
|
132
134
|
const nodePath = await import('node:path');
|
|
133
135
|
const { readFile, stat } = await import('node:fs/promises');
|
|
134
136
|
const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat);
|
|
135
|
-
|
|
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))) {
|
|
136
142
|
try {
|
|
137
143
|
const body = await readFile(file);
|
|
138
144
|
return sendBytes(res, 200, body, contentType(file, nodePath));
|
|
@@ -147,9 +153,9 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
|
-
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)) {
|
|
151
157
|
const ac = new AbortController();
|
|
152
|
-
const
|
|
158
|
+
const onClientGone = () => {
|
|
153
159
|
try {
|
|
154
160
|
ac.abort();
|
|
155
161
|
}
|
|
@@ -157,28 +163,57 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
157
163
|
/* ignore */
|
|
158
164
|
}
|
|
159
165
|
};
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
});
|
|
162
173
|
try {
|
|
163
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
|
+
}
|
|
164
187
|
const rendered = await opts.renderPageStream(url.pathname, {
|
|
165
188
|
signal: ac.signal,
|
|
166
189
|
searchParams: url.searchParams,
|
|
167
190
|
cookieHeader: String(req.headers.cookie || ''),
|
|
191
|
+
method: verb,
|
|
192
|
+
body,
|
|
168
193
|
});
|
|
169
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
|
+
}
|
|
170
205
|
const status = rendered && typeof rendered === 'object' && 'status' in rendered ? Number(rendered.status) || 200 : 200;
|
|
171
206
|
const stream = rendered && typeof rendered === 'object' && rendered.stream ? rendered.stream : rendered;
|
|
172
207
|
return await sendHtmlStream(res, status, stream, ac.signal);
|
|
173
208
|
}
|
|
174
209
|
}
|
|
175
|
-
else if (typeof opts.renderPage === 'function') {
|
|
210
|
+
else if (verb === 'GET' && typeof opts.renderPage === 'function') {
|
|
176
211
|
const html = await opts.renderPage(url.pathname);
|
|
177
212
|
if (html != null) {
|
|
178
213
|
return sendHtml(res, 200, html);
|
|
179
214
|
}
|
|
180
215
|
}
|
|
181
|
-
else if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
216
|
+
else if (verb === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
182
217
|
// Legacy index-only SSR (pre multi-page file routes).
|
|
183
218
|
if (typeof opts.renderIndexStream === 'function') {
|
|
184
219
|
return await sendHtmlStream(res, 200, opts.renderIndexStream({ signal: ac.signal }), ac.signal);
|
|
@@ -189,8 +224,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
|
|
|
189
224
|
}
|
|
190
225
|
}
|
|
191
226
|
finally {
|
|
192
|
-
req.off('
|
|
193
|
-
req.off('aborted', onClose);
|
|
227
|
+
req.off('aborted', onClientGone);
|
|
194
228
|
}
|
|
195
229
|
}
|
|
196
230
|
if (!res.headersSent) {
|
|
@@ -234,6 +268,25 @@ function safeDistFile(distDir, pathname, nodePath) {
|
|
|
234
268
|
return null;
|
|
235
269
|
return full;
|
|
236
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
|
+
}
|
|
237
290
|
/**
|
|
238
291
|
* Static resolve for app assets + integrated document mounts:
|
|
239
292
|
* `/d/` → `d/index.html`, `/d/zh-hans/guide` → `d/zh-hans/guide.html`.
|
|
@@ -347,6 +400,39 @@ function readJson(req) {
|
|
|
347
400
|
req.on('error', reject);
|
|
348
401
|
});
|
|
349
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
|
+
}
|
|
350
436
|
/**
|
|
351
437
|
* @param {import('node:http').ServerResponse} res
|
|
352
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": [
|