mnfst-run 1.0.22 → 1.0.24
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/bin/mnfst-run.js +0 -0
- package/package.json +2 -2
- package/serve.mjs +140 -17
package/bin/mnfst-run.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mnfst-run",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.24",
|
|
4
4
|
"description": "Zero-dependency dev server for Manifest projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/
|
|
27
|
+
"url": "git+https://github.com/Manifest-X/Manifest.git",
|
|
28
28
|
"directory": "packages/run"
|
|
29
29
|
}
|
|
30
30
|
}
|
package/serve.mjs
CHANGED
|
@@ -101,6 +101,7 @@ const MIME = {
|
|
|
101
101
|
// server the tab is gone.
|
|
102
102
|
const LIVE_RELOAD_SCRIPT = `<script>
|
|
103
103
|
(function () {
|
|
104
|
+
window.__mnfstRun = true; // dev marker: the framework loader skips its service worker here
|
|
104
105
|
var tabId = (window.crypto && crypto.randomUUID)
|
|
105
106
|
? crypto.randomUUID()
|
|
106
107
|
: (Math.random().toString(36).slice(2) + Date.now().toString(36));
|
|
@@ -396,6 +397,32 @@ const aiConfig = (() => {
|
|
|
396
397
|
try { return JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')).ai || null; }
|
|
397
398
|
catch { return null; }
|
|
398
399
|
})();
|
|
400
|
+
|
|
401
|
+
// Doc-grounding: `ai.system` (persona/instructions) + optional `ai.grounding`
|
|
402
|
+
// (URL or project-relative file whose text is appended to the system prompt).
|
|
403
|
+
// Resolved ONCE at startup so the combined prompt is byte-identical across
|
|
404
|
+
// requests — the precondition for the server-side prompt cache to hit.
|
|
405
|
+
let aiSystemResolved = (aiConfig && aiConfig.system) || '';
|
|
406
|
+
async function resolveAiGrounding() {
|
|
407
|
+
const src = aiConfig && aiConfig.grounding;
|
|
408
|
+
if (!src) return;
|
|
409
|
+
try {
|
|
410
|
+
let text;
|
|
411
|
+
if (/^https?:\/\//.test(src)) {
|
|
412
|
+
const r = await fetch(src);
|
|
413
|
+
if (!r.ok) throw new Error(`http ${r.status}`);
|
|
414
|
+
text = await r.text();
|
|
415
|
+
} else {
|
|
416
|
+
text = readFileSync(join(root, src), 'utf8');
|
|
417
|
+
}
|
|
418
|
+
aiSystemResolved = (aiSystemResolved ? aiSystemResolved + '\n\n' : '')
|
|
419
|
+
+ '<reference_documentation>\n' + text + '\n</reference_documentation>';
|
|
420
|
+
console.log(`[mnfst-run] AI grounding loaded from ${src} (${text.length} bytes)`);
|
|
421
|
+
} catch (e) {
|
|
422
|
+
console.warn(`[mnfst-run] AI grounding failed to load from ${src}: ${e.message} — continuing without it`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (aiConfig) resolveAiGrounding();
|
|
399
426
|
const aiKey = process.env.ANTHROPIC_API_KEY || privateEnv.ANTHROPIC_API_KEY || '';
|
|
400
427
|
if (publicEnv.PUBLIC_ANTHROPIC_API_KEY) {
|
|
401
428
|
// The one footgun: a PUBLIC_-prefixed LLM key would ship to every visitor.
|
|
@@ -420,6 +447,12 @@ function streamMockAi(res) {
|
|
|
420
447
|
res.on('close', () => clearInterval(tick));
|
|
421
448
|
}
|
|
422
449
|
async function streamRealAi(res, payload) {
|
|
450
|
+
// System prompt as a cache_control block: the (instructions + grounding) text
|
|
451
|
+
// is byte-identical every request, so Anthropic serves it from the prompt
|
|
452
|
+
// cache (~0.1× input price) after the first request — long doc-grounded
|
|
453
|
+
// prompts cost near-nothing per message. Per-request payload.system overrides
|
|
454
|
+
// (adapter opts) still work but won't share the project-level cache entry.
|
|
455
|
+
const systemText = payload.system || aiSystemResolved;
|
|
423
456
|
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
|
|
424
457
|
method: 'POST',
|
|
425
458
|
headers: { 'content-type': 'application/json', 'x-api-key': aiKey, 'anthropic-version': '2023-06-01' },
|
|
@@ -427,7 +460,7 @@ async function streamRealAi(res, payload) {
|
|
|
427
460
|
model: payload.model || aiConfig.model || 'claude-haiku-4-5',
|
|
428
461
|
max_tokens: payload.max_tokens || aiConfig.maxTokens || 1024,
|
|
429
462
|
stream: true,
|
|
430
|
-
system:
|
|
463
|
+
system: systemText ? [{ type: 'text', text: systemText, cache_control: { type: 'ephemeral' } }] : undefined,
|
|
431
464
|
messages: payload.messages || []
|
|
432
465
|
})
|
|
433
466
|
});
|
|
@@ -698,35 +731,97 @@ function serverStaticKey(html, child) { // mirror the client's staticKey (tag
|
|
|
698
731
|
const text = html.slice(child.innerStart, child.innerEnd).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 24);
|
|
699
732
|
return child.tag.toUpperCase() + ':' + text;
|
|
700
733
|
}
|
|
701
|
-
function writeStaticOps(file, key, edits, order) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
|
|
705
|
-
const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
|
|
706
|
-
if (!node) continue;
|
|
707
|
-
const openTag = html.slice(node.tagStart, node.openEnd + 1);
|
|
708
|
-
if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
|
|
709
|
-
else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
|
|
710
|
-
else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
|
|
711
|
-
writeFileSync(file, html);
|
|
712
|
-
}
|
|
734
|
+
function writeStaticOps(file, key, edits, order, aliases) {
|
|
735
|
+
// Order FIRST: keys match on authored content (edits haven't rewritten it yet) and
|
|
736
|
+
// edit paths are current DOM positions, valid only once the file order matches.
|
|
713
737
|
let reordered = false;
|
|
714
738
|
if (order && order.length) {
|
|
715
739
|
let html = readFileSync(file, 'utf8'); const loc = locateEditEl(html, key);
|
|
716
740
|
if (loc) {
|
|
717
741
|
const kids = []; let n = 0, c;
|
|
718
742
|
while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
|
|
719
|
-
const
|
|
743
|
+
const keys = dedupedKeys(html, kids); // ordinal-deduped, mirroring the client
|
|
744
|
+
// A block edited BEFORE its move carries a live key derived from the EDITED text;
|
|
745
|
+
// the file holds the authored text, so file keys can't resolve it and the whole
|
|
746
|
+
// order would be skipped. writeStaticMove recorded (live toKey → inserted slice):
|
|
747
|
+
// bind those entries to their exact inserted child, then key the remaining kids
|
|
748
|
+
// counting the live base the client's rekey counted (twins keep their ordinals).
|
|
749
|
+
const bound = new Map();
|
|
750
|
+
for (const a of (aliases || [])) {
|
|
751
|
+
if (!order.includes(a.key) || keys.includes(a.key)) continue; // unedited arrival: file keys already match
|
|
752
|
+
const kid = kids.find(k => !bound.has(k) && html.slice(k.tagStart, k.closeEnd) === a.slice);
|
|
753
|
+
if (kid) bound.set(kid, a.key);
|
|
754
|
+
}
|
|
755
|
+
const byKey = {};
|
|
756
|
+
if (bound.size) {
|
|
757
|
+
const seen = Object.create(null);
|
|
758
|
+
kids.forEach(k => {
|
|
759
|
+
const live = bound.get(k), base = live ? ((/^([\s\S]*)#\d+$/.exec(live) || [, live])[1]) : serverStaticKey(html, k); // ordinal = last #digits suffix, matching the client
|
|
760
|
+
const d = seen[base] = (seen[base] || 0) + 1;
|
|
761
|
+
byKey[live || (d > 1 ? base + '#' + d : base)] = k;
|
|
762
|
+
});
|
|
763
|
+
} else kids.forEach((k, i) => { byKey[keys[i]] = k; });
|
|
720
764
|
const seq = order.map(k => byKey[k]).filter(Boolean);
|
|
721
|
-
if (seq.length === kids.length && seq.length) {
|
|
765
|
+
if (seq.length === kids.length && seq.length && seq.some((k, i) => k !== kids[i])) { // skip when already in order
|
|
722
766
|
const reassembled = seq.map(k => html.slice(k.tagStart, k.closeEnd)).join('\n ');
|
|
723
767
|
html = html.slice(0, kids[0].tagStart) + reassembled + html.slice(kids[kids.length - 1].closeEnd);
|
|
724
768
|
writeFileSync(file, html); reordered = true;
|
|
725
769
|
}
|
|
726
770
|
}
|
|
727
771
|
}
|
|
772
|
+
for (const ed of (edits || [])) { // re-read+re-locate each (offsets shift after writes)
|
|
773
|
+
let html = readFileSync(file, 'utf8');
|
|
774
|
+
const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
|
|
775
|
+
const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
|
|
776
|
+
if (!node) continue;
|
|
777
|
+
const openTag = html.slice(node.tagStart, node.openEnd + 1);
|
|
778
|
+
if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
|
|
779
|
+
else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
|
|
780
|
+
else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
|
|
781
|
+
writeFileSync(file, html);
|
|
782
|
+
}
|
|
728
783
|
return { region: key, status: 'written', file: basename(file), edits: (edits || []).length, reordered };
|
|
729
784
|
}
|
|
785
|
+
// Cross-region move: lift a child's authored markup out of one x-edit region and
|
|
786
|
+
// splice it into another (same file). Keys mirror the client's staticKeys (ordinal-deduped).
|
|
787
|
+
function regionChildren(html, loc) {
|
|
788
|
+
const kids = []; let n = 0, c;
|
|
789
|
+
while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
|
|
790
|
+
return kids;
|
|
791
|
+
}
|
|
792
|
+
function dedupedKeys(html, kids) {
|
|
793
|
+
const seen = Object.create(null);
|
|
794
|
+
return kids.map(k => { const base = serverStaticKey(html, k); const n = seen[base] = (seen[base] || 0) + 1; return n > 1 ? base + '#' + n : base; });
|
|
795
|
+
}
|
|
796
|
+
function writeStaticMove(file, p, aliases) {
|
|
797
|
+
let html = readFileSync(file, 'utf8');
|
|
798
|
+
const fromLoc = locateEditEl(html, p.from);
|
|
799
|
+
if (!fromLoc) return { region: p.from, status: 'error', reason: `x-edit="${p.from}" not found in ${basename(file)}` };
|
|
800
|
+
if (!locateEditEl(html, p.to)) return { region: p.to, status: 'error', reason: `x-edit="${p.to}" not found in ${basename(file)}` };
|
|
801
|
+
const kids = regionChildren(html, fromLoc);
|
|
802
|
+
const i = dedupedKeys(html, kids).indexOf(p.key);
|
|
803
|
+
if (i < 0) return { region: p.from, status: 'error', reason: `child '${p.key}' not found in region '${p.from}'` };
|
|
804
|
+
const child = kids[i], slice = html.slice(child.tagStart, child.closeEnd);
|
|
805
|
+
let remStart = child.tagStart;
|
|
806
|
+
const pre = html.slice(0, remStart), nl = pre.lastIndexOf('\n');
|
|
807
|
+
if (nl >= 0 && /^[ \t]*$/.test(pre.slice(nl + 1))) remStart = nl; // take the indent + newline with it
|
|
808
|
+
html = html.slice(0, remStart) + html.slice(child.closeEnd);
|
|
809
|
+
const toLoc = locateEditEl(html, p.to); // re-locate: offsets shifted
|
|
810
|
+
if (!toLoc) return { region: p.to, status: 'error', reason: `x-edit="${p.to}" not found after removal` };
|
|
811
|
+
const toKids = regionChildren(html, toLoc);
|
|
812
|
+
// Positional placement: toOrder minus the arrival is the destination's children, so
|
|
813
|
+
// the arrival's index in toOrder is where it goes — robust against ordinal re-keys
|
|
814
|
+
// (a collision renames the twin, so key matching would miss). Any residual order
|
|
815
|
+
// drift is settled by the region's own order patch, which follows the move.
|
|
816
|
+
const at = (p.toOrder || []).indexOf(p.toKey);
|
|
817
|
+
let insertAt = toLoc.innerEnd; // default: end of region
|
|
818
|
+
if (at >= 0 && at < toKids.length) insertAt = toKids[at].tagStart;
|
|
819
|
+
const head = html.slice(0, insertAt), hnl = head.lastIndexOf('\n');
|
|
820
|
+
const indent = hnl >= 0 && /^[ \t]*$/.test(head.slice(hnl + 1)) ? head.slice(hnl + 1) : '';
|
|
821
|
+
writeFileSync(file, head + slice + '\n' + indent + html.slice(insertAt));
|
|
822
|
+
if (aliases) (aliases[p.to] = aliases[p.to] || []).push({ key: p.toKey, slice }); // the region's order patch may address this child by its LIVE key
|
|
823
|
+
return { region: `${p.from} → ${p.to}`, status: 'written', file: basename(file), key: p.key };
|
|
824
|
+
}
|
|
730
825
|
function writeStaticRegion(file, key, innerHTML, style) {
|
|
731
826
|
let html = readFileSync(file, 'utf8');
|
|
732
827
|
const loc = locateEditEl(html, key);
|
|
@@ -751,6 +846,7 @@ function childAt(html, from, to, idx) { // nth element child within [f
|
|
|
751
846
|
const selfClose = html[openEnd - 1] === '/' || VOID_TAGS.has(tag.toLowerCase());
|
|
752
847
|
let innerStart = openEnd + 1, innerEnd = innerStart, closeEnd = openEnd + 1;
|
|
753
848
|
if (!selfClose) { const cl = matchingClose(html, tag, innerStart); if (!cl) return null; innerEnd = cl.start; closeEnd = cl.end; }
|
|
849
|
+
if (/\sdata-edit-(handle|ghost)[\s=/>]/.test(html.slice(lt, openEnd + 1))) { i = closeEnd; continue; } // plugin-injected, not an authored child (mirrors the client's realChildren)
|
|
754
850
|
if (count === idx) return { tag, tagStart: lt, openEnd, innerStart, innerEnd, closeEnd };
|
|
755
851
|
count++; i = closeEnd;
|
|
756
852
|
}
|
|
@@ -764,7 +860,12 @@ function navigateToNode(html, path) { // path indices are relative t
|
|
|
764
860
|
}
|
|
765
861
|
function resolveComponentFile(manifest, name) {
|
|
766
862
|
const all = [...(manifest.preloadedComponents || []), ...(manifest.components || [])];
|
|
767
|
-
|
|
863
|
+
const listed = all.find(p => String(p).split('/').pop().replace('.html', '') === name);
|
|
864
|
+
if (listed) return listed;
|
|
865
|
+
// Convention component: components/<name>.html needs no manifest entry.
|
|
866
|
+
const rel = 'components/' + name + '.html';
|
|
867
|
+
const file = safeResolve('/' + rel);
|
|
868
|
+
return file && isFile(file) ? rel : null;
|
|
768
869
|
}
|
|
769
870
|
|
|
770
871
|
// Theme var write: rewrite (or append) a single `--var: value;` in the target CSS file.
|
|
@@ -865,6 +966,20 @@ function writeComponentInstance(file, key, overrides, removals) {
|
|
|
865
966
|
return { region: key, status: 'written', file: basename(file), applied: Object.keys(overrides || {}), removed: removals || [] };
|
|
866
967
|
}
|
|
867
968
|
|
|
969
|
+
// No-op service worker served at /sw.js when the project has none: a stale
|
|
970
|
+
// production worker (cached shell from a previous deploy) can never hold a
|
|
971
|
+
// dev session. It replaces that worker at the next update check, clears the
|
|
972
|
+
// framework's caches and unregisters itself.
|
|
973
|
+
const NOOP_SW = `// mnfst-run: no-op service worker (unregisters itself, clears Manifest caches)
|
|
974
|
+
self.addEventListener('install', function () { self.skipWaiting(); });
|
|
975
|
+
self.addEventListener('activate', function (e) {
|
|
976
|
+
e.waitUntil((async function () {
|
|
977
|
+
try { var keys = await caches.keys(); await Promise.all(keys.filter(function (k) { return k.indexOf('mnfst-sw:') === 0; }).map(function (k) { return caches.delete(k); })); } catch (_) {}
|
|
978
|
+
try { await self.registration.unregister(); } catch (_) {}
|
|
979
|
+
})());
|
|
980
|
+
});
|
|
981
|
+
`;
|
|
982
|
+
|
|
868
983
|
// Resolve a request path against `root` and refuse anything that escapes.
|
|
869
984
|
// `path.join` does NOT prevent `..` traversal — `join('/a/b', '/../../etc/passwd')`
|
|
870
985
|
// returns `/etc/passwd`. Use `path.resolve` + an explicit prefix check.
|
|
@@ -1132,6 +1247,7 @@ const server = createServer((req, res) => {
|
|
|
1132
1247
|
if (!Array.isArray(patches)) patches = [patches];
|
|
1133
1248
|
let manifest = {}; try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); } catch {}
|
|
1134
1249
|
const indexFile = join(root, 'index.html');
|
|
1250
|
+
const moveAliases = {}; // region → [{key: live toKey, slice}] from this batch's moves
|
|
1135
1251
|
const results = patches.map(p => {
|
|
1136
1252
|
try {
|
|
1137
1253
|
if (p.kind === 'data') {
|
|
@@ -1143,7 +1259,8 @@ const server = createServer((req, res) => {
|
|
|
1143
1259
|
return { region: p.region, status: reorderDataFile(file, p.order) ? 'written' : 'noop', file: rel };
|
|
1144
1260
|
}
|
|
1145
1261
|
if (p.kind === 'data-val') return writeDataValue(p, manifest);
|
|
1146
|
-
if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order);
|
|
1262
|
+
if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order, moveAliases[p.region]);
|
|
1263
|
+
if (p.kind === 'static-move') return writeStaticMove(indexFile, p, moveAliases);
|
|
1147
1264
|
if (p.kind === 'component') return writeComponentEdits(p, manifest);
|
|
1148
1265
|
if (p.kind === 'theme') return writeThemeVar(p);
|
|
1149
1266
|
return { region: p.region, status: 'skipped', reason: `unknown kind ${p.kind}` };
|
|
@@ -1158,6 +1275,12 @@ const server = createServer((req, res) => {
|
|
|
1158
1275
|
const exact = safeResolve(urlPath);
|
|
1159
1276
|
if (exact && isFile(exact)) return serveFile(res, exact);
|
|
1160
1277
|
|
|
1278
|
+
if (urlPath === '/sw.js') {
|
|
1279
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
1280
|
+
res.end(NOOP_SW);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1161
1284
|
const indexPath = safeResolve(urlPath.replace(/\/$/, '') + '/index.html');
|
|
1162
1285
|
if (indexPath && isFile(indexPath)) return serveFile(res, indexPath);
|
|
1163
1286
|
|