mnfst-run 1.0.21 → 1.0.23
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 +1 -1
- package/serve.mjs +571 -5
package/bin/mnfst-run.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/serve.mjs
CHANGED
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
* other → full page reload
|
|
54
54
|
*/
|
|
55
55
|
import { createServer, get as httpGet, request as httpRequest } from 'http';
|
|
56
|
+
import { request as httpsRequest } from 'https';
|
|
57
|
+
import { connect as tlsConnect } from 'tls';
|
|
56
58
|
import {
|
|
57
59
|
readFileSync, statSync, watch,
|
|
58
60
|
existsSync, writeFileSync, unlinkSync,
|
|
@@ -99,6 +101,7 @@ const MIME = {
|
|
|
99
101
|
// server the tab is gone.
|
|
100
102
|
const LIVE_RELOAD_SCRIPT = `<script>
|
|
101
103
|
(function () {
|
|
104
|
+
window.__mnfstRun = true; // dev marker: the framework loader skips its service worker here
|
|
102
105
|
var tabId = (window.crypto && crypto.randomUUID)
|
|
103
106
|
? crypto.randomUUID()
|
|
104
107
|
: (Math.random().toString(36).slice(2) + Date.now().toString(36));
|
|
@@ -161,9 +164,10 @@ const LIVE_RELOAD_SCRIPT = `<script>
|
|
|
161
164
|
// by the host. See the Appwrite setup doc for the full pattern.
|
|
162
165
|
function loadEnvFile(rootDir) {
|
|
163
166
|
const envPath = join(rootDir, '.env');
|
|
164
|
-
if (!existsSync(envPath)) return { public: {}, private: [] };
|
|
167
|
+
if (!existsSync(envPath)) return { public: {}, private: [], privateValues: {} };
|
|
165
168
|
const publicEnv = {};
|
|
166
169
|
const privateNames = [];
|
|
170
|
+
const privateValues = {}; // server-side only — NEVER injected into window.env
|
|
167
171
|
try {
|
|
168
172
|
const text = readFileSync(envPath, 'utf8');
|
|
169
173
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -179,12 +183,12 @@ function loadEnvFile(rootDir) {
|
|
|
179
183
|
value = value.slice(1, -1);
|
|
180
184
|
}
|
|
181
185
|
if (key.startsWith('PUBLIC_')) publicEnv[key] = value;
|
|
182
|
-
else privateNames.push(key);
|
|
186
|
+
else { privateNames.push(key); privateValues[key] = value; }
|
|
183
187
|
}
|
|
184
188
|
} catch (error) {
|
|
185
189
|
console.warn('[mnfst-run] Failed to parse .env:', error.message);
|
|
186
190
|
}
|
|
187
|
-
return { public: publicEnv, private: privateNames };
|
|
191
|
+
return { public: publicEnv, private: privateNames, privateValues };
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
// Build a `<script>window.env = {…};</script>` tag from the public env map.
|
|
@@ -357,13 +361,119 @@ if (listMode) {
|
|
|
357
361
|
}
|
|
358
362
|
|
|
359
363
|
const root = resolve(process.cwd(), dir);
|
|
364
|
+
const EDIT_ENABLED = process.argv.includes('--edit') || process.env.MNFST_EDIT === '1'; // gates /__edit/save (edit-plugin source write-back, authoring only)
|
|
360
365
|
|
|
361
366
|
// Load .env from the serving root (if present) and pre-build the inject
|
|
362
367
|
// script. Kept as a single string so serveFile doesn't re-stringify on every
|
|
363
368
|
// HTML response. Empty string when no public vars exist — the injection step
|
|
364
369
|
// becomes a no-op for projects whose .env holds only server-side secrets.
|
|
365
|
-
const { public: publicEnv, private: privateEnvNames } = loadEnvFile(root);
|
|
366
|
-
|
|
370
|
+
const { public: publicEnv, private: privateEnvNames, privateValues: privateEnv } = loadEnvFile(root);
|
|
371
|
+
// Appwrite dev proxy: when APPWRITE_PROXY_TARGET is set (server-side .env), the
|
|
372
|
+
// dev server proxies /_appwrite/* to that Appwrite origin so the session cookie
|
|
373
|
+
// is FIRST-PARTY on localhost. Cross-origin Appwrite Cloud blocks the third-party
|
|
374
|
+
// session cookie, and some endpoints (e.g. Presences) don't honour the
|
|
375
|
+
// X-Fallback-Cookies workaround the SDK uses for the others — so user-scoped
|
|
376
|
+
// calls land as the anonymous "guests" role. Proxying makes Appwrite same-origin
|
|
377
|
+
// and fixes it uniformly (HTTP + Realtime WebSocket).
|
|
378
|
+
const APPWRITE_PROXY_TARGET = (process.env.APPWRITE_PROXY_TARGET || privateEnv.APPWRITE_PROXY_TARGET || '').trim();
|
|
379
|
+
const APPWRITE_PROXY_PREFIX = '/_appwrite';
|
|
380
|
+
|
|
381
|
+
let envInjectScript = buildEnvInjectScript(publicEnv);
|
|
382
|
+
if (APPWRITE_PROXY_TARGET) {
|
|
383
|
+
// Point the Appwrite endpoint at the same-origin proxy. Computed in the browser
|
|
384
|
+
// from location.origin so it's port-independent (auto-port safe) and prod-safe
|
|
385
|
+
// (production sets PUBLIC_APPWRITE_ENDPOINT to the real/custom domain instead).
|
|
386
|
+
envInjectScript += `<script>window.env=Object.assign(window.env||{},{PUBLIC_APPWRITE_ENDPOINT:location.origin+'${APPWRITE_PROXY_PREFIX}/v1'});</script>`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
// --- Turnkey AI relay (gates the same-origin /_ai/chat route) ---------------
|
|
391
|
+
// Reads the optional `ai` block from manifest.json and the LLM key from .env
|
|
392
|
+
// (server-side only). When the `ai` block is present, the dev server hosts the
|
|
393
|
+
// relay so a chat adapter can POST to `/_ai/chat` with no separate proxy, no
|
|
394
|
+
// CORS, and no key in the browser. No key → MOCK replies (try keyless); add the
|
|
395
|
+
// key for real. The same path is served by managed Manifest hosting in prod.
|
|
396
|
+
const aiConfig = (() => {
|
|
397
|
+
try { return JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')).ai || null; }
|
|
398
|
+
catch { return null; }
|
|
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();
|
|
426
|
+
const aiKey = process.env.ANTHROPIC_API_KEY || privateEnv.ANTHROPIC_API_KEY || '';
|
|
427
|
+
if (publicEnv.PUBLIC_ANTHROPIC_API_KEY) {
|
|
428
|
+
// The one footgun: a PUBLIC_-prefixed LLM key would ship to every visitor.
|
|
429
|
+
console.warn('[mnfst-run] ⚠ PUBLIC_ANTHROPIC_API_KEY is injected into the BROWSER and exposes your key to every visitor. Rename it to ANTHROPIC_API_KEY (no PUBLIC_ prefix) — the dev server uses it server-side via /_ai/chat.');
|
|
430
|
+
}
|
|
431
|
+
if (aiConfig) {
|
|
432
|
+
console.log(`[mnfst-run] AI relay on /_ai/chat — provider=${aiConfig.provider || 'anthropic'} model=${aiConfig.model || 'claude-haiku-4-5'} mode=${aiKey ? 'REAL' : 'MOCK (no ANTHROPIC_API_KEY)'}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function aiSse(res, o) { res.write(`event: ${o.type}\ndata: ${JSON.stringify(o)}\n\n`); }
|
|
436
|
+
function streamMockAi(res) {
|
|
437
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
438
|
+
aiSse(res, { type: 'message_start', message: { id: 'mock', role: 'assistant' } });
|
|
439
|
+
aiSse(res, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
440
|
+
const text = "Here's a **mock** reply from the in-server relay:\n\n- no separate proxy\n- the key stays server-side\n- add `ANTHROPIC_API_KEY` to `.env` for real Claude\n\n```js\nconst turnkey = true;\n```";
|
|
441
|
+
const chunks = text.match(/\S+\s*/g) || [text];
|
|
442
|
+
let i = 0;
|
|
443
|
+
const tick = setInterval(() => {
|
|
444
|
+
if (i >= chunks.length) { clearInterval(tick); aiSse(res, { type: 'content_block_stop', index: 0 }); aiSse(res, { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: chunks.length } }); aiSse(res, { type: 'message_stop' }); res.end(); return; }
|
|
445
|
+
aiSse(res, { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: chunks[i++] } });
|
|
446
|
+
}, 45);
|
|
447
|
+
res.on('close', () => clearInterval(tick));
|
|
448
|
+
}
|
|
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;
|
|
456
|
+
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'content-type': 'application/json', 'x-api-key': aiKey, 'anthropic-version': '2023-06-01' },
|
|
459
|
+
body: JSON.stringify({
|
|
460
|
+
model: payload.model || aiConfig.model || 'claude-haiku-4-5',
|
|
461
|
+
max_tokens: payload.max_tokens || aiConfig.maxTokens || 1024,
|
|
462
|
+
stream: true,
|
|
463
|
+
system: systemText ? [{ type: 'text', text: systemText, cache_control: { type: 'ephemeral' } }] : undefined,
|
|
464
|
+
messages: payload.messages || []
|
|
465
|
+
})
|
|
466
|
+
});
|
|
467
|
+
if (!upstream.ok) {
|
|
468
|
+
const body = await upstream.text();
|
|
469
|
+
res.writeHead(upstream.status, { 'Content-Type': 'text/event-stream' });
|
|
470
|
+
aiSse(res, { type: 'error', error: { message: `upstream ${upstream.status}: ${body.slice(0, 300)}` } });
|
|
471
|
+
return res.end();
|
|
472
|
+
}
|
|
473
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
|
|
474
|
+
for await (const chunk of upstream.body) res.write(chunk); // passthrough — exact Anthropic SSE
|
|
475
|
+
res.end();
|
|
476
|
+
}
|
|
367
477
|
const publicCount = Object.keys(publicEnv).length;
|
|
368
478
|
if (publicCount > 0) {
|
|
369
479
|
console.log(`Loaded ${publicCount} PUBLIC_ env var(s) into window.env`);
|
|
@@ -497,6 +607,316 @@ function isFile(p) {
|
|
|
497
607
|
try { return statSync(p).isFile(); } catch { return false; }
|
|
498
608
|
}
|
|
499
609
|
|
|
610
|
+
// Reorder a data file's rows/items to match `order` (array of ids). CSV (tabular,
|
|
611
|
+
// header with an `id` column) or JSON (array of {id}). Ids not in `order` keep their
|
|
612
|
+
// relative order at the end. Returns true if written. Used by /__edit/save (spike).
|
|
613
|
+
function reorderDataFile(file, order) {
|
|
614
|
+
const ext = extname(file).toLowerCase();
|
|
615
|
+
const text = readFileSync(file, 'utf8');
|
|
616
|
+
const ord = (order || []).map(String);
|
|
617
|
+
if (ext === '.json') {
|
|
618
|
+
const data = JSON.parse(text);
|
|
619
|
+
if (!Array.isArray(data)) return false;
|
|
620
|
+
const byId = new Map(data.map(it => [String(it.id), it]));
|
|
621
|
+
const next = ord.map(id => byId.get(id)).filter(Boolean);
|
|
622
|
+
data.forEach(it => { if (!ord.includes(String(it.id))) next.push(it); });
|
|
623
|
+
writeFileSync(file, JSON.stringify(next, null, 2) + (text.endsWith('\n') ? '\n' : ''));
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
if (ext === '.csv') {
|
|
627
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
628
|
+
const lines = text.split(/\r?\n/);
|
|
629
|
+
const trailing = lines.length && lines[lines.length - 1] === '';
|
|
630
|
+
if (trailing) lines.pop();
|
|
631
|
+
if (lines.length < 2) return false;
|
|
632
|
+
const header = lines[0];
|
|
633
|
+
const idIdx = header.split(',').map(s => s.trim().toLowerCase()).indexOf('id');
|
|
634
|
+
if (idIdx < 0) return false;
|
|
635
|
+
const rows = lines.slice(1);
|
|
636
|
+
const idOf = (r) => (r.split(',')[idIdx] || '').trim(); // id col is simple (unquoted); full row line preserved
|
|
637
|
+
const byId = new Map(rows.map(r => [idOf(r), r]));
|
|
638
|
+
const next = ord.map(id => byId.get(id)).filter(Boolean);
|
|
639
|
+
rows.forEach(r => { if (!ord.includes(idOf(r))) next.push(r); });
|
|
640
|
+
writeFileSync(file, [header, ...next].join(eol) + (trailing ? eol : ''));
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Quote-aware CSV line split + cell quoting (so editing an early column doesn't corrupt
|
|
647
|
+
// a later quoted field with commas).
|
|
648
|
+
function parseCsvLine(line) {
|
|
649
|
+
const out = []; let cur = '', q = false;
|
|
650
|
+
for (let i = 0; i < line.length; i++) { const ch = line[i]; if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; } else { if (ch === ',') { out.push(cur); cur = ''; } else if (ch === '"') q = true; else cur += ch; } }
|
|
651
|
+
out.push(cur); return out;
|
|
652
|
+
}
|
|
653
|
+
const csvCell = (v) => /[",\n]/.test(v) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v);
|
|
654
|
+
// Data-value write-back (local CSV/JSON cell). Cloud sources are not files → caller should
|
|
655
|
+
// persist via $x.<source>.$update(id,{field}) instead.
|
|
656
|
+
function writeDataValue(p, manifest) {
|
|
657
|
+
const src = (manifest.data || {})[p.source], rel = typeof src === 'string' ? src : null;
|
|
658
|
+
if (!rel) return { kind: 'data-val', source: p.source, status: 'skipped', reason: 'not a file source (cloud → use $x.$update)' };
|
|
659
|
+
const file = safeResolve(rel); if (!file || !isFile(file)) return { kind: 'data-val', source: p.source, status: 'error', reason: `file not found: ${rel}` };
|
|
660
|
+
const ext = extname(file).toLowerCase(), text = readFileSync(file, 'utf8');
|
|
661
|
+
if (ext === '.json') {
|
|
662
|
+
const data = JSON.parse(text); if (!Array.isArray(data)) return { kind: 'data-val', source: p.source, status: 'error', reason: 'JSON is not an array' };
|
|
663
|
+
const rec = data.find(r => String(r.id) === String(p.id)); if (!rec) return { kind: 'data-val', source: p.source, status: 'error', reason: `id ${p.id} not found` };
|
|
664
|
+
rec[p.field] = p.value; writeFileSync(file, JSON.stringify(data, null, 2) + (text.endsWith('\n') ? '\n' : ''));
|
|
665
|
+
return { kind: 'data-val', source: p.source, id: p.id, field: p.field, status: 'written', file: basename(file) };
|
|
666
|
+
}
|
|
667
|
+
if (ext === '.csv') {
|
|
668
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n', lines = text.split(/\r?\n/);
|
|
669
|
+
const trailing = lines.length && lines[lines.length - 1] === ''; if (trailing) lines.pop();
|
|
670
|
+
if (lines.length < 2) return { kind: 'data-val', source: p.source, status: 'error', reason: 'empty CSV' };
|
|
671
|
+
const header = parseCsvLine(lines[0]).map(h => h.trim().toLowerCase());
|
|
672
|
+
const idIdx = header.indexOf('id'), fIdx = header.indexOf(String(p.field).toLowerCase());
|
|
673
|
+
if (idIdx < 0 || fIdx < 0) return { kind: 'data-val', source: p.source, status: 'error', reason: `column not found (id/${p.field})` };
|
|
674
|
+
let hit = false;
|
|
675
|
+
const rows = lines.slice(1).map(line => { const cells = parseCsvLine(line); if (String(cells[idIdx]).trim() === String(p.id)) { cells[fIdx] = p.value; hit = true; return cells.map(csvCell).join(','); } return line; });
|
|
676
|
+
if (!hit) return { kind: 'data-val', source: p.source, status: 'error', reason: `id ${p.id} not found` };
|
|
677
|
+
writeFileSync(file, [lines[0], ...rows].join(eol) + (trailing ? eol : ''));
|
|
678
|
+
return { kind: 'data-val', source: p.source, id: p.id, field: p.field, status: 'written', file: basename(file) };
|
|
679
|
+
}
|
|
680
|
+
return { kind: 'data-val', source: p.source, status: 'error', reason: `unsupported ext ${ext}` };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// --- Dependency-free HTML region editor (spike) ---
|
|
684
|
+
// packages/run is zero-dep by design, so instead of pulling in an HTML parser we do a
|
|
685
|
+
// small tag-aware scan: find the element carrying x-edit="<key>", depth-match its close,
|
|
686
|
+
// and splice. Robust for authored Manifest source (well-formed); not a general parser.
|
|
687
|
+
const escapeReg = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
688
|
+
function tagEnd(html, from) { // from = index of '<'; returns index of the unquoted '>'
|
|
689
|
+
let q = null;
|
|
690
|
+
for (let i = from + 1; i < html.length; i++) {
|
|
691
|
+
const ch = html[i];
|
|
692
|
+
if (q) { if (ch === q) q = null; }
|
|
693
|
+
else if (ch === '"' || ch === "'") q = ch;
|
|
694
|
+
else if (ch === '>') return i;
|
|
695
|
+
}
|
|
696
|
+
return -1;
|
|
697
|
+
}
|
|
698
|
+
function matchingClose(html, tag, from) { // depth-match </tag> accounting for nested same-name tags
|
|
699
|
+
const re = new RegExp(`<(/?)${escapeReg(tag)}(?=[\\s/>])`, 'gi');
|
|
700
|
+
re.lastIndex = from;
|
|
701
|
+
let depth = 1, m;
|
|
702
|
+
while ((m = re.exec(html))) {
|
|
703
|
+
if (m[1] === '/') { depth--; if (depth === 0) { const gt = html.indexOf('>', m.index); return { start: m.index, end: gt + 1 }; } }
|
|
704
|
+
else { const ge = tagEnd(html, m.index); if (ge >= 0) { if (html[ge - 1] !== '/') depth++; re.lastIndex = ge; } }
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
function locateEditEl(html, key) { // element whose x-edit*-named attr value === key
|
|
709
|
+
const m = new RegExp(`x-edit[.\\w-]*\\s*=\\s*(?:"${escapeReg(key)}"|'${escapeReg(key)}')`).exec(html);
|
|
710
|
+
if (!m) return null;
|
|
711
|
+
const openStart = html.lastIndexOf('<', m.index); if (openStart < 0) return null;
|
|
712
|
+
const nameM = /^<([a-zA-Z][\w-]*)/.exec(html.slice(openStart)); if (!nameM) return null;
|
|
713
|
+
const openEnd = tagEnd(html, openStart); if (openEnd < 0) return null;
|
|
714
|
+
const innerStart = openEnd + 1;
|
|
715
|
+
const cl = matchingClose(html, nameM[1], innerStart); if (!cl) return null;
|
|
716
|
+
return { tag: nameM[1], openStart, openEnd, innerStart, innerEnd: cl.start };
|
|
717
|
+
}
|
|
718
|
+
function setAttr(openTag, name, value) { // replace or insert an attribute in an opening-tag string
|
|
719
|
+
const val = `"${String(value).replace(/"/g, '"')}"`;
|
|
720
|
+
const re = new RegExp(`(\\s${escapeReg(name)}\\s*=\\s*)(?:"[^"]*"|'[^']*')`);
|
|
721
|
+
if (re.test(openTag)) return openTag.replace(re, `$1${val}`);
|
|
722
|
+
return openTag.replace(/\s*\/?>$/, m => ` ${name}=${val}${m}`);
|
|
723
|
+
}
|
|
724
|
+
// Static per-node ops + reorder — surgical source edits (no whole-innerHTML replacement).
|
|
725
|
+
function navInRegion(html, loc, path) {
|
|
726
|
+
let node = { innerStart: loc.innerStart, innerEnd: loc.innerEnd };
|
|
727
|
+
for (const i of path.split('.').map(Number)) { node = childAt(html, node.innerStart, node.innerEnd, i); if (!node) return null; }
|
|
728
|
+
return node;
|
|
729
|
+
}
|
|
730
|
+
function serverStaticKey(html, child) { // mirror the client's staticKey (tag + first 24 text chars)
|
|
731
|
+
const text = html.slice(child.innerStart, child.innerEnd).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 24);
|
|
732
|
+
return child.tag.toUpperCase() + ':' + text;
|
|
733
|
+
}
|
|
734
|
+
function writeStaticOps(file, key, edits, order) {
|
|
735
|
+
for (const ed of (edits || [])) { // re-read+re-locate each (offsets shift after writes)
|
|
736
|
+
let html = readFileSync(file, 'utf8');
|
|
737
|
+
const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
|
|
738
|
+
const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
|
|
739
|
+
if (!node) continue;
|
|
740
|
+
const openTag = html.slice(node.tagStart, node.openEnd + 1);
|
|
741
|
+
if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
|
|
742
|
+
else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
|
|
743
|
+
else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
|
|
744
|
+
writeFileSync(file, html);
|
|
745
|
+
}
|
|
746
|
+
let reordered = false;
|
|
747
|
+
if (order && order.length) {
|
|
748
|
+
let html = readFileSync(file, 'utf8'); const loc = locateEditEl(html, key);
|
|
749
|
+
if (loc) {
|
|
750
|
+
const kids = []; let n = 0, c;
|
|
751
|
+
while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
|
|
752
|
+
const byKey = {}; kids.forEach(k => { byKey[serverStaticKey(html, k)] = k; });
|
|
753
|
+
const seq = order.map(k => byKey[k]).filter(Boolean);
|
|
754
|
+
if (seq.length === kids.length && seq.length) {
|
|
755
|
+
const reassembled = seq.map(k => html.slice(k.tagStart, k.closeEnd)).join('\n ');
|
|
756
|
+
html = html.slice(0, kids[0].tagStart) + reassembled + html.slice(kids[kids.length - 1].closeEnd);
|
|
757
|
+
writeFileSync(file, html); reordered = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { region: key, status: 'written', file: basename(file), edits: (edits || []).length, reordered };
|
|
762
|
+
}
|
|
763
|
+
function writeStaticRegion(file, key, innerHTML, style) {
|
|
764
|
+
let html = readFileSync(file, 'utf8');
|
|
765
|
+
const loc = locateEditEl(html, key);
|
|
766
|
+
if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
|
|
767
|
+
let openTag = html.slice(loc.openStart, loc.openEnd + 1);
|
|
768
|
+
if (style) openTag = setAttr(openTag, 'style', style);
|
|
769
|
+
html = html.slice(0, loc.openStart) + openTag + '\n' + (innerHTML || '') + '\n' + html.slice(loc.innerEnd);
|
|
770
|
+
writeFileSync(file, html);
|
|
771
|
+
return { region: key, status: 'written', file: basename(file) };
|
|
772
|
+
}
|
|
773
|
+
// Structural navigation for component-file edits (dependency-free).
|
|
774
|
+
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
775
|
+
const escapeText = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
776
|
+
function childAt(html, from, to, idx) { // nth element child within [from,to), skipping comments/text
|
|
777
|
+
let i = from, count = 0;
|
|
778
|
+
while (i < to) {
|
|
779
|
+
const lt = html.indexOf('<', i); if (lt < 0 || lt >= to) return null;
|
|
780
|
+
if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt); i = e < 0 ? to : e + 3; continue; }
|
|
781
|
+
if (html[lt + 1] === '/') { i = html.indexOf('>', lt) + 1; continue; }
|
|
782
|
+
const nameM = /^<([a-zA-Z][\w-]*)/.exec(html.slice(lt)); if (!nameM) { i = lt + 1; continue; }
|
|
783
|
+
const tag = nameM[1], openEnd = tagEnd(html, lt); if (openEnd < 0) return null;
|
|
784
|
+
const selfClose = html[openEnd - 1] === '/' || VOID_TAGS.has(tag.toLowerCase());
|
|
785
|
+
let innerStart = openEnd + 1, innerEnd = innerStart, closeEnd = openEnd + 1;
|
|
786
|
+
if (!selfClose) { const cl = matchingClose(html, tag, innerStart); if (!cl) return null; innerEnd = cl.start; closeEnd = cl.end; }
|
|
787
|
+
if (count === idx) return { tag, tagStart: lt, openEnd, innerStart, innerEnd, closeEnd };
|
|
788
|
+
count++; i = closeEnd;
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
function navigateToNode(html, path) { // path indices are relative to the component's first top-level element ('' = the root itself)
|
|
793
|
+
let node = childAt(html, 0, html.length, 0); if (!node) return null;
|
|
794
|
+
if (!path) return node;
|
|
795
|
+
for (const i of path.split('.').map(Number)) { node = childAt(html, node.innerStart, node.innerEnd, i); if (!node) return null; }
|
|
796
|
+
return node;
|
|
797
|
+
}
|
|
798
|
+
function resolveComponentFile(manifest, name) {
|
|
799
|
+
const all = [...(manifest.preloadedComponents || []), ...(manifest.components || [])];
|
|
800
|
+
const listed = all.find(p => String(p).split('/').pop().replace('.html', '') === name);
|
|
801
|
+
if (listed) return listed;
|
|
802
|
+
// Convention component: components/<name>.html needs no manifest entry.
|
|
803
|
+
const rel = 'components/' + name + '.html';
|
|
804
|
+
const file = safeResolve('/' + rel);
|
|
805
|
+
return file && isFile(file) ? rel : null;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Theme var write: rewrite (or append) a single `--var: value;` in the target CSS file.
|
|
809
|
+
// Scoped vars carry their file (data-edit-theme-file); global vars fall back to the
|
|
810
|
+
// standard theme file. Surgical — rewrites the existing declaration in place, else
|
|
811
|
+
// appends into the first :root{} block, else creates one.
|
|
812
|
+
const DEFAULT_THEME_FILE = 'styles/core/manifest.theme.css';
|
|
813
|
+
function writeThemeVar(p) {
|
|
814
|
+
if (!/^--[\w-]+$/.test(p.var || '')) return { region: p.var, status: 'error', reason: 'bad var name' };
|
|
815
|
+
const rel = (p.file || DEFAULT_THEME_FILE).replace(/^\//, '');
|
|
816
|
+
const file = safeResolve('/' + rel);
|
|
817
|
+
if (!file) return { region: p.var, status: 'error', reason: `path outside root: ${rel}` };
|
|
818
|
+
let css = isFile(file) ? readFileSync(file, 'utf8') : '';
|
|
819
|
+
const decl = `${p.var}: ${p.value};`;
|
|
820
|
+
const declRe = new RegExp(`(${escapeReg(p.var)}\\s*:)[^;]*;`);
|
|
821
|
+
if (declRe.test(css)) css = css.replace(declRe, `$1 ${p.value};`); // rewrite in place
|
|
822
|
+
else if (/:root\s*[^{]*\{/.test(css)) css = css.replace(/(:root\s*[^{]*\{)/, `$1\n ${decl}`); // append into :root
|
|
823
|
+
else css = `:root {\n ${decl}\n}\n${css}`; // create :root
|
|
824
|
+
writeFileSync(file, css);
|
|
825
|
+
return { region: `${p.scope ? p.scope + ':' : ''}${p.var}`, status: 'written', file: rel };
|
|
826
|
+
}
|
|
827
|
+
const modParamIn = (openTag, attr) => { const m = openTag.match(new RegExp(escapeReg(attr) + `\\s*=\\s*("[^"]*"|'[^']*')`)); if (!m) return null; const mm = m[1].slice(1, -1).match(/\$modify\(['"]([\w-]+)['"]\)/); return mm ? mm[1] : null; };
|
|
828
|
+
const allModifyParams = (html) => { const s = new Set(); let m; const re = /\$modify\(['"]([\w-]+)['"]\)/g; while ((m = re.exec(html))) s.add(m[1]); return [...s]; };
|
|
829
|
+
const removeAttr = (openTag, name) => openTag.replace(new RegExp(`\\s${escapeReg(name)}\\s*=\\s*(?:"[^"]*"|'[^']*')`, 'g'), '');
|
|
830
|
+
const classOf = (openTag) => { const m = openTag.match(/\sclass\s*=\s*("[^"]*"|'[^']*')/); return m ? m[1].slice(1, -1) : ''; };
|
|
831
|
+
|
|
832
|
+
// Component edits, per node (structural path), per prop (text|class), routed by scope.
|
|
833
|
+
// main → edit the component source literal/class (affects every instance)
|
|
834
|
+
// instance → set the $modify instance attr; AUTO-PROMOTE a plain node first
|
|
835
|
+
// (text → x-text="$modify('p_…') ?? 'orig'"; class → :class="$modify('c_…') ?? 'orig'")
|
|
836
|
+
// reverts → remove the instance's $modify attrs so it falls back to the component default
|
|
837
|
+
function writeComponentEdits(p, manifest) {
|
|
838
|
+
const compRel = resolveComponentFile(manifest, p.component);
|
|
839
|
+
if (!compRel) return { region: p.region, status: 'error', reason: `component '${p.component}' not registered` };
|
|
840
|
+
const compFile = safeResolve(compRel);
|
|
841
|
+
if (!compFile || !isFile(compFile)) return { region: p.region, status: 'error', reason: `component file not found: ${compRel}` };
|
|
842
|
+
const indexFile = join(root, 'index.html'), instanceAttrs = {}, removeNames = new Set(), applied = [];
|
|
843
|
+
for (const ed of (p.edits || [])) {
|
|
844
|
+
const prop = ed.prop || 'text';
|
|
845
|
+
let html = readFileSync(compFile, 'utf8');
|
|
846
|
+
const node = navigateToNode(html, ed.path);
|
|
847
|
+
if (!node) { applied.push({ path: ed.path, status: 'not-found' }); continue; }
|
|
848
|
+
let openTag = html.slice(node.tagStart, node.openEnd + 1);
|
|
849
|
+
const seg = ed.path === '' ? 'root' : ed.path.replace(/\./g, '_');
|
|
850
|
+
|
|
851
|
+
if (prop === 'class') {
|
|
852
|
+
const param = modParamIn(openTag, ':class');
|
|
853
|
+
if (p.scope === 'main') { writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1)); applied.push({ path: ed.path, prop, status: 'main' }); }
|
|
854
|
+
else if (param) { instanceAttrs[param] = ed.value; applied.push({ path: ed.path, prop, status: 'instance', param }); }
|
|
855
|
+
else { const name = 'c_' + seg; writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, ':class', `$modify('${name}') ?? '${classOf(openTag).replace(/'/g, "\\'")}'`) + html.slice(node.openEnd + 1)); instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name }); }
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// text — value is innerHTML; may contain nested elements (<i>/<br>), bound via x-html.
|
|
860
|
+
const hasMarkup = /<[a-z!\/][\s\S]*>/i.test(ed.value);
|
|
861
|
+
const param = modParamIn(openTag, 'x-html') || modParamIn(openTag, 'x-text');
|
|
862
|
+
if (p.scope === 'main') {
|
|
863
|
+
if (param) writeFileSync(compFile, html.slice(0, node.tagStart) + openTag.replace(/((?:\?\?|\|\|)\s*)(['"]).*?\2/, `$1'${ed.value.replace(/'/g, "\\'")}'`) + html.slice(node.openEnd + 1));
|
|
864
|
+
else writeFileSync(compFile, html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd)); // raw HTML literal
|
|
865
|
+
applied.push({ path: ed.path, prop, status: 'main' });
|
|
866
|
+
} else if (param) {
|
|
867
|
+
if (hasMarkup && /\sx-text\s*=/.test(openTag) && !/\sx-html\s*=/.test(openTag)) { writeFileSync(compFile, html.slice(0, node.tagStart) + openTag.replace(/\sx-text(\s*=)/, ' x-html$1') + html.slice(node.openEnd + 1)); }
|
|
868
|
+
instanceAttrs[param] = ed.value; applied.push({ path: ed.path, prop, status: 'instance', param });
|
|
869
|
+
} else { // PROMOTE: x-html if rich, else x-text
|
|
870
|
+
const name = 'p_' + seg, bind = hasMarkup ? 'x-html' : 'x-text';
|
|
871
|
+
const orig = html.slice(node.innerStart, node.innerEnd).trim().replace(/'/g, "\\'");
|
|
872
|
+
writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, bind, `$modify('${name}') ?? '${orig}'`) + html.slice(node.openEnd + 1));
|
|
873
|
+
instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name, bind });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (p.scope === 'instance' && (p.reverts || []).length) {
|
|
877
|
+
const html = readFileSync(compFile, 'utf8');
|
|
878
|
+
for (const rp of p.reverts) {
|
|
879
|
+
if (rp === '*') { allModifyParams(html).forEach(n => removeNames.add(n)); applied.push({ path: '*', status: 'revert-all' }); continue; }
|
|
880
|
+
const node = navigateToNode(html, rp); if (!node) continue;
|
|
881
|
+
const openTag = html.slice(node.tagStart, node.openEnd + 1);
|
|
882
|
+
[modParamIn(openTag, 'x-text'), modParamIn(openTag, 'x-html'), modParamIn(openTag, ':class')].forEach(n => { if (n) removeNames.add(n); });
|
|
883
|
+
applied.push({ path: rp, status: 'reverted' });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const inst = (Object.keys(instanceAttrs).length || removeNames.size) ? writeComponentInstance(indexFile, p.region, instanceAttrs, [...removeNames]) : null;
|
|
887
|
+
return { region: p.region, status: 'written', scope: p.scope, file: basename(compFile), applied, instance: inst && inst.status };
|
|
888
|
+
}
|
|
889
|
+
function writeComponentInstance(file, key, overrides, removals) {
|
|
890
|
+
let html = readFileSync(file, 'utf8');
|
|
891
|
+
const loc = locateEditEl(html, key);
|
|
892
|
+
if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
|
|
893
|
+
const inner = html.slice(loc.innerStart, loc.innerEnd);
|
|
894
|
+
const cm = /<x-[\w-]+/.exec(inner); // first component instance in the region
|
|
895
|
+
if (!cm) return { region: key, status: 'error', reason: 'no <x-*> instance in region' };
|
|
896
|
+
const instStart = loc.innerStart + cm.index, instEnd = tagEnd(html, instStart);
|
|
897
|
+
if (instEnd < 0) return { region: key, status: 'error', reason: 'malformed instance tag' };
|
|
898
|
+
let openTag = html.slice(instStart, instEnd + 1);
|
|
899
|
+
for (const [k, v] of Object.entries(overrides || {})) openTag = setAttr(openTag, k, v);
|
|
900
|
+
for (const name of (removals || [])) openTag = removeAttr(openTag, name);
|
|
901
|
+
html = html.slice(0, instStart) + openTag + html.slice(instEnd + 1);
|
|
902
|
+
writeFileSync(file, html);
|
|
903
|
+
return { region: key, status: 'written', file: basename(file), applied: Object.keys(overrides || {}), removed: removals || [] };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// No-op service worker served at /sw.js when the project has none: a stale
|
|
907
|
+
// production worker (cached shell from a previous deploy) can never hold a
|
|
908
|
+
// dev session. It replaces that worker at the next update check, clears the
|
|
909
|
+
// framework's caches and unregisters itself.
|
|
910
|
+
const NOOP_SW = `// mnfst-run: no-op service worker (unregisters itself, clears Manifest caches)
|
|
911
|
+
self.addEventListener('install', function () { self.skipWaiting(); });
|
|
912
|
+
self.addEventListener('activate', function (e) {
|
|
913
|
+
e.waitUntil((async function () {
|
|
914
|
+
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 (_) {}
|
|
915
|
+
try { await self.registration.unregister(); } catch (_) {}
|
|
916
|
+
})());
|
|
917
|
+
});
|
|
918
|
+
`;
|
|
919
|
+
|
|
500
920
|
// Resolve a request path against `root` and refuse anything that escapes.
|
|
501
921
|
// `path.join` does NOT prevent `..` traversal — `join('/a/b', '/../../etc/passwd')`
|
|
502
922
|
// returns `/etc/passwd`. Use `path.resolve` + an explicit prefix check.
|
|
@@ -571,6 +991,75 @@ function isLocalOrigin(origin, port) {
|
|
|
571
991
|
}
|
|
572
992
|
|
|
573
993
|
// --- HTTP server ---
|
|
994
|
+
// --- Appwrite dev proxy (see APPWRITE_PROXY_TARGET above) ---
|
|
995
|
+
|
|
996
|
+
// Rewrite upstream Set-Cookie so the session cookie is storable + sent on
|
|
997
|
+
// http://localhost: drop Domain (defaults to our host), drop Secure (we're http),
|
|
998
|
+
// and downgrade SameSite=None → Lax (None requires Secure, which we just removed).
|
|
999
|
+
function rewriteAppwriteSetCookie(values) {
|
|
1000
|
+
return (Array.isArray(values) ? values : [values]).map(v => v
|
|
1001
|
+
.replace(/;\s*Domain=[^;]*/ig, '')
|
|
1002
|
+
.replace(/;\s*Secure\b/ig, '')
|
|
1003
|
+
.replace(/;\s*SameSite=None/ig, '; SameSite=Lax'));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// /_appwrite/v1/account?x → /v1/account?x (strip our prefix, keep path + query)
|
|
1007
|
+
function appwriteUpstreamPath(reqUrl, urlPath) {
|
|
1008
|
+
const search = reqUrl.includes('?') ? reqUrl.slice(reqUrl.indexOf('?')) : '';
|
|
1009
|
+
return urlPath.slice(APPWRITE_PROXY_PREFIX.length) + search;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function proxyAppwriteHttp(req, res, urlPath) {
|
|
1013
|
+
let u;
|
|
1014
|
+
try { u = new URL(APPWRITE_PROXY_TARGET); } catch { res.writeHead(502); res.end('bad APPWRITE_PROXY_TARGET'); return; }
|
|
1015
|
+
const headers = { ...req.headers, host: u.host };
|
|
1016
|
+
delete headers.connection;
|
|
1017
|
+
// Force cookie-only auth. Appwrite emits X-Fallback-Cookies (because the
|
|
1018
|
+
// forwarded Origin looks cross-origin) and the SDK then replays it on every
|
|
1019
|
+
// request — but the Presences endpoint REJECTS any request carrying the
|
|
1020
|
+
// fallback, even with a valid session cookie present. Strip it both ways so
|
|
1021
|
+
// the real first-party cookie (which the proxy makes same-origin) is used.
|
|
1022
|
+
delete headers['x-fallback-cookies'];
|
|
1023
|
+
const transport = u.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
1024
|
+
const upstream = transport({
|
|
1025
|
+
protocol: u.protocol, hostname: u.hostname,
|
|
1026
|
+
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
1027
|
+
method: req.method, path: appwriteUpstreamPath(req.url, urlPath), headers,
|
|
1028
|
+
}, (up) => {
|
|
1029
|
+
const outHeaders = { ...up.headers };
|
|
1030
|
+
delete outHeaders.connection; delete outHeaders['transfer-encoding'];
|
|
1031
|
+
delete outHeaders['x-fallback-cookies']; // keep the SDK out of localStorage-fallback mode
|
|
1032
|
+
if (up.headers['set-cookie']) outHeaders['set-cookie'] = rewriteAppwriteSetCookie(up.headers['set-cookie']);
|
|
1033
|
+
res.writeHead(up.statusCode || 502, outHeaders);
|
|
1034
|
+
up.pipe(res);
|
|
1035
|
+
});
|
|
1036
|
+
upstream.on('error', (e) => { try { res.writeHead(502, { 'Content-Type': 'text/plain' }); res.end('appwrite proxy error: ' + e.message); } catch { /* client gone */ } });
|
|
1037
|
+
req.pipe(upstream);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Raw WebSocket tunnel for Appwrite Realtime (wss). Replays the HTTP upgrade over
|
|
1041
|
+
// a TLS socket to the upstream and pipes both ways; the browser's first-party
|
|
1042
|
+
// localhost cookie rides the upgrade headers, so realtime auth works too.
|
|
1043
|
+
function proxyAppwriteWs(req, socket, head, urlPath) {
|
|
1044
|
+
let u;
|
|
1045
|
+
try { u = new URL(APPWRITE_PROXY_TARGET); } catch { socket.destroy(); return; }
|
|
1046
|
+
const upstream = tlsConnect({ host: u.hostname, port: u.port || 443, servername: u.hostname }, () => {
|
|
1047
|
+
const headers = { ...req.headers, host: u.host };
|
|
1048
|
+
delete headers['x-fallback-cookies']; // cookie-only auth (see HTTP proxy)
|
|
1049
|
+
let handshake = `${req.method} ${appwriteUpstreamPath(req.url, urlPath)} HTTP/1.1\r\n`;
|
|
1050
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
1051
|
+
(Array.isArray(v) ? v : [v]).forEach(val => { handshake += `${k}: ${val}\r\n`; });
|
|
1052
|
+
}
|
|
1053
|
+
handshake += '\r\n';
|
|
1054
|
+
upstream.write(handshake);
|
|
1055
|
+
if (head && head.length) upstream.write(head);
|
|
1056
|
+
upstream.pipe(socket);
|
|
1057
|
+
socket.pipe(upstream);
|
|
1058
|
+
});
|
|
1059
|
+
upstream.on('error', () => socket.destroy());
|
|
1060
|
+
socket.on('error', () => upstream.destroy());
|
|
1061
|
+
}
|
|
1062
|
+
|
|
574
1063
|
const server = createServer((req, res) => {
|
|
575
1064
|
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
576
1065
|
|
|
@@ -584,6 +1073,13 @@ const server = createServer((req, res) => {
|
|
|
584
1073
|
return;
|
|
585
1074
|
}
|
|
586
1075
|
|
|
1076
|
+
// Appwrite dev proxy — forward to the configured Appwrite origin so the
|
|
1077
|
+
// session cookie is first-party (see APPWRITE_PROXY_TARGET).
|
|
1078
|
+
if (APPWRITE_PROXY_TARGET && urlPath.startsWith(APPWRITE_PROXY_PREFIX + '/')) {
|
|
1079
|
+
proxyAppwriteHttp(req, res, urlPath);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
587
1083
|
// Identity endpoint: lets `mnfst-run` (and `--list`) confirm that a server
|
|
588
1084
|
// on a registered port really is OUR server for the expected root, not
|
|
589
1085
|
// some unrelated process that happened to inherit a recycled PID/port.
|
|
@@ -657,9 +1153,69 @@ const server = createServer((req, res) => {
|
|
|
657
1153
|
return;
|
|
658
1154
|
}
|
|
659
1155
|
|
|
1156
|
+
// Edit-plugin B-side write-back (SPIKE, dev-only). POST + same-origin. Currently
|
|
1157
|
+
// handles the `data` regime: reorder the source CSV/JSON for a registered data
|
|
1158
|
+
// source. static/component regimes are reported unsupported (need an HTML parser).
|
|
1159
|
+
// Turnkey AI relay — same-origin chat proxy; key held server-side, never in
|
|
1160
|
+
// the browser. Inert (404) unless manifest.json has an `ai` block. No key →
|
|
1161
|
+
// mock stream so keyless dev works; add ANTHROPIC_API_KEY to .env for real.
|
|
1162
|
+
if (urlPath === '/_ai/chat') {
|
|
1163
|
+
if (!aiConfig) { res.writeHead(404); res.end(); return; }
|
|
1164
|
+
if (req.method !== 'POST') { res.writeHead(405, { 'Allow': 'POST' }); res.end(); return; }
|
|
1165
|
+
if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) { res.writeHead(403); res.end(); return; }
|
|
1166
|
+
let raw = '';
|
|
1167
|
+
req.on('data', c => { raw += c; if (raw.length > 25e6) req.destroy(); }); // 25MB cap (attachments)
|
|
1168
|
+
req.on('end', async () => {
|
|
1169
|
+
let payload = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { res.writeHead(400); res.end('bad json'); return; }
|
|
1170
|
+
try { if (aiKey) await streamRealAi(res, payload); else streamMockAi(res); }
|
|
1171
|
+
catch (e) { try { res.writeHead(500); res.end(String(e && e.message || e)); } catch (_) {} }
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (urlPath === '/__edit/save') {
|
|
1177
|
+
if (!EDIT_ENABLED) { res.writeHead(404); res.end(); return; } // opt-in (--edit / MNFST_EDIT=1): source write-back is authoring-only
|
|
1178
|
+
if (req.method !== 'POST') { res.writeHead(405, { 'Allow': 'POST' }); res.end(); return; }
|
|
1179
|
+
if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) { res.writeHead(403); res.end(); return; }
|
|
1180
|
+
let raw = '';
|
|
1181
|
+
req.on('data', c => { raw += c; if (raw.length > 1e6) req.destroy(); });
|
|
1182
|
+
req.on('end', () => {
|
|
1183
|
+
let patches; try { patches = JSON.parse(raw); } catch { res.writeHead(400); res.end('bad json'); return; }
|
|
1184
|
+
if (!Array.isArray(patches)) patches = [patches];
|
|
1185
|
+
let manifest = {}; try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); } catch {}
|
|
1186
|
+
const indexFile = join(root, 'index.html');
|
|
1187
|
+
const results = patches.map(p => {
|
|
1188
|
+
try {
|
|
1189
|
+
if (p.kind === 'data') {
|
|
1190
|
+
const src = (manifest.data || {})[p.source];
|
|
1191
|
+
const rel = typeof src === 'string' ? src : null;
|
|
1192
|
+
if (!rel) return { region: p.region, status: 'error', reason: `data source '${p.source}' is not a plain file path` };
|
|
1193
|
+
const file = safeResolve(rel);
|
|
1194
|
+
if (!file || !isFile(file)) return { region: p.region, status: 'error', reason: `file not found: ${rel}` };
|
|
1195
|
+
return { region: p.region, status: reorderDataFile(file, p.order) ? 'written' : 'noop', file: rel };
|
|
1196
|
+
}
|
|
1197
|
+
if (p.kind === 'data-val') return writeDataValue(p, manifest);
|
|
1198
|
+
if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order);
|
|
1199
|
+
if (p.kind === 'component') return writeComponentEdits(p, manifest);
|
|
1200
|
+
if (p.kind === 'theme') return writeThemeVar(p);
|
|
1201
|
+
return { region: p.region, status: 'skipped', reason: `unknown kind ${p.kind}` };
|
|
1202
|
+
} catch (e) { return { region: p.region, status: 'error', reason: e.message }; }
|
|
1203
|
+
});
|
|
1204
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1205
|
+
res.end(JSON.stringify({ results }));
|
|
1206
|
+
});
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
660
1210
|
const exact = safeResolve(urlPath);
|
|
661
1211
|
if (exact && isFile(exact)) return serveFile(res, exact);
|
|
662
1212
|
|
|
1213
|
+
if (urlPath === '/sw.js') {
|
|
1214
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
1215
|
+
res.end(NOOP_SW);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
663
1219
|
const indexPath = safeResolve(urlPath.replace(/\/$/, '') + '/index.html');
|
|
664
1220
|
if (indexPath && isFile(indexPath)) return serveFile(res, indexPath);
|
|
665
1221
|
|
|
@@ -748,6 +1304,16 @@ function startProxy(listenPort, upstreamPort) {
|
|
|
748
1304
|
watchUpstream(upstreamPort);
|
|
749
1305
|
}
|
|
750
1306
|
|
|
1307
|
+
// Appwrite Realtime (WebSocket) proxy — same first-party-cookie rationale as the
|
|
1308
|
+
// HTTP proxy. Without it, switching the endpoint to the proxy would break realtime.
|
|
1309
|
+
if (APPWRITE_PROXY_TARGET) {
|
|
1310
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1311
|
+
const p = (req.url || '').split('?')[0];
|
|
1312
|
+
if (p.startsWith(APPWRITE_PROXY_PREFIX + '/')) proxyAppwriteWs(req, socket, head, p);
|
|
1313
|
+
else socket.destroy();
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
751
1317
|
function tryListen(p, attempt = 0) {
|
|
752
1318
|
if (attempt > 20) {
|
|
753
1319
|
console.error('mnfst-run: could not find a free port after 20 attempts.');
|