@vmz/core 0.0.3 → 0.1.0

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
@@ -29,8 +29,16 @@ export declare function handleRpc(body: any): Promise<any>;
29
29
  * @returns {Route | null}
30
30
  */
31
31
  export declare function matchRoute(verb: any, pathname: any): any;
32
+ /**
33
+ * Web Standards Fetch entry for ServerArtifact hosts (Node adapter, worker/edge parity).
34
+ * Handles RPC + public ServerRoute only; static/SSR stay on Node host options.
35
+ * @param {Request} request
36
+ * @returns {Promise<Response>}
37
+ */
38
+ export declare function handleFetchRequest(request: any): Promise<Response>;
32
39
  /**
33
40
  * Node `http.createServer` listener: RPC + REST + optional static / SSR index.
41
+ * RPC/REST go through {@link handleFetchRequest} so Node and Fetch hosts share one core.
34
42
  * @param {import('node:http').IncomingMessage} req
35
43
  * @param {import('node:http').ServerResponse} res
36
44
  * @param {NodeRequestOptions} [opts]
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
  */
@@ -106,8 +106,95 @@ export function matchRoute(verb, pathname) {
106
106
  const v = verb.toUpperCase();
107
107
  return routes.find((r) => r.verb.toUpperCase() === v && r.path === pathname) ?? null;
108
108
  }
