@vmz/core 0.0.4 → 0.1.1

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
@@ -75,7 +75,21 @@ async function callServerLocal(moduleId, method, args) {
75
75
  * @param {unknown[]} args
76
76
  */
77
77
  async function callServerHttp(moduleId, method, args) {
78
- const rpcPath = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_PATH) || DEFAULT_RPC_PATH;
78
+ let rpcPath = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_PATH) || DEFAULT_RPC_PATH;
79
+ // Node undici `fetch` rejects relative URLs; browsers accept path-only.
80
+ if (typeof rpcPath === 'string' && !/^https?:\/\//i.test(rpcPath)) {
81
+ const origin = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_ORIGIN) ||
82
+ (typeof window !== 'undefined' && window.location && window.location.origin) ||
83
+ null;
84
+ if (origin) {
85
+ rpcPath = new URL(rpcPath, origin).href;
86
+ }
87
+ else if (typeof window === 'undefined') {
88
+ const host = (typeof process !== 'undefined' && (process.env.VMZ_HOST || process.env.HOST)) || '127.0.0.1';
89
+ const port = (typeof process !== 'undefined' && (process.env.VMZ_PORT || process.env.PORT)) || '5173';
90
+ rpcPath = new URL(rpcPath, `http://${host}:${port}`).href;
91
+ }
92
+ }
79
93
  const res = await fetch(rpcPath, {
80
94
  method: 'POST',
81
95
  headers: { 'content-type': 'application/json' },
@@ -106,8 +120,95 @@ export function matchRoute(verb, pathname) {
106
120
  const v = verb.toUpperCase();
107
121
  return routes.find((r) => r.verb.toUpperCase() === v && r.path === pathname) ?? null;
108
122
  }
123
+ /**
124
+ * Build `callServerLocal` args from a Fetch Request for REST routes.
125
+ * GET/HEAD → `[]`. JSON POST/PUT/PATCH → `[body]`. form-urlencoded → `[record]`.
126
+ * Multipart → `[record]` where File/Blob parts stay as File/Blob (tool-site binary upload).
127
+ * Octet-stream PUT also forwards Upload resumable chunk headers (upload-id / chunk-index / chunk-total).
128
+ * Extra args are ignored by zero-parameter server methods (JS).
129
+ * @param {Request} request
130
+ * @param {string} verb
131
+ * @returns {Promise<unknown[]>}
132
+ */
133
+ async function routeArgsFromRequest(request, verb) {
134
+ const v = String(verb || 'GET').toUpperCase();
135
+ if (v === 'GET' || v === 'HEAD' || v === 'OPTIONS')
136
+ return [];
137
+ const ctype = String(request.headers.get('content-type') || '');
138
+ if (ctype.includes('application/json')) {
139
+ const text = await request.text();
140
+ if (!text || !String(text).trim())
141
+ return [{}];
142
+ try {
143
+ return [JSON.parse(text)];
144
+ }
145
+ catch (err) {
146
+ throw new Error(`invalid JSON body: ${err instanceof Error ? err.message : String(err)}`);
147
+ }
148
+ }
149
+ if (ctype.includes('multipart/form-data')) {
150
+ // Parse from raw bytes — undici Request.formData() can UTF-8-mangle high bytes in file parts
151
+ // (0xFF/0xFE → U+FFFD), which breaks Upload binary / tool-site intakes.
152
+ const buf = Buffer.from(await request.arrayBuffer());
153
+ return [parseMultipartBuffer(buf, ctype)];
154
+ }
155
+ // Object-store PUT / resumable chunk PUT — keep bytes (never request.text()).
156
+ if (ctype.includes('application/octet-stream') ||
157
+ ((v === 'PUT' || v === 'PATCH') && !ctype.includes('json') && !ctype.includes('x-www-form-urlencoded'))) {
158
+ const buf = Buffer.from(await request.arrayBuffer());
159
+ const key = String(request.headers.get('x-vmz-object-key') || '');
160
+ const uploadId = String(request.headers.get('x-vmz-upload-id') || '');
161
+ const chunkIndexRaw = request.headers.get('x-vmz-chunk-index');
162
+ const chunkTotalRaw = request.headers.get('x-vmz-chunk-total');
163
+ const chunkIndex = chunkIndexRaw != null && String(chunkIndexRaw).trim() !== '' ? Number(chunkIndexRaw) : undefined;
164
+ const chunkTotal = chunkTotalRaw != null && String(chunkTotalRaw).trim() !== '' ? Number(chunkTotalRaw) : undefined;
165
+ return [
166
+ {
167
+ bytes: buf,
168
+ size: buf.byteLength,
169
+ key,
170
+ uploadId,
171
+ chunkIndex: Number.isFinite(chunkIndex) ? chunkIndex : undefined,
172
+ chunkTotal: Number.isFinite(chunkTotal) ? chunkTotal : undefined,
173
+ contentType: ctype || 'application/octet-stream',
174
+ },
175
+ ];
176
+ }
177
+ const raw = await request.text();
178
+ return [parseFormBody(raw, ctype)];
179
+ }
180
+ /**
181
+ * Web Standards Fetch entry for ServerArtifact hosts (Node adapter, worker/edge parity).
182
+ * Handles RPC + public ServerRoute only; static/SSR stay on Node host options.
183
+ * @param {Request} request
184
+ * @returns {Promise<Response>}
185
+ */
186
+ export async function handleFetchRequest(request) {
187
+ const url = new URL(request.url);
188
+ const verb = (request.method || 'GET').toUpperCase();
189
+ try {
190
+ if (verb === 'POST' && url.pathname === DEFAULT_RPC_PATH) {
191
+ const body = await request.json();
192
+ const result = await handleRpc(body);
193
+ return Response.json(result);
194
+ }
195
+ const route = matchRoute(verb, url.pathname);
196
+ if (route) {
197
+ const args = await routeArgsFromRequest(request, verb);
198
+ const result = await callServerLocal(route.moduleId, route.method, args);
199
+ return Response.json(result);
200
+ }
201
+ return Response.json({ error: 'not found', path: url.pathname }, { status: 404 });
202
+ }
203
+ catch (err) {
204
+ return Response.json({
205
+ error: err instanceof Error ? err.message : String(err),
206
+ }, { status: 500 });
207
+ }
208
+ }
109
209
  /**
110
210
  * Node `http.createServer` listener: RPC + REST + optional static / SSR index.
211
+ * RPC/REST go through {@link handleFetchRequest} so Node and Fetch hosts share one core.
111
212
  * @param {import('node:http').IncomingMessage} req
112
213
  * @param {import('node:http').ServerResponse} res
113
214
  * @param {NodeRequestOptions} [opts]
@@ -117,15 +218,12 @@ export async function handleNodeRequest(req, res, opts = {}) {
117
218
  const url = new URL(req.url || '/', `http://${host}`);
118
219
  const verb = (req.method || 'GET').toUpperCase();
119
220
  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
- }
221
+ const isRpc = verb === 'POST' && url.pathname === DEFAULT_RPC_PATH;
125
222
  const route = matchRoute(verb, url.pathname);
126
- if (route) {
127
- const result = await callServerLocal(route.moduleId, route.method, []);
128
- return sendJson(res, 200, result);
223
+ if (isRpc || route) {
224
+ const request = await incomingToRequest(req, url);
225
+ const response = await handleFetchRequest(request);
226
+ return await writeFetchResponse(res, response);
129
227
  }
130
228
  // Static first for assets + DocumentMount (`/d/…`) so docs aren't swallowed by SSR 404 shells.
131
229
  // web-static route HTML (`index.html`, `about/index.html`, …) is a CDN/deploy projection only —
@@ -247,6 +345,43 @@ export async function handleNodeRequest(req, res, opts = {}) {
247
345
  });
248
346
  }
249
347
  }
348
+ /**
349
+ * @param {import('node:http').IncomingMessage} req
350
+ * @param {URL} url
351
+ * @returns {Promise<Request>}
352
+ */
353
+ async function incomingToRequest(req, url) {
354
+ const method = (req.method || 'GET').toUpperCase();
355
+ /** @type {HeadersInit} */
356
+ const headers = {};
357
+ for (const [k, v] of Object.entries(req.headers)) {
358
+ if (v == null)
359
+ continue;
360
+ headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
361
+ }
362
+ if (method === 'GET' || method === 'HEAD') {
363
+ return new Request(url, { method, headers });
364
+ }
365
+ const raw = await readRawBody(req);
366
+ // Node undici requires duplex when constructing Request with a body.
367
+ return new Request(url, { method, headers, body: raw, duplex: 'half' });
368
+ }
369
+ /**
370
+ * @param {import('node:http').ServerResponse} res
371
+ * @param {Response} response
372
+ */
373
+ async function writeFetchResponse(res, response) {
374
+ const headers = {};
375
+ response.headers.forEach((value, key) => {
376
+ headers[key] = value;
377
+ });
378
+ const buf = Buffer.from(await response.arrayBuffer());
379
+ if (!headers['content-length'] && !headers['Content-Length']) {
380
+ headers['content-length'] = String(buf.byteLength);
381
+ }
382
+ res.writeHead(response.status, headers);
383
+ res.end(buf);
384
+ }
250
385
  /**
251
386
  * Resolve a path under distDir; reject `..` escapes.
252
387
  * @param {string} distDir
@@ -402,16 +537,103 @@ function readJson(req) {
402
537
  }
403
538
  /**
404
539
  * @param {import('node:http').IncomingMessage} req
405
- * @returns {Promise<string>}
540
+ * @returns {Promise<Buffer>}
406
541
  */
407
542
  function readRawBody(req) {
408
543
  return new Promise((resolve, reject) => {
409
544
  const chunks = [];
410
545
  req.on('data', (c) => chunks.push(c));
411
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
546
+ // Buffer — never utf8-string: multipart File bytes must survive (Upload binary gate).
547
+ req.on('end', () => resolve(Buffer.concat(chunks)));
412
548
  req.on('error', reject);
413
549
  });
414
550
  }
551
+ /**
552
+ * Buffer-safe multipart/form-data parser (file parts stay binary).
553
+ * @param {Buffer} buf
554
+ * @param {string} contentType
555
+ * @returns {Record<string, unknown>}
556
+ */
557
+ function parseMultipartBuffer(buf, contentType) {
558
+ const bm = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(String(contentType || ''));
559
+ const boundary = bm ? bm[1] || bm[2] : '';
560
+ if (!boundary) {
561
+ throw new Error('multipart: missing boundary');
562
+ }
563
+ const sep = Buffer.from(`--${boundary}`);
564
+ /** @type {Record<string, unknown>} */
565
+ const out = {};
566
+ let start = indexOfBuffer(buf, sep, 0);
567
+ if (start < 0)
568
+ return out;
569
+ start += sep.length;
570
+ // Optional leading CRLF after first boundary is handled per-part.
571
+ while (start < buf.length) {
572
+ if (buf[start] === 0x2d && buf[start + 1] === 0x2d)
573
+ break; // trailing --
574
+ if (buf[start] === 0x0d && buf[start + 1] === 0x0a)
575
+ start += 2;
576
+ const next = indexOfBuffer(buf, sep, start);
577
+ const end = next < 0 ? buf.length : next;
578
+ let part = buf.subarray(start, end);
579
+ // Trim trailing CRLF before boundary.
580
+ if (part.length >= 2 && part[part.length - 2] === 0x0d && part[part.length - 1] === 0x0a) {
581
+ part = part.subarray(0, part.length - 2);
582
+ }
583
+ const splitAt = indexOfBuffer(part, Buffer.from('\r\n\r\n'), 0);
584
+ if (splitAt >= 0) {
585
+ const headerText = part.subarray(0, splitAt).toString('utf8');
586
+ let body = part.subarray(splitAt + 4);
587
+ const nameM = /content-disposition:[^\r\n]*;\s*name="([^"]*)"/i.exec(headerText);
588
+ const fileM = /content-disposition:[^\r\n]*;\s*filename="([^"]*)"/i.exec(headerText);
589
+ const typeM = /content-type:\s*([^\r\n]+)/i.exec(headerText);
590
+ const key = nameM ? nameM[1] : '';
591
+ if (key) {
592
+ if (fileM) {
593
+ const filename = fileM[1] || 'upload.bin';
594
+ const type = typeM ? String(typeM[1]).trim() : 'application/octet-stream';
595
+ // Copy body — File may outlive the request buffer.
596
+ const copy = Buffer.from(body);
597
+ const file = new File([copy], filename, { type });
598
+ const prev = out[key];
599
+ if (prev == null) {
600
+ out[key] = file;
601
+ }
602
+ else if (Array.isArray(prev)) {
603
+ prev.push(file);
604
+ }
605
+ else {
606
+ out[key] = [prev, file];
607
+ }
608
+ }
609
+ else {
610
+ out[key] = body.toString('utf8');
611
+ }
612
+ }
613
+ }
614
+ if (next < 0)
615
+ break;
616
+ start = next + sep.length;
617
+ }
618
+ return out;
619
+ }
620
+ /**
621
+ * @param {Buffer} hay
622
+ * @param {Buffer} needle
623
+ * @param {number} from
624
+ */
625
+ function indexOfBuffer(hay, needle, from) {
626
+ if (!needle.length)
627
+ return from;
628
+ outer: for (let i = Math.max(0, from); i <= hay.length - needle.length; i++) {
629
+ for (let j = 0; j < needle.length; j++) {
630
+ if (hay[i + j] !== needle[j])
631
+ continue outer;
632
+ }
633
+ return i;
634
+ }
635
+ return -1;
636
+ }
415
637
  /**
416
638
  * @param {string} raw
417
639
  * @param {string} contentType
@@ -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.4",
3
+ "version": "0.1.1",
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"
@@ -38,6 +42,9 @@
38
42
  "scripts": {
39
43
  "build": "tsc -p tsconfig.json && node ../../../scripts/build/copy-serve-host-mjs.mjs"
40
44
  },
45
+ "dependencies": {
46
+ "linkedom": "^0.18.13"
47
+ },
41
48
  "publishConfig": {
42
49
  "access": "public"
43
50
  },