@vmz/core 0.0.4 → 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.
@@ -1 +1,14 @@
1
+ /**
2
+ * Generic VMZ Node host — SSR file-route pages + dist static + RPC/REST.
3
+ *
4
+ * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
5
+ *
6
+ * Pathname → `pages/**` (PascalCase stem → lowercase URL; `index` → parent;
7
+ * `[Param]` / `[...rest]` dynamic). Not an SPA shell.
8
+ *
9
+ * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
10
+ * GET `/__vmz/events` SSE notifies the browser:
11
+ * - island HMR → re-import `entry-client.js` (no full document reload)
12
+ * - otherwise → `location.reload`
13
+ */
1
14
  export {};
@@ -12,13 +12,13 @@
12
12
  * - island HMR → re-import `entry-client.js` (no full document reload)
13
13
  * - otherwise → `location.reload`
14
14
  */
15
+ import { existsSync } from 'node:fs';
16
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
15
17
  import http from 'node:http';
16
18
  import path from 'node:path';
17
- import { readdir, writeFile, readFile } from 'node:fs/promises';
18
- import { existsSync } from 'node:fs';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
- import { setServerModuleResolver, setRoutes, handleNodeRequest } from './vmz-runtime.js';
21
20
  import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
21
+ import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
22
22
  const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
23
23
  const host = process.env.VMZ_HOST || '127.0.0.1';
24
24
  const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
@@ -33,6 +33,8 @@ const pageCtors = new Map();
33
33
  let cssEntry = null;
34
34
  /** @type {{ defaultThemeId: string, themeIds: string[], activationAttr: string, contentHash: string|null } | null} */
35
35
  let styleTheme = null;
36
+ /** Locale route realization artifact from `_vmz/locale-route-realization.json` (optional). */
37
+ let localeArtifact = null;
36
38
  /** @type {Set<import('node:http').ServerResponse>} */
37
39
  const sseClients = new Set();
38
40
  /** In-flight HTTP requests (graceful shutdown drain). */
@@ -94,9 +96,14 @@ async function renderPageStream(pathname, opts = {}) {
94
96
  if (isDev && lastDevError && pageCtors.size === 0) {
95
97
  return { status: 500, stream: emitDevErrorHtml(lastDevError) };
96
98
  }
97
- let match = matchFileRoute(pathname, pageCatalog);
99
+ const localePlan = resolveLocalePath(pathname);
100
+ if (localePlan.redirectTo) {
101
+ return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
102
+ }
103
+ const routePath = localePlan.restPath || pathname;
104
+ let match = matchFileRoute(routePath, pageCatalog);
98
105
  let status = 200;
99
- const gated = await runRouteGate(pathname, match?.chunkId);
106
+ const gated = await runRouteGate(routePath, match?.chunkId);
100
107
  if (gated === 'not_found') {
101
108
  match = findRootCatchAll(pageCatalog);
102
109
  status = 404;
@@ -121,16 +128,24 @@ async function renderPageStream(pathname, opts = {}) {
121
128
  }
122
129
  return null;
123
130
  }
124
- const params = extractRouteParams(match.segs, pathname);
131
+ const params = extractRouteParams(match.segs, routePath);
125
132
  const method = String(opts.method || 'GET').toUpperCase();
133
+ const localeCtx = {
134
+ localeId: localePlan.localeId,
135
+ dir: localePlan.dir,
136
+ pathname,
137
+ routePath,
138
+ alternates: pageMetaAlternates(match.chunkId, localePlan.localeId),
139
+ };
126
140
  if (typeof Page.access === 'function') {
127
141
  const access = await Page.access({
128
142
  params,
129
- pathname,
143
+ pathname: routePath,
130
144
  chunkId: match.chunkId,
131
145
  signal: opts.signal,
132
146
  searchParams: opts.searchParams,
133
147
  method,
148
+ localeId: localeCtx.localeId,
134
149
  });
135
150
  const closed = normalizeAccessResult(access);
136
151
  if (closed.kind === 'redirect') {
@@ -148,7 +163,7 @@ async function renderPageStream(pathname, opts = {}) {
148
163
  const eventOnlyShell = isEventOnlyShell(resumeEntries.map((e) => e.strategy));
149
164
  return {
150
165
  status: 404,
151
- stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts),
166
+ stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts, [], localeCtx),
152
167
  };
153
168
  }
154
169
  }
