@vmz/core 0.0.0 → 0.0.2

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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Minimal VMZ runtime — `#server` invoke + RPC/REST HTTP + optional static/SSR.
3
+ *
4
+ * Design: 规划设计/vmz/08-虚拟server.md, 07-REST接口.md
5
+ *
6
+ * Browser-safe: no static `node:*` imports. Node builtins are loaded only inside
7
+ * Node/SSR request handlers so client bundles can `import { callServer }`.
8
+ */
9
+ /**
10
+ * Map `#server/foo` → filesystem / URL the host can `import()`.
11
+ * Only set this in Node/SSR hosts — browser bundles must omit it so RPC goes HTTP.
12
+ */
13
+ export declare function setServerModuleResolver(fn: any): void;
14
+ /** @param {Route[]} next */
15
+ export declare function setRoutes(next: any): void;
16
+ /**
17
+ * @param {string} moduleId
18
+ * @param {string} method
19
+ * @param {unknown[]} args
20
+ */
21
+ export declare function callServer(moduleId: any, method: any, args?: any[]): Promise<any>;
22
+ /**
23
+ * @param {RpcRequest} body
24
+ */
25
+ export declare function handleRpc(body: any): Promise<any>;
26
+ /**
27
+ * Match REST route from `vmz-routes.json`.
28
+ * @param {string} verb
29
+ * @param {string} pathname
30
+ * @returns {Route | null}
31
+ */
32
+ export declare function matchRoute(verb: any, pathname: any): any;
33
+ /**
34
+ * Node `http.createServer` listener: RPC + REST + optional static / SSR index.
35
+ * @param {import('node:http').IncomingMessage} req
36
+ * @param {import('node:http').ServerResponse} res
37
+ * @param {NodeRequestOptions} [opts]
38
+ */
39
+ export declare function handleNodeRequest(req: any, res: any, opts?: {}): Promise<void>;
package/dist/server.js ADDED
@@ -0,0 +1,437 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Minimal VMZ runtime — `#server` invoke + RPC/REST HTTP + optional static/SSR.
4
+ *
5
+ * Design: 规划设计/vmz/08-虚拟server.md, 07-REST接口.md
6
+ *
7
+ * Browser-safe: no static `node:*` imports. Node builtins are loaded only inside
8
+ * Node/SSR request handlers so client bundles can `import { callServer }`.
9
+ */
10
+ /** @typedef {{ moduleId: string, method: string, args?: unknown[] }} RpcRequest */
11
+ /** @typedef {{ verb: string, path: string, moduleId: string, method: string, className?: string }} Route */
12
+ /**
13
+ * @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,
20
+ * }} NodeRequestOptions
21
+ */
22
+ const DEFAULT_RPC_PATH = '/__vmz/rpc';
23
+ /** @type {((id: string) => string | URL) | null} */
24
+ let resolveServerModule = null;
25
+ /** @type {Route[]} */
26
+ let routes = [];
27
+ /**
28
+ * Map `#server/foo` → filesystem / URL the host can `import()`.
29
+ * Only set this in Node/SSR hosts — browser bundles must omit it so RPC goes HTTP.
30
+ */
31
+ export function setServerModuleResolver(fn) {
32
+ resolveServerModule = fn;
33
+ }
34
+ /** @param {Route[]} next */
35
+ export function setRoutes(next) {
36
+ routes = Array.isArray(next) ? next : [];
37
+ }
38
+ /**
39
+ * @param {string} moduleId
40
+ * @param {string} method
41
+ * @param {unknown[]} args
42
+ */
43
+ export async function callServer(moduleId, method, args = []) {
44
+ // Browser (or tests forcing HTTP): never touch server modules in-process.
45
+ // Node SSR / smokes set a resolver and leave __VMZ_USE_HTTP_RPC unset → local.
46
+ if (globalThis.__VMZ_USE_HTTP_RPC || !resolveServerModule) {
47
+ return callServerHttp(moduleId, method, args);
48
+ }
49
+ return callServerLocal(moduleId, method, args);
50
+ }
51
+ /**
52
+ * @param {string} moduleId
53
+ * @param {string} method
54
+ * @param {unknown[]} args
55
+ */
56
+ async function callServerLocal(moduleId, method, args) {
57
+ if (!resolveServerModule) {
58
+ throw new Error(`vmz:runtime callServer(${moduleId}): setServerModuleResolver() required in Node`);
59
+ }
60
+ const spec = resolveServerModule(moduleId);
61
+ const mod = await import(spec);
62
+ const Ctor = mod.default ?? mod[exportGuess(moduleId)];
63
+ if (typeof Ctor !== 'function') {
64
+ throw new Error(`vmz:runtime: no class export for ${moduleId}`);
65
+ }
66
+ const instance = new Ctor();
67
+ const fn = instance[method];
68
+ if (typeof fn !== 'function') {
69
+ throw new Error(`vmz:runtime: ${moduleId}.${method} is not a function`);
70
+ }
71
+ return fn.apply(instance, args);
72
+ }
73
+ /**
74
+ * @param {string} moduleId
75
+ * @param {string} method
76
+ * @param {unknown[]} args
77
+ */
78
+ async function callServerHttp(moduleId, method, args) {
79
+ const rpcPath = (typeof globalThis !== 'undefined' && globalThis.__VMZ_RPC_PATH) || DEFAULT_RPC_PATH;
80
+ const res = await fetch(rpcPath, {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json' },
83
+ body: JSON.stringify({ moduleId, method, args }),
84
+ });
85
+ if (!res.ok) {
86
+ throw new Error(`vmz:runtime RPC ${res.status} for ${moduleId}.${method}`);
87
+ }
88
+ return res.json();
89
+ }
90
+ function exportGuess(moduleId) {
91
+ const base = moduleId.split('/').pop() || 'Server';
92
+ return `${base}Server`;
93
+ }
94
+ /**
95
+ * @param {RpcRequest} body
96
+ */
97
+ export async function handleRpc(body) {
98
+ return callServerLocal(body.moduleId, body.method, body.args ?? []);
99
+ }
100
+ /**
101
+ * Match REST route from `vmz-routes.json`.
102
+ * @param {string} verb
103
+ * @param {string} pathname
104
+ * @returns {Route | null}
105
+ */
106
+ export function matchRoute(verb, pathname) {
107
+ const v = verb.toUpperCase();
108
+ return routes.find((r) => r.verb.toUpperCase() === v && r.path === pathname) ?? null;
109
+ }
110
+ /**
111
+ * Node `http.createServer` listener: RPC + REST + optional static / SSR index.
112
+ * @param {import('node:http').IncomingMessage} req
113
+ * @param {import('node:http').ServerResponse} res
114
+ * @param {NodeRequestOptions} [opts]
115
+ */
116
+ export async function handleNodeRequest(req, res, opts = {}) {
117
+ const host = req.headers.host || '127.0.0.1';
118
+ const url = new URL(req.url || '/', `http://${host}`);
119
+ const verb = (req.method || 'GET').toUpperCase();
120
+ try {
121
+ if (verb === 'POST' && url.pathname === DEFAULT_RPC_PATH) {
122
+ const body = await readJson(req);
123
+ const result = await handleRpc(body);
124
+ return sendJson(res, 200, result);
125
+ }
126
+ const route = matchRoute(verb, url.pathname);
127
+ if (route) {
128
+ const result = await callServerLocal(route.moduleId, route.method, []);
129
+ return sendJson(res, 200, result);
130
+ }
131
+ // Static first (integrated DocumentMount + assets) so /d/… is not swallowed by SSR 404 shells.
132
+ if (verb === 'GET' && opts.distDir) {
133
+ const nodePath = await import('node:path');
134
+ const { readFile, stat } = await import('node:fs/promises');
135
+ const file = await resolveDistStatic(opts.distDir, url.pathname, nodePath, stat);
136
+ if (file) {
137
+ try {
138
+ const body = await readFile(file);
139
+ return sendBytes(res, 200, body, contentType(file, nodePath));
140
+ }
141
+ catch (err) {
142
+ if (err && (err.code === 'ENOENT' || err.code === 'EISDIR')) {
143
+ /* fall through */
144
+ }
145
+ else {
146
+ throw err;
147
+ }
148
+ }
149
+ }
150
+ }
151
+ if (verb === 'GET' && (opts.renderPageStream || opts.renderPage || opts.renderIndexStream || opts.renderIndex)) {
152
+ const ac = new AbortController();
153
+ const onClose = () => {
154
+ try {
155
+ ac.abort();
156
+ }
157
+ catch {
158
+ /* ignore */
159
+ }
160
+ };
161
+ req.on('close', onClose);
162
+ req.on('aborted', onClose);
163
+ try {
164
+ if (typeof opts.renderPageStream === 'function') {
165
+ const rendered = await opts.renderPageStream(url.pathname, {
166
+ signal: ac.signal,
167
+ searchParams: url.searchParams,
168
+ cookieHeader: String(req.headers.cookie || ''),
169
+ });
170
+ if (rendered) {
171
+ const status = rendered && typeof rendered === 'object' && 'status' in rendered ? Number(rendered.status) || 200 : 200;
172
+ const stream = rendered && typeof rendered === 'object' && rendered.stream ? rendered.stream : rendered;
173
+ return await sendHtmlStream(res, status, stream, ac.signal);
174
+ }
175
+ }
176
+ else if (typeof opts.renderPage === 'function') {
177
+ const html = await opts.renderPage(url.pathname);
178
+ if (html != null) {
179
+ return sendHtml(res, 200, html);
180
+ }
181
+ }
182
+ else if (url.pathname === '/' || url.pathname === '/index.html') {
183
+ // Legacy index-only SSR (pre multi-page file routes).
184
+ if (typeof opts.renderIndexStream === 'function') {
185
+ return await sendHtmlStream(res, 200, opts.renderIndexStream({ signal: ac.signal }), ac.signal);
186
+ }
187
+ if (typeof opts.renderIndex === 'function') {
188
+ return sendHtml(res, 200, await opts.renderIndex());
189
+ }
190
+ }
191
+ }
192
+ finally {
193
+ req.off('close', onClose);
194
+ req.off('aborted', onClose);
195
+ }
196
+ }
197
+ if (!res.headersSent) {
198
+ sendJson(res, 404, { error: 'not found', path: url.pathname });
199
+ }
200
+ }
201
+ catch (err) {
202
+ if (res.headersSent || res.writableEnded || res.destroyed) {
203
+ console.error('vmz serve: request failed after headers', err);
204
+ try {
205
+ res.destroy(err instanceof Error ? err : undefined);
206
+ }
207
+ catch {
208
+ /* ignore */
209
+ }
210
+ return;
211
+ }
212
+ sendJson(res, 500, {
213
+ error: err instanceof Error ? err.message : String(err),
214
+ });
215
+ }
216
+ }
217
+ /**
218
+ * Resolve a path under distDir; reject `..` escapes.
219
+ * @param {string} distDir
220
+ * @param {string} pathname
221
+ * @param {typeof import('node:path')} nodePath
222
+ * @returns {string | null}
223
+ */
224
+ function safeDistFile(distDir, pathname, nodePath) {
225
+ let rel = decodeURIComponent(pathname.split('?')[0] || '/');
226
+ if (rel === '/' || rel === '')
227
+ return null;
228
+ if (rel.startsWith('/'))
229
+ rel = rel.slice(1);
230
+ if (!rel || rel.includes('\0'))
231
+ return null;
232
+ const root = nodePath.resolve(distDir);
233
+ const full = nodePath.resolve(root, rel);
234
+ if (full !== root && !full.startsWith(root + nodePath.sep))
235
+ return null;
236
+ return full;
237
+ }
238
+ /**
239
+ * Static resolve for app assets + integrated document mounts:
240
+ * `/d/` → `d/index.html`, `/d/zh-hans/guide` → `d/zh-hans/guide.html`.
241
+ * @param {string} distDir
242
+ * @param {string} pathname
243
+ * @param {typeof import('node:path')} nodePath
244
+ * @param {typeof import('node:fs/promises').stat} stat
245
+ * @returns {Promise<string | null>}
246
+ */
247
+ async function resolveDistStatic(distDir, pathname, nodePath, stat) {
248
+ const candidates = staticPathCandidates(pathname);
249
+ for (const candidate of candidates) {
250
+ const full = safeDistFile(distDir, candidate, nodePath);
251
+ if (!full)
252
+ continue;
253
+ try {
254
+ const st = await stat(full);
255
+ if (st.isFile())
256
+ return full;
257
+ if (st.isDirectory()) {
258
+ const index = nodePath.join(full, 'index.html');
259
+ const indexSt = await stat(index).catch(() => null);
260
+ if (indexSt && indexSt.isFile())
261
+ return index;
262
+ }
263
+ }
264
+ catch {
265
+ /* try next */
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+ /**
271
+ * @param {string} pathname
272
+ * @returns {string[]}
273
+ */
274
+ function staticPathCandidates(pathname) {
275
+ let rel = decodeURIComponent(pathname.split('?')[0] || '/');
276
+ if (!rel.startsWith('/'))
277
+ rel = `/${rel}`;
278
+ /** @type {string[]} */
279
+ const out = [];
280
+ const push = (p) => {
281
+ if (p && !out.includes(p))
282
+ out.push(p);
283
+ };
284
+ push(rel);
285
+ if (rel.endsWith('/')) {
286
+ push(`${rel}index.html`);
287
+ const trimmed = rel.replace(/\/+$/, '');
288
+ if (trimmed) {
289
+ push(`${trimmed}.html`);
290
+ push(trimmed);
291
+ }
292
+ }
293
+ else {
294
+ push(`${rel}/`);
295
+ push(`${rel}/index.html`);
296
+ if (!nodePathExt(rel)) {
297
+ push(`${rel}.html`);
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+ /** @param {string} p */
303
+ function nodePathExt(p) {
304
+ const base = p.split('/').pop() || '';
305
+ const i = base.lastIndexOf('.');
306
+ return i > 0 ? base.slice(i) : '';
307
+ }
308
+ /**
309
+ * @param {string} filePath
310
+ * @param {typeof import('node:path')} nodePath
311
+ */
312
+ function contentType(filePath, nodePath) {
313
+ const ext = nodePath.extname(filePath).toLowerCase();
314
+ switch (ext) {
315
+ case '.html':
316
+ return 'text/html; charset=utf-8';
317
+ case '.js':
318
+ case '.mjs':
319
+ return 'text/javascript; charset=utf-8';
320
+ case '.css':
321
+ return 'text/css; charset=utf-8';
322
+ case '.json':
323
+ return 'application/json; charset=utf-8';
324
+ case '.svg':
325
+ return 'image/svg+xml';
326
+ case '.map':
327
+ return 'application/json; charset=utf-8';
328
+ default:
329
+ return 'application/octet-stream';
330
+ }
331
+ }
332
+ /**
333
+ * @param {import('node:http').IncomingMessage} req
334
+ */
335
+ function readJson(req) {
336
+ return new Promise((resolve, reject) => {
337
+ const chunks = [];
338
+ req.on('data', (c) => chunks.push(c));
339
+ req.on('end', () => {
340
+ try {
341
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
342
+ resolve(JSON.parse(raw));
343
+ }
344
+ catch (e) {
345
+ reject(e);
346
+ }
347
+ });
348
+ req.on('error', reject);
349
+ });
350
+ }
351
+ /**
352
+ * @param {import('node:http').ServerResponse} res
353
+ * @param {number} status
354
+ * @param {unknown} body
355
+ */
356
+ function sendJson(res, status, body) {
357
+ const payload = JSON.stringify(body);
358
+ res.writeHead(status, {
359
+ 'content-type': 'application/json; charset=utf-8',
360
+ 'content-length': Buffer.byteLength(payload),
361
+ });
362
+ res.end(payload);
363
+ }
364
+ /**
365
+ * @param {import('node:http').ServerResponse} res
366
+ * @param {number} status
367
+ * @param {string} html
368
+ */
369
+ function sendHtml(res, status, html) {
370
+ const payload = typeof html === 'string' ? html : String(html);
371
+ res.writeHead(status, {
372
+ 'content-type': 'text/html; charset=utf-8',
373
+ 'content-length': Buffer.byteLength(payload),
374
+ });
375
+ res.end(payload);
376
+ }
377
+ /**
378
+ * Stream HTML without buffering the full document (event-flow / stream SSR host).
379
+ * Honors AbortSignal + response destroy for cancel; awaits drain for backpressure.
380
+ * @param {import('node:http').ServerResponse} res
381
+ * @param {number} status
382
+ * @param {AsyncIterable<string> | Iterable<string> | AsyncGenerator<string, any, any>} source
383
+ * @param {AbortSignal} [signal]
384
+ */
385
+ async function sendHtmlStream(res, status, source, signal) {
386
+ res.writeHead(status, {
387
+ 'content-type': 'text/html; charset=utf-8',
388
+ 'transfer-encoding': 'chunked',
389
+ 'cache-control': 'no-cache',
390
+ });
391
+ const aborted = () => Boolean(signal?.aborted || res.destroyed || res.writableEnded || !res.writable);
392
+ try {
393
+ for await (const chunk of source) {
394
+ if (aborted())
395
+ break;
396
+ if (chunk == null || chunk === '')
397
+ continue;
398
+ const s = typeof chunk === 'string' ? chunk : String(chunk);
399
+ const ok = res.write(s);
400
+ if (!ok) {
401
+ await Promise.race([
402
+ new Promise((resolve) => res.once('drain', resolve)),
403
+ new Promise((resolve) => {
404
+ if (!signal)
405
+ return;
406
+ if (signal.aborted)
407
+ return resolve();
408
+ signal.addEventListener('abort', () => resolve(), { once: true });
409
+ }),
410
+ new Promise((resolve) => res.once('close', resolve)),
411
+ ]);
412
+ if (aborted())
413
+ break;
414
+ }
415
+ }
416
+ }
417
+ catch (err) {
418
+ if (!aborted())
419
+ throw err;
420
+ }
421
+ if (!res.writableEnded && !res.destroyed) {
422
+ res.end();
423
+ }
424
+ }
425
+ /**
426
+ * @param {import('node:http').ServerResponse} res
427
+ * @param {number} status
428
+ * @param {Buffer} body
429
+ * @param {string} type
430
+ */
431
+ function sendBytes(res, status, body, type) {
432
+ res.writeHead(status, {
433
+ 'content-type': type,
434
+ 'content-length': body.byteLength,
435
+ });
436
+ res.end(body);
437
+ }
package/package.json CHANGED
@@ -1,10 +1,44 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.0.0",
4
- "description": "VMZ placeholder — not for production use.",
5
- "license": "MIT",
6
- "private": false,
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/server.js"
10
+ },
11
+ "./dom": {
12
+ "types": "./dist/dom.d.ts",
13
+ "default": "./dist/dom.js"
14
+ },
15
+ "./server": {
16
+ "types": "./dist/server.d.ts",
17
+ "default": "./dist/server.js"
18
+ },
19
+ "./http": {
20
+ "types": "./dist/http.d.ts",
21
+ "default": "./dist/http.js"
22
+ }
23
+ },
7
24
  "files": [
25
+ "dist",
8
26
  "README.md"
9
- ]
27
+ ],
28
+ "keywords": [
29
+ "vmz",
30
+ "runtime"
31
+ ],
32
+ "main": "./dist/server.js",
33
+ "types": "./dist/index.d.ts",
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json && node ../../../scripts/copy-serve-host-mjs.mjs"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/doki-land/vmz-framework.git"
43
+ }
10
44
  }