@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/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 (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).
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
- 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))) {
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 onClose = () => {
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
- req.on('close', onClose);
161
- 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
+ });
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('close', onClose);
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",
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": [