@@ -159,12 +174,13 @@ async function renderPageStream(pathname, opts = {}) {
159
174
  if (method === 'POST' && typeof Page.action === 'function') {
160
175
  const acted = await Page.action({
161
176
  params,
162
- pathname,
177
+ pathname: routePath,
163
178
  chunkId: match.chunkId,
164
179
  signal: opts.signal,
165
180
  searchParams: opts.searchParams,
166
181
  body: opts.body,
167
182
  method,
183
+ localeId: localeCtx.localeId,
168
184
  });
169
185
  const actionClosed = normalizeActionResult(acted);
170
186
  if (actionClosed.kind === 'redirect') {
@@ -183,10 +199,11 @@ async function renderPageStream(pathname, opts = {}) {
183
199
  if (typeof Page.load === 'function') {
184
200
  const loaded = await Page.load({
185
201
  params,
186
- pathname,
202
+ pathname: routePath,
187
203
  chunkId: match.chunkId,
188
204
  signal: opts.signal,
189
205
  searchParams: opts.searchParams,
206
+ localeId: localeCtx.localeId,
190
207
  });
191
208
  if (opts.signal?.aborted) {
192
209
  return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
@@ -204,7 +221,7 @@ async function renderPageStream(pathname, opts = {}) {
204
221
  const layoutChain = resolveLayoutChain(match.chunkId);
205
222
  return {
206
223
  status,
207
- stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain),
224
+ stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain, localeCtx),
208
225
  };
209
226
  }
210
227
  /**
@@ -274,7 +291,7 @@ async function* emitAccessShell(marker) {
274
291
  * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
275
292
  * @param {string[]} [layoutChain] layout chunk ids outer→inner
276
293
  */
277
- async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = []) {
294
+ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = [], localeCtx = {}) {
278
295
  const signal = opts.signal;
279
296
  const live = isDev
280
297
  ? `\n <script>
@@ -381,15 +398,31 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
381
398
  const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
382
399
  const propsJson = JSON.stringify(props ?? {});
383
400
  const layoutAttr = layoutChain.length ? ` data-vmz-layout="${escapeAttr(layoutChain.join(','))}"` : '';
401
+ const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
402
+ const dir = localeCtx.dir || 'ltr';
403
+ const localeAttr = ` data-vmz-locale="${escapeAttr(localeId)}" data-vmz-dir="${escapeAttr(dir)}"`;
404
+ const routingJson = localeArtifact?.routing
405
+ ? escapeAttr(JSON.stringify({
406
+ strategy: localeArtifact.routing.strategy || 'prefix',
407
+ defaultPrefix: localeArtifact.routing.defaultPrefix || 'include',
408
+ defaultLocale: localeArtifact.defaultLocale,
409
+ locales: (localeArtifact.locales || []).map((l) => l.id),
410
+ }))
411
+ : '';
412
+ const routingAttr = routingJson ? ` data-vmz-locale-routing="${routingJson}"` : '';
413
+ const hreflangLinks = (localeCtx.alternates || [])
414
+ .map((a) => ` <link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}" />`)
415
+ .join('\n');
416
+ const hreflangBlock = hreflangLinks ? `${hreflangLinks}\n` : '';
384
417
  yield `<!DOCTYPE html>
385
- <html lang="en"${htmlTheme}>
418
+ <html lang="${escapeAttr(localeId)}" data-locale="${escapeAttr(localeId)}" dir="${escapeAttr(dir)}"${routingAttr}${htmlTheme}>
386
419
  <head>
387
420
  <meta charset="utf-8" />
388
421
  <meta name="viewport" content="width=device-width, initial-scale=1" />
389
422
  <title>VMZ</title>
390
- ${themeBoot}${cssLink}</head>
423
+ ${hreflangBlock}${themeBoot}${cssLink}</head>
391
424
  <body>
392
- <div id="app" data-vmz-page="${escapeAttr(chunkId)}"${layoutAttr} data-vmz-props="${escapeAttr(propsJson)}">`;
425
+ <div id="app" data-vmz-page="${escapeAttr(chunkId)}"${layoutAttr}${localeAttr} data-vmz-props="${escapeAttr(propsJson)}">`;
393
426
  let bodyHtml = '';
394
427
  for await (const chunk of renderToStream(Page, props, { signal })) {
395
428
  if (signal?.aborted)
@@ -407,6 +440,10 @@ ${themeBoot}${cssLink}</head>
407
440
  if (signal?.aborted)
408
441
  return;
409
442
  }
443
+ // Locale discipline: same-app Links retain current LocaleId (realization authority).
444
+ if (localeArtifact && localeId) {
445
+ bodyHtml = localizeBodyLinksInHost(bodyHtml, localeId, localeArtifact);
446
+ }
410
447
  yield bodyHtml;
411
448
  if (signal?.aborted)
412
449
  return;
@@ -547,6 +584,12 @@ async function softReload(opts = {}) {
547
584
  catch {
548
585
  setRoutes([]);
549
586
  }
587
+ try {
588
+ localeArtifact = JSON.parse(await readFile(path.join(distDir, '_vmz', 'locale-route-realization.json'), 'utf8'));
589
+ }
590
+ catch {
591
+ localeArtifact = null;
592
+ }
550
593
  const componentEntries = await listClientComponents(distDir);
551
594
  const nextCatalog = await listPageClientFiles(distDir);
552
595
  if (!nextCatalog.length) {
@@ -697,6 +740,130 @@ async function* emitDevErrorHtml(err) {
697
740
  function escapeHtml(s) {
698
741
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
699
742
  }
743
+ /**
744
+ * Resolve LocaleId from pathname using `_vmz/locale-route-realization.json`.
745
+ * LocaleId is a realization dimension — matching still uses stable route path.
746
+ * @param {string} pathname
747
+ */
748
+ function resolveLocalePath(pathname) {
749
+ const raw = String(pathname || '/');
750
+ const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
751
+ if (!localeArtifact) {
752
+ return { localeId: 'en', dir: 'ltr', restPath: normalized, redirectTo: null };
753
+ }
754
+ const supported = (localeArtifact.locales || []).map((l) => l.id);
755
+ const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
756
+ const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
757
+ const routing = localeArtifact.routing || {};
758
+ const parts = normalized.split('/').filter(Boolean);
759
+ let localeId = null;
760
+ let restPath = normalized;
761
+ if (parts.length && supported.includes(parts[0])) {
762
+ localeId = parts[0];
763
+ const rest = parts.slice(1);
764
+ restPath = rest.length ? `/${rest.join('/')}` : '/';
765
+ }
766
+ // omit defaultPrefix: prefixed defaultLocale URL redirects to unprefixed canonical.
767
+ if (routing.defaultPrefix === 'omit' && localeId === defaultLocale) {
768
+ return {
769
+ localeId: defaultLocale,
770
+ dir: directions[defaultLocale] || 'ltr',
771
+ restPath,
772
+ redirectTo: restPath,
773
+ };
774
+ }
775
+ const contentLocale = localeId || defaultLocale;
776
+ return {
777
+ localeId: contentLocale,
778
+ dir: directions[contentLocale] || 'ltr',
779
+ restPath,
780
+ redirectTo: null,
781
+ };
782
+ }
783
+ /**
784
+ * Realize href for current LocaleId (prefix strategy). Kept local so serve-host
785
+ * stays free of CLI package imports.
786
+ * @param {string} href
787
+ * @param {string} localeId
788
+ * @param {any} artifact
789
+ */
790
+ function localizeSameAppHrefHost(href, localeId, artifact) {
791
+ if (!href || !localeId || !artifact)
792
+ return href;
793
+ if (href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href))
794
+ return href;
795
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('/'))
796
+ return href;
797
+ let pathname = String(href);
798
+ let search = '';
799
+ let hash = '';
800
+ const hashIdx = pathname.indexOf('#');
801
+ if (hashIdx >= 0) {
802
+ hash = pathname.slice(hashIdx);
803
+ pathname = pathname.slice(0, hashIdx);
804
+ }
805
+ const qIdx = pathname.indexOf('?');
806
+ if (qIdx >= 0) {
807
+ search = pathname.slice(qIdx);
808
+ pathname = pathname.slice(0, qIdx);
809
+ }
810
+ if (!pathname)
811
+ pathname = '/';
812
+ const supported = (artifact.locales || []).map((l) => l.id).filter(Boolean);
813
+ const defaultLocale = artifact.defaultLocale || artifact.routing?.defaultLocale;
814
+ const routing = artifact.routing || {};
815
+ const strategy = routing.strategy || 'prefix';
816
+ const defaultPrefix = routing.defaultPrefix || 'include';
817
+ const parts = pathname.split('/').filter(Boolean);
818
+ let rest = pathname;
819
+ if (parts.length && supported.includes(parts[0])) {
820
+ const r = parts.slice(1);
821
+ rest = r.length ? `/${r.join('/')}` : '/';
822
+ }
823
+ if (rest.length > 1 && rest.endsWith('/'))
824
+ rest = rest.slice(0, -1);
825
+ if (!rest.startsWith('/'))
826
+ rest = `/${rest}`;
827
+ if (strategy === 'none' || strategy === 'domain')
828
+ return `${rest}${search}${hash}`;
829
+ const omitDefault = defaultPrefix === 'omit' && localeId === defaultLocale;
830
+ if (omitDefault)
831
+ return `${rest}${search}${hash}`;
832
+ const pathOut = rest === '/' ? `/${localeId}` : `/${localeId}${rest}`;
833
+ return `${pathOut}${search}${hash}`;
834
+ }
835
+ /**
836
+ * @param {string} html
837
+ * @param {string} localeId
838
+ * @param {any} artifact
839
+ */
840
+ function localizeBodyLinksInHost(html, localeId, artifact) {
841
+ if (!html || !localeId || !artifact)
842
+ return html;
843
+ return String(html).replace(/<a\b([^>]*)>/gi, (full, attrs) => {
844
+ if (!/\bdata-vmz-route\s*=/.test(attrs))
845
+ return full;
846
+ const hm = attrs.match(/\bhref\s*=\s*"([^"]*)"/i);
847
+ if (!hm)
848
+ return full;
849
+ const next = localizeSameAppHrefHost(hm[1], localeId, artifact);
850
+ if (next === hm[1])
851
+ return full;
852
+ const newAttrs = attrs.replace(/\bhref\s*=\s*"[^"]*"/i, `href="${escapeAttr(next)}"`);
853
+ return `<a${newAttrs}>`;
854
+ });
855
+ }
856
+ /**
857
+ * @param {string} chunkId
858
+ * @param {string} localeId
859
+ */
860
+ function pageMetaAlternates(chunkId, localeId) {
861
+ if (!localeArtifact?.pageMetas)
862
+ return [];
863
+ const meta = localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeId) ||
864
+ localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeArtifact.defaultLocale);
865
+ return Array.isArray(meta?.alternates) ? meta.alternates : [];
866
+ }
700
867
  /** @param {string} href */
701
868
  function bustUrl(href) {
702
869
  const u = new URL(href);
@@ -977,7 +1144,7 @@ globalThis.__vmzLoadComponent = async (name) => {
977
1144
  return `/**
978
1145
  * Generated by vmz serve — hydrate matched file-route page (data-vmz-page) + layout chain + client Link takeover.
979
1146
  */
980
- import { registerComponents, hydrate, hydrateRoute, destroy } from ${JSON.stringify(`./vmz-dom.js${q}`)};
1147
+ import { registerComponents, hydrate, hydrateRoute, hydrateRoutePage, destroy } from ${JSON.stringify(`./vmz-dom.js${q}`)};
981
1148
  import { installClientNavigation } from ${JSON.stringify(`./vmz-client-nav.js${q}`)};
982
1149
  ${imports}
983
1150
 
@@ -1003,6 +1170,7 @@ await hydrateRoute(Page, root, props, layoutCtors);
1003
1170
  installClientNavigation({
1004
1171
  hydrate,
1005
1172
  hydrateRoute,
1173
+ hydrateRoutePage,
1006
1174
  destroy,
1007
1175
  importPage: async (id) => (await import("./" + id + ".client.js${q}")).default,
1008
1176
  });
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
@@ -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,15 +204,12 @@ 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
214
  // Static first for assets + DocumentMount (`/d/…`) so docs aren't swallowed by SSR 404 shells.
131
215
  // web-static route HTML (`index.html`, `about/index.html`, …) is a CDN/deploy projection only —
@@ -247,6 +331,43 @@ export async function handleNodeRequest(req, res, opts = {}) {
247
331
  });
248
332
  }
249
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
+ }
250
371
  /**
251
372
  * Resolve a path under distDir; reject `..` escapes.
252
373
  * @param {string} distDir
@@ -402,16 +523,103 @@ function readJson(req) {
402
523
  }
403
524
  /**
404
525
  * @param {import('node:http').IncomingMessage} req
405
- * @returns {Promise<string>}
526
+ * @returns {Promise<Buffer>}
406
527
  */
407
528
  function readRawBody(req) {
408
529
  return new Promise((resolve, reject) => {
409
530
  const chunks = [];
410
531
  req.on('data', (c) => chunks.push(c));
411
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
532
+ // Buffer — never utf8-string: multipart File bytes must survive (Upload binary gate).
533
+ req.on('end', () => resolve(Buffer.concat(chunks)));
412
534
  req.on('error', reject);
413
535
  });
414
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
+ }
415
623
  /**
416
624
  * @param {string} raw
417
625
  * @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';