@vmz/core 0.0.1 → 0.0.3

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