109
+ /**
110
+ * Build `callServerLocal` args from a Fetch Request for REST routes.
111
+ * GET/HEAD → `[]`. JSON POST/PUT/PATCH → `[body]`. form-urlencoded → `[record]`.
112
+ * Multipart → `[record]` where File/Blob parts stay as File/Blob (tool-site binary upload).
113
+ * Octet-stream PUT also forwards Upload resumable chunk headers (upload-id / chunk-index / chunk-total).
114
+ * Extra args are ignored by zero-parameter server methods (JS).
115
+ * @param {Request} request
116
+ * @param {string} verb
117
+ * @returns {Promise<unknown[]>}
118
+ */
119
+ async function routeArgsFromRequest(request, verb) {
120
+ const v = String(verb || 'GET').toUpperCase();
121
+ if (v === 'GET' || v === 'HEAD' || v === 'OPTIONS')
122
+ return [];
123
+ const ctype = String(request.headers.get('content-type') || '');
124
+ if (ctype.includes('application/json')) {
125
+ const text = await request.text();
126
+ if (!text || !String(text).trim())
127
+ return [{}];
128
+ try {
129
+ return [JSON.parse(text)];
130
+ }
131
+ catch (err) {
132
+ throw new Error(`invalid JSON body: ${err instanceof Error ? err.message : String(err)}`);
133
+ }
134
+ }
135
+ if (ctype.includes('multipart/form-data')) {
136
+ // Parse from raw bytes — undici Request.formData() can UTF-8-mangle high bytes in file parts
137
+ // (0xFF/0xFE → U+FFFD), which breaks Upload binary / tool-site intakes.
138
+ const buf = Buffer.from(await request.arrayBuffer());
139
+ return [parseMultipartBuffer(buf, ctype)];
140
+ }
141
+ // Object-store PUT / resumable chunk PUT — keep bytes (never request.text()).
142
+ if (ctype.includes('application/octet-stream') ||
143
+ ((v === 'PUT' || v === 'PATCH') && !ctype.includes('json') && !ctype.includes('x-www-form-urlencoded'))) {
144
+ const buf = Buffer.from(await request.arrayBuffer());
145
+ const key = String(request.headers.get('x-vmz-object-key') || '');
146
+ const uploadId = String(request.headers.get('x-vmz-upload-id') || '');
147
+ const chunkIndexRaw = request.headers.get('x-vmz-chunk-index');
148
+ const chunkTotalRaw = request.headers.get('x-vmz-chunk-total');
149
+ const chunkIndex = chunkIndexRaw != null && String(chunkIndexRaw).trim() !== '' ? Number(chunkIndexRaw) : undefined;
150
+ const chunkTotal = chunkTotalRaw != null && String(chunkTotalRaw).trim() !== '' ? Number(chunkTotalRaw) : undefined;
151
+ return [
152
+ {
153
+ bytes: buf,
154
+ size: buf.byteLength,
155
+ key,
156
+ uploadId,
157
+ chunkIndex: Number.isFinite(chunkIndex) ? chunkIndex : undefined,
158
+ chunkTotal: Number.isFinite(chunkTotal) ? chunkTotal : undefined,
159
+ contentType: ctype || 'application/octet-stream',
160
+ },
161
+ ];
162
+ }
163
+ const raw = await request.text();
164
+ return [parseFormBody(raw, ctype)];
165
+ }
166
+ /**
167
+ * Web Standards Fetch entry for ServerArtifact hosts (Node adapter, worker/edge parity).
168
+ * Handles RPC + public ServerRoute only; static/SSR stay on Node host options.
169
+ * @param {Request} request
170
+ * @returns {Promise<Response>}
171
+ */
172
+ export async function handleFetchRequest(request) {
173
+ const url = new URL(request.url);
174
+ const verb = (request.method || 'GET').toUpperCase();
175
+ try {
176
+ if (verb === 'POST' && url.pathname === DEFAULT_RPC_PATH) {
177
+ const body = await request.json();
178
+ const result = await handleRpc(body);
179
+ return Response.json(result);
180
+ }
181
+ const route = matchRoute(verb, url.pathname);
182
+ if (route) {
183
+ const args = await routeArgsFromRequest(request, verb);
184
+ const result = await callServerLocal(route.moduleId, route.method, args);
185
+ return Response.json(result);
186
+ }
187
+ return Response.json({ error: 'not found', path: url.pathname }, { status: 404 });
188
+ }
189
+ catch (err) {
190
+ return Response.json({
191
+ error: err instanceof Error ? err.message : String(err),
192
+ }, { status: 500 });
193
+ }
194
+ }
109
195
  /**
110
196
  * Node `http.createServer` listener: RPC + REST + optional static / SSR index.
197
+ * RPC/REST go through {@link handleFetchRequest} so Node and Fetch hosts share one core.
111
198
  * @param {import('node:http').IncomingMessage} req
112
199
  * @param {import('node:http').ServerResponse} res
113
200
  * @param {NodeRequestOptions} [opts]
@@ -117,22 +204,25 @@ export async function handleNodeRequest(req, res, opts = {}) {
117
204
  const url = new URL(req.url || '/', `http://${host}`);
118
205
  const verb = (req.method || 'GET').toUpperCase();
119
206
  try {
120
- if (verb === 'POST' && url.pathname === DEFAULT_RPC_PATH) {
121
- const body = await readJson(req);
122
- const result = await handleRpc(body);
123
- return sendJson(res, 200, result);
124
- }
207
+ const isRpc = verb === 'POST' && url.pathname === DEFAULT_RPC_PATH;
125
208
  const route = matchRoute(verb, url.pathname);
126
- if (route) {
127
- const result = await callServerLocal(route.moduleId, route.method, []);
128
- return sendJson(res, 200, result);
209
+ if (isRpc || route) {
210
+ const request = await incomingToRequest(req, url);
211
+ const response = await handleFetchRequest(request);
212
+ return await writeFetchResponse(res, response);
129
213
  }
130
- // Static first (integrated DocumentMount + assets) so /d/… is not swallowed by SSR 404 shells.
214
+ // Static first for assets + DocumentMount (`/d/…`) so docs aren't swallowed by SSR 404 shells.
215
+ // web-static route HTML (`index.html`, `about/index.html`, …) is a CDN/deploy projection only —
216
+ // when Server Host SSR is active, those files must not shadow live render (local/dev ≡ SSR truth).
131
217
  if (verb === 'GET' && opts.distDir) {
132
218
  const nodePath = await import('node:path');
133
219
  const { readFile, stat } = await import('node:fs/promises');
134
220
  const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat);
135
- if (file) {
221
+ const hasSsr = typeof opts.renderPageStream === 'function' ||
222
+ typeof opts.renderPage === 'function' ||
223
+ typeof opts.renderIndexStream === 'function' ||
224
+ typeof opts.renderIndex === 'function';
225
+ if (file && !(hasSsr && isWebStaticHtmlShadow(file, url.pathname, nodePath))) {
136
226
  try {
137
227
  const body = await readFile(file);
138
228
  return sendBytes(res, 200, body, contentType(file, nodePath));
@@ -147,9 +237,9 @@ export async function handleNodeRequest(req, res, opts = {}) {
147
237
  }
148
238
  }
149
239
  }
150
- if (verb === 'GET' && (opts.renderPageStream || opts.renderPage || opts.renderIndexStream || opts.renderIndex)) {
240
+ if ((verb === 'GET' || verb === 'POST') && (opts.renderPageStream || opts.renderPage || opts.renderIndexStream || opts.renderIndex)) {
151
241
  const ac = new AbortController();
152
- const onClose = () => {
242
+ const onClientGone = () => {
153
243
  try {
154
244
  ac.abort();
155
245
  }
@@ -157,28 +247,57 @@ export async function handleNodeRequest(req, res, opts = {}) {
157
247
  /* ignore */
