@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.
- package/dist/client-nav.d.ts +20 -2
- package/dist/client-nav.js +560 -50
- package/dist/dom-core.d.ts +337 -0
- package/dist/dom-core.js +4565 -0
- package/dist/dom-ssr.d.ts +78 -0
- package/dist/dom-ssr.js +970 -0
- package/dist/dom.client.d.ts +4 -0
- package/dist/dom.client.js +5 -0
- package/dist/dom.d.ts +3 -219
- package/dist/dom.js +3 -4377
- package/dist/serve-host.d.ts +13 -0
- package/dist/serve-host.mjs +184 -16
- package/dist/server.d.ts +8 -0
- package/dist/server.js +218 -10
- package/dist/vmz-dom.d.ts +5 -0
- package/dist/vmz-dom.js +6 -0
- package/dist/vmz-runtime.d.ts +5 -0
- package/dist/vmz-runtime.js +6 -0
- package/package.json +8 -1
package/dist/serve-host.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/serve-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
|
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="
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
package/dist/vmz-dom.js
ADDED