@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/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
- * distDir?: string,
15
- * renderIndex?: () => Promise<string> | string,
16
- * renderIndexStream?: (opts?: { signal?: AbortSignal }) => AsyncIterable<string>,
17
- * renderPage?: (pathname: string) => Promise<string | null> | string | null,
18
- * renderPageStream?: (pathname: string, opts?: { signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }) => Promise<AsyncIterable<string> | null> | AsyncIterable<string> | null,
19
- * req?: import('node:http').IncomingMessage,
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 (integrated DocumentMount + assets) so /d/… is not swallowed by SSR 404 shells.
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
- if (file) {
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 onClose = () => {
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
- req.on('close', onClose);
162
- req.on('aborted', onClose);
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('close', onClose);
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.2",
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"