158
248
  }
159
249
  };
160
- req.on('close', onClose);
161
- req.on('aborted', onClose);
250
+ // Do not abort on IncomingMessage `close` — that fires after a POST body is
251
+ // fully read (normal), which would cancel SSR before the first chunk.
252
+ req.on('aborted', onClientGone);
253
+ res.on('close', () => {
254
+ if (!res.writableEnded)
255
+ onClientGone();
256
+ });
162
257
  try {
163
258
  if (typeof opts.renderPageStream === 'function') {
259
+ /** @type {unknown} */
260
+ let body;
261
+ if (verb === 'POST') {
262
+ const ctype = String(req.headers['content-type'] || '');
263
+ if (ctype.includes('application/json')) {
264
+ body = await readJson(req);
265
+ }
266
+ else {
267
+ const raw = await readRawBody(req);
268
+ body = parseFormBody(raw, ctype);
269
+ }
270
+ }
164
271
  const rendered = await opts.renderPageStream(url.pathname, {
165
272
  signal: ac.signal,
166
273
  searchParams: url.searchParams,
167
274
  cookieHeader: String(req.headers.cookie || ''),
275
+ method: verb,
276
+ body,
168
277
  });
169
278
  if (rendered) {
279
+ if (typeof rendered === 'object' && rendered.redirect) {
280
+ const status = Number(rendered.status) || 302;
281
+ const headers = {
282
+ Location: String(rendered.redirect),
283
+ ...(rendered.headers && typeof rendered.headers === 'object' ? rendered.headers : {}),
284
+ };
285
+ res.writeHead(status, headers);
286
+ res.end();
287
+ return;
288
+ }
170
289
  const status = rendered && typeof rendered === 'object' && 'status' in rendered ? Number(rendered.status) || 200 : 200;
171
290
  const stream = rendered && typeof rendered === 'object' && rendered.stream ? rendered.stream : rendered;
172
291
  return await sendHtmlStream(res, status, stream, ac.signal);
173
292
  }
174
293
  }
175
- else if (typeof opts.renderPage === 'function') {
294
+ else if (verb === 'GET' && typeof opts.renderPage === 'function') {
176
295
  const html = await opts.renderPage(url.pathname);
177
296
  if (html != null) {
178
297
  return sendHtml(res, 200, html);
179
298
  }
180
299
  }
181
- else if (url.pathname === '/' || url.pathname === '/index.html') {
300
+ else if (verb === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
182
301
  // Legacy index-only SSR (pre multi-page file routes).
183
302
  if (typeof opts.renderIndexStream === 'function') {
184
303
  return await sendHtmlStream(res, 200, opts.renderIndexStream({ signal: ac.signal }), ac.signal);
@@ -189,8 +308,7 @@ export async function handleNodeRequest(req, res, opts = {}) {
189
308
  }
190
309
  }
191
310
  finally {
192
- req.off('close', onClose);
193
- req.off('aborted', onClose);
311
+ req.off('aborted', onClientGone);
194
312
  }
195
313
  }
196
314
  if (!res.headersSent) {
@@ -213,6 +331,43 @@ export async function handleNodeRequest(req, res, opts = {}) {
213
331
  });
214
332
  }
215
333
  }
334
+ /**
335
+ * @param {import('node:http').IncomingMessage} req
336
+ * @param {URL} url
337
+ * @returns {Promise<Request>}
338
+ */
339
+ async function incomingToRequest(req, url) {
340
+ const method = (req.method || 'GET').toUpperCase();
341
+ /** @type {HeadersInit} */
342
+ const headers = {};
343
+ for (const [k, v] of Object.entries(req.headers)) {
344
+ if (v == null)
345
+ continue;
346
+ headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
347
+ }
348
+ if (method === 'GET' || method === 'HEAD') {
349
+ return new Request(url, { method, headers });
350
+ }
351
+ const raw = await readRawBody(req);
352
+ // Node undici requires duplex when constructing Request with a body.
353
+ return new Request(url, { method, headers, body: raw, duplex: 'half' });
354
+ }
355
+ /**
356
+ * @param {import('node:http').ServerResponse} res
357
+ * @param {Response} response
358
+ */
359
+ async function writeFetchResponse(res, response) {
360
+ const headers = {};
361
+ response.headers.forEach((value, key) => {
362
+ headers[key] = value;
363
+ });
364
+ const buf = Buffer.from(await response.arrayBuffer());
365
+ if (!headers['content-length'] && !headers['Content-Length']) {
366
+ headers['content-length'] = String(buf.byteLength);
367
+ }
368
+ res.writeHead(response.status, headers);
369
+ res.end(buf);
370
+ }
216
371
  /**
217
372
  * Resolve a path under distDir; reject `..` escapes.
218
373
  * @param {string} distDir
@@ -234,6 +389,25 @@ function safeDistFile(distDir, pathname, nodePath) {
234
389
  return null;
235
390
  return full;
236
391
  }
392
+ /**
393
+ * web-static emits per-route HTML beside client assets. That HTML is for CDN / local-static
394
+ * delivery hosts — not for Server Host when SSR is available. DocumentMount stays static.
395
+ * @param {string} file
396
+ * @param {string} pathname
397
+ * @param {typeof import('node:path')} nodePath
398
+ */
399
+ function isWebStaticHtmlShadow(file, pathname, nodePath) {
400
+ const ext = nodePath.extname(file).toLowerCase();
401
+ if (ext !== '.html' && ext !== '.htm')
402
+ return false;
403
+ let rel = decodeURIComponent(String(pathname || '').split('?')[0] || '/');
404
+ if (!rel.startsWith('/'))
405
+ rel = `/${rel}`;
406
+ // Integrated DocumentMount — keep static-first (see resolveDistStatic comment).
407
+ if (rel === '/d' || rel.startsWith('/d/'))
408
+ return false;
409
+ return true;
410
+ }
237
411
  /**
238
412
  * Static resolve for app assets + integrated document mounts:
239
413
  * `/d/` → `d/index.html`, `/d/zh-hans/guide` → `d/zh-hans/guide.html`.
@@ -347,6 +521,126 @@ function readJson(req) {
347
521
  req.on('error', reject);
348
522
  });
349
523
  }
524
+ /**
525
+ * @param {import('node:http').IncomingMessage} req
526
+ * @returns {Promise<Buffer>}
527
+ */
528
+ function readRawBody(req) {
529
+ return new Promise((resolve, reject) => {
530
+ const chunks = [];
531
+ req.on('data', (c) => chunks.push(c));
532
+ // Buffer — never utf8-string: multipart File bytes must survive (Upload binary gate).
533
+ req.on('end', () => resolve(Buffer.concat(chunks)));
534
+ req.on('error', reject);
535
+ });
536
+ }
537
+ /**
538
+ * Buffer-safe multipart/form-data parser (file parts stay binary).
539
+ * @param {Buffer} buf
540
+ * @param {string} contentType
541
+ * @returns {Record<string, unknown>}
542
+ */
543
+ function parseMultipartBuffer(buf, contentType) {
544
+ const bm = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(String(contentType || ''));
545
+ const boundary = bm ? bm[1] || bm[2] : '';
546
+ if (!boundary) {
547
+ throw new Error('multipart: missing boundary');
548
+ }
549
+ const sep = Buffer.from(`--${boundary}`);
550
+ /** @type {Record<string, unknown>} */
551
+ const out = {};
552
+ let start = indexOfBuffer(buf, sep, 0);
553
+ if (start < 0)
554
+ return out;
555
+ start += sep.length;
556
+ // Optional leading CRLF after first boundary is handled per-part.
557
+ while (start < buf.length) {
558
+ if (buf[start] === 0x2d && buf[start + 1] === 0x2d)
559
+ break; // trailing --
560
+ if (buf[start] === 0x0d && buf[start + 1] === 0x0a)
561
+ start += 2;
562
+ const next = indexOfBuffer(buf, sep, start);
563
+ const end = next < 0 ? buf.length : next;
564
+ let part = buf.subarray(start, end);
565
+ // Trim trailing CRLF before boundary.
566
+ if (part.length >= 2 && part[part.length - 2] === 0x0d && part[part.length - 1] === 0x0a) {
567
+ part = part.subarray(0, part.length - 2);
568
+ }
569
+ const splitAt = indexOfBuffer(part, Buffer.from('\r\n\r\n'), 0);
570
+ if (splitAt >= 0) {
571
+ const headerText = part.subarray(0, splitAt).toString('utf8');
572
+ let body = part.subarray(splitAt + 4);
573
+ const nameM = /content-disposition:[^\r\n]*;\s*name="([^"]*)"/i.exec(headerText);
574
+ const fileM = /content-disposition:[^\r\n]*;\s*filename="([^"]*)"/i.exec(headerText);
575
+ const typeM = /content-type:\s*([^\r\n]+)/i.exec(headerText);
576
+ const key = nameM ? nameM[1] : '';
577
+ if (key) {
578
+ if (fileM) {
579
+ const filename = fileM[1] || 'upload.bin';
580
+ const type = typeM ? String(typeM[1]).trim() : 'application/octet-stream';
581
+ // Copy body — File may outlive the request buffer.
582
+ const copy = Buffer.from(body);
583
+ const file = new File([copy], filename, { type });
584
+ const prev = out[key];
585
+ if (prev == null) {
586
+ out[key] = file;
587
+ }
588
+ else if (Array.isArray(prev)) {
589
+ prev.push(file);
590
+ }
591
+ else {
592
+ out[key] = [prev, file];
593
+ }
594
+ }
595
+ else {
596
+ out[key] = body.toString('utf8');
597
+ }
598
+ }
599
+ }
600
+ if (next < 0)
601
+ break;
602
+ start = next + sep.length;
603
+ }
604
+ return out;
605
+ }
606
+ /**
607
+ * @param {Buffer} hay
608
+ * @param {Buffer} needle
609
+ * @param {number} from
610
+ */
611
+ function indexOfBuffer(hay, needle, from) {
612
+ if (!needle.length)
613
+ return from;
614
+ outer: for (let i = Math.max(0, from); i <= hay.length - needle.length; i++) {
615
+ for (let j = 0; j < needle.length; j++) {
616
+ if (hay[i + j] !== needle[j])
617
+ continue outer;
618
+ }
619
+ return i;
620
+ }
621
+ return -1;
622
+ }
623
+ /**
624
+ * @param {string} raw
625
+ * @param {string} contentType
626
+ * @returns {Record<string, string> | string}
627
+ */
628
+ function parseFormBody(raw, contentType) {
629
+ if (!raw)
630
+ return {};
631
+ if (contentType.includes('application/x-www-form-urlencoded')) {
632
+ const out = {};
633
+ for (const [k, v] of new URLSearchParams(raw))
634
+ out[k] = v;
635
+ return out;
636
+ }
637
+ try {
638
+ return JSON.parse(raw);
639
+ }
640
+ catch {
641
+ return { raw };
642
+ }
643
+ }
350
644
  /**
351
645
  * @param {import('node:http').ServerResponse} res
352
646
  * @param {number} status
@@ -0,0 +1,5 @@
1
+ /**
2
+ * App-dist alias: compiler copies dom.js → vmz-dom.js.
3
+ * Package-local serve-host imports this path during `@vmz/core` build.
4
+ */
5
+ export * from './dom.js';
@@ -0,0 +1,6 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * App-dist alias: compiler copies dom.js → vmz-dom.js.
4
+ * Package-local serve-host imports this path during `@vmz/core` build.
5
+ */
6
+ export * from './dom.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * App-dist alias: compiler copies server.js → vmz-runtime.js.
3
+ * Package-local serve-host imports this path during `@vmz/core` build.
4
+ */
5
+ export * from './server.js';
@@ -0,0 +1,6 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * App-dist alias: compiler copies server.js → vmz-runtime.js.
4
+ * Package-local serve-host imports this path during `@vmz/core` build.
5
+ */
6
+ export * from './server.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {
@@ -12,6 +12,10 @@
12
12
  "types": "./dist/dom.d.ts",
13
13
  "default": "./dist/dom.js"
14
14
  },
15
+ "./dom/client": {
16
+ "types": "./dist/dom.client.d.ts",
17
+ "default": "./dist/dom.client.js"
18
+ },
15
19
  "./server": {
16
20
  "types": "./dist/server.d.ts",
17
21
  "default": "./dist/server.js"
@@ -19,6 +23,10 @@
19
23
  "./http": {
20
24
  "types": "./dist/http.d.ts",
21
25
  "default": "./dist/http.js"
26
+ },
27
+ "./client-nav": {
28
+ "types": "./dist/client-nav.d.ts",
29
+ "default": "./dist/client-nav.js"
22
30
  }
23
31
  },
24
32
  "files": [
@@ -34,6 +42,9 @@
34
42
  "scripts": {
35
43
  "build": "tsc -p tsconfig.json && node ../../../scripts/build/copy-serve-host-mjs.mjs"
36
44
  },
45
+ "dependencies": {
46
+ "linkedom": "^0.18.13"
47
+ },
37
48
  "publishConfig": {
38
49
  "access": "public"
39
50
  },