sitedrift 0.1.0 → 0.2.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/AGENTS.md +101 -0
- package/README.md +151 -27
- package/assets/viewer.css +766 -0
- package/assets/viewer.html +159 -0
- package/assets/viewer.js +859 -0
- package/docs/images/sitedrift-collaboration.jpg +0 -0
- package/docs/images/sitedrift-diff.jpg +0 -0
- package/docs/images/sitedrift-mobile.jpg +0 -0
- package/docs/images/sitedrift-split.jpg +0 -0
- package/package.json +19 -4
- package/sitedrift-mcp.mjs +4 -0
- package/sitedrift.mjs +71 -1789
- package/src/agent.mjs +73 -0
- package/src/browser.mjs +9 -0
- package/src/cli.mjs +215 -0
- package/src/http.mjs +21 -0
- package/src/mcp.mjs +324 -0
- package/src/notes.mjs +78 -0
- package/src/proxy.mjs +146 -0
- package/src/server.mjs +171 -0
- package/src/session.mjs +53 -0
- package/src/tls.mjs +115 -0
- package/src/viewer.mjs +38 -0
package/src/notes.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Review notes are a JSON file the server reads/mutates and the viewer polls,
|
|
5
|
+
// making it a shared channel between humans and AI sessions.
|
|
6
|
+
export function createNotes({ notesFile, author }) {
|
|
7
|
+
function load() {
|
|
8
|
+
try {
|
|
9
|
+
const data = JSON.parse(fs.readFileSync(notesFile, 'utf8'));
|
|
10
|
+
if (Array.isArray(data)) return data;
|
|
11
|
+
return Array.isArray(data.notes) ? data.notes : [];
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function save(notes) {
|
|
18
|
+
fs.mkdirSync(path.dirname(notesFile), { recursive: true });
|
|
19
|
+
const tmp = `${notesFile}.${process.pid}.${Date.now()}.tmp`;
|
|
20
|
+
fs.writeFileSync(tmp, JSON.stringify(notes, null, 2), { mode: 0o600 });
|
|
21
|
+
fs.renameSync(tmp, notesFile);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function id() {
|
|
25
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function markdown(notes) {
|
|
29
|
+
if (!notes.length) return '# sitedrift review notes\n\n_No notes yet._\n';
|
|
30
|
+
const lines = ['# sitedrift review notes', ''];
|
|
31
|
+
for (const note of notes) {
|
|
32
|
+
const box = note.done ? '[x]' : '[ ]';
|
|
33
|
+
const where = [note.route && note.route !== '/' ? note.route : '', note.side ? note.side.toUpperCase() : '']
|
|
34
|
+
.filter(Boolean).join(' ');
|
|
35
|
+
const tag = where ? ` _(${where})_` : '';
|
|
36
|
+
lines.push(`- ${box} **${note.author || 'note'}:** ${note.text}${tag}`);
|
|
37
|
+
}
|
|
38
|
+
lines.push('');
|
|
39
|
+
return lines.join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyOp(op) {
|
|
43
|
+
let notes = load();
|
|
44
|
+
if (op.op === 'add' && op.text) {
|
|
45
|
+
const text = String(op.text).slice(0, 2000);
|
|
46
|
+
const rawRoute = String(op.route || '/').slice(0, 2048);
|
|
47
|
+
const route = rawRoute.startsWith('/') ? rawRoute : `/${rawRoute}`;
|
|
48
|
+
const who = String(op.author || author || 'note').slice(0, 24);
|
|
49
|
+
const side = op.side === 'dev' || op.side === 'live' ? op.side : null;
|
|
50
|
+
// Skip an identical open note so repeated `--note` seeding doesn't pile up.
|
|
51
|
+
const duplicate = notes.some((note) => !note.done
|
|
52
|
+
&& note.text === text && note.route === route && note.author === who && note.side === side);
|
|
53
|
+
if (!duplicate) {
|
|
54
|
+
notes.push({ id: id(), text, author: who, route, side, done: false, ts: Date.now() });
|
|
55
|
+
if (notes.length > 1000) notes = notes.slice(-1000);
|
|
56
|
+
}
|
|
57
|
+
} else if (op.op === 'remove') {
|
|
58
|
+
if (!notes.some((note) => note.id === op.id)) throw new Error(`Unknown note id: ${op.id}`);
|
|
59
|
+
notes = notes.filter((note) => note.id !== op.id);
|
|
60
|
+
} else if (op.op === 'toggle' || op.op === 'resolve' || op.op === 'reopen') {
|
|
61
|
+
const found = notes.some((note) => note.id === op.id);
|
|
62
|
+
if (!found) throw new Error(`Unknown note id: ${op.id}`);
|
|
63
|
+
notes = notes.map((note) => {
|
|
64
|
+
if (note.id !== op.id) return note;
|
|
65
|
+
const done = op.op === 'toggle' ? !note.done : op.op === 'resolve';
|
|
66
|
+
return { ...note, done };
|
|
67
|
+
});
|
|
68
|
+
} else if (op.op === 'clear') {
|
|
69
|
+
notes = [];
|
|
70
|
+
} else {
|
|
71
|
+
throw new Error(`Unknown notes operation: ${op.op || '(missing)'}`);
|
|
72
|
+
}
|
|
73
|
+
save(notes);
|
|
74
|
+
return notes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { load, save, markdown, applyOp };
|
|
78
|
+
}
|
package/src/proxy.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { send } from './http.mjs';
|
|
2
|
+
|
|
3
|
+
// Reverse-proxies the two origins under /__dev/* and /__live/*, rewriting
|
|
4
|
+
// root-relative URLs so both sites render framed side-by-side. Deliberately
|
|
5
|
+
// strips framing/isolation headers — safe for loopback development only.
|
|
6
|
+
export function createProxy({ devBase, liveBase }) {
|
|
7
|
+
function bridge(side) {
|
|
8
|
+
const script = `(() => {
|
|
9
|
+
const side=${JSON.stringify(side)};
|
|
10
|
+
let linked=false, mirror=false;
|
|
11
|
+
const send=(type,data={})=>parent.postMessage({source:'sitedrift-frame',side,type,...data},'*');
|
|
12
|
+
const root=()=>document.scrollingElement||document.documentElement;
|
|
13
|
+
const route=()=>location.pathname.replace(/^\\/__${side}/,'')+location.search+location.hash||'/';
|
|
14
|
+
const snapshot=()=>{
|
|
15
|
+
const q=(s)=>document.querySelector(s);
|
|
16
|
+
const imgs=[...document.querySelectorAll('img')];
|
|
17
|
+
const title=(document.title||'').trim();
|
|
18
|
+
const description=q('meta[name="description"]')?.content?.trim()||'';
|
|
19
|
+
const canonical=q('link[rel="canonical"]')?.href||'';
|
|
20
|
+
const checks=[
|
|
21
|
+
['Title present',!!title],['Title 30–60 chars',title.length>=30&&title.length<=60,title.length+''],
|
|
22
|
+
['Meta description',!!description],['Description 70–160',description.length>=70&&description.length<=160,description.length+''],
|
|
23
|
+
['Exactly one H1',document.querySelectorAll('h1').length===1,document.querySelectorAll('h1').length+' found'],
|
|
24
|
+
['Canonical link',!!q('link[rel="canonical"]')],['Viewport meta',!!q('meta[name="viewport"]')],
|
|
25
|
+
['html lang',!!document.documentElement.lang],['Open Graph title',!!q('meta[property="og:title"]')],
|
|
26
|
+
['Open Graph image',!!q('meta[property="og:image"]')],
|
|
27
|
+
['Not noindex',!(q('meta[name="robots"]')?.content||'').toLowerCase().includes('noindex')],
|
|
28
|
+
['Favicon',!!q('link[rel~="icon"]')],
|
|
29
|
+
['Images have alt',imgs.every((img)=>img.hasAttribute('alt')),imgs.filter((img)=>!img.hasAttribute('alt')).length+' missing']
|
|
30
|
+
].map(([label,ok,note])=>({label,ok,note}));
|
|
31
|
+
send('ready',{route:route(),meta:{title,description,canonical,heading:q('h1')?.textContent?.trim()||'',
|
|
32
|
+
siteName:q('meta[property="og:site_name"]')?.content?.trim()||'',icon:q('link[rel~="icon"]')?.href||'',checks}});
|
|
33
|
+
send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)});
|
|
34
|
+
};
|
|
35
|
+
addEventListener('message',(event)=>{
|
|
36
|
+
const msg=event.data||{};
|
|
37
|
+
if(msg.source!=='sitedrift-parent'||msg.side!==side)return;
|
|
38
|
+
if(msg.type==='settings'){linked=!!msg.linked;mirror=!!msg.mirror;document.documentElement.style.scrollBehavior='auto';}
|
|
39
|
+
if(msg.type==='scroll'){root().scrollTop=msg.y;}
|
|
40
|
+
if(msg.type==='reload')location.reload();
|
|
41
|
+
});
|
|
42
|
+
addEventListener('scroll',()=>send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)}),{passive:true});
|
|
43
|
+
addEventListener('wheel',(event)=>{if(!linked||!event.deltaY)return;event.preventDefault();send('wheel',{delta:event.deltaY,mode:event.deltaMode,height:innerHeight,y:scrollY});},{passive:false,capture:true});
|
|
44
|
+
addEventListener('keydown',(event)=>{
|
|
45
|
+
const typing=/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)||event.target.isContentEditable;
|
|
46
|
+
if(!typing&&!event.metaKey&&!event.ctrlKey&&!event.altKey&&['r','s','0','/','o','d'].includes(event.key.toLowerCase())){
|
|
47
|
+
event.preventDefault();send('key',{key:event.key.toLowerCase()});return;
|
|
48
|
+
}
|
|
49
|
+
if(linked&&!typing&&!event.metaKey&&!event.ctrlKey&&!event.altKey)send('key',{key:event.key,shift:event.shiftKey,y:scrollY,height:innerHeight,max:Math.max(0,root().scrollHeight-innerHeight)});
|
|
50
|
+
},true);
|
|
51
|
+
addEventListener('click',(event)=>{
|
|
52
|
+
if(!mirror||event.defaultPrevented||event.button!==0||event.metaKey||event.ctrlKey||event.shiftKey||event.altKey)return;
|
|
53
|
+
const link=event.target.closest('a[href]');if(!link||link.target==='_blank'||link.hasAttribute('download'))return;
|
|
54
|
+
const url=new URL(link.href,location.href);if(url.origin!==location.origin||!url.pathname.startsWith('/__'+side))return;
|
|
55
|
+
event.preventDefault();send('navigate',{route:url.pathname.slice(('/__'+side).length)||'/'});
|
|
56
|
+
},true);
|
|
57
|
+
addEventListener('DOMContentLoaded',snapshot,{once:true});if(document.readyState!=='loading')snapshot();
|
|
58
|
+
})();`;
|
|
59
|
+
return `<script>${script.replace(/<\//g, '<\\/')}</script>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function targetFor(side, pathname, search) {
|
|
63
|
+
const base = side === 'dev' ? devBase : liveBase;
|
|
64
|
+
const relative = pathname.replace(new RegExp(`^/__${side}`), '') || '/';
|
|
65
|
+
return new URL(`${relative}${search}`, `${base.href}/`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rewriteRootPaths(body, side) {
|
|
69
|
+
const prefix = `/__${side}`;
|
|
70
|
+
return body
|
|
71
|
+
.replace(/(\b(?:href|src|action|poster)=["'])\/(?!\/)/gi, `$1${prefix}/`)
|
|
72
|
+
.replace(/\bsrcset=(["'])(.*?)\1/gi, (attribute, quote, value) => {
|
|
73
|
+
const rewritten = value.replace(/(^|,\s*)\/(?!\/)/g, `$1${prefix}/`);
|
|
74
|
+
return `srcset=${quote}${rewritten}${quote}`;
|
|
75
|
+
})
|
|
76
|
+
.replace(/url\((["']?)\/(?!\/)/gi, `url($1${prefix}/`)
|
|
77
|
+
.replace(/(["'`])\/(@(?:id|vite|fs)\/|_astro\/)/g, `$1${prefix}/$2`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function proxy(req, res, side, requestUrl) {
|
|
81
|
+
const target = targetFor(side, requestUrl.pathname, requestUrl.search);
|
|
82
|
+
const headers = { ...req.headers, host: target.host };
|
|
83
|
+
delete headers['accept-encoding'];
|
|
84
|
+
delete headers.connection;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const upstream = await fetch(target, {
|
|
88
|
+
method: req.method,
|
|
89
|
+
headers,
|
|
90
|
+
redirect: 'manual',
|
|
91
|
+
});
|
|
92
|
+
const responseHeaders = {};
|
|
93
|
+
upstream.headers.forEach((value, key) => {
|
|
94
|
+
if (![
|
|
95
|
+
'content-encoding',
|
|
96
|
+
'content-length',
|
|
97
|
+
'content-security-policy',
|
|
98
|
+
'content-security-policy-report-only',
|
|
99
|
+
'cross-origin-embedder-policy',
|
|
100
|
+
'cross-origin-opener-policy',
|
|
101
|
+
'cross-origin-resource-policy',
|
|
102
|
+
'transfer-encoding',
|
|
103
|
+
'x-frame-options',
|
|
104
|
+
].includes(key)) {
|
|
105
|
+
responseHeaders[key] = value;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
responseHeaders['cache-control'] = 'no-store';
|
|
109
|
+
|
|
110
|
+
const location = upstream.headers.get('location');
|
|
111
|
+
if (location) {
|
|
112
|
+
const redirected = new URL(location, target);
|
|
113
|
+
if (redirected.origin === target.origin) {
|
|
114
|
+
responseHeaders.location = `/__${side}${redirected.pathname}${redirected.search}${redirected.hash}`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const type = upstream.headers.get('content-type') || '';
|
|
119
|
+
// Rewrite markup/CSS/JS always; rewrite JSON only on the dev side (Vite
|
|
120
|
+
// manifests) so live API payloads with path-like strings aren't corrupted.
|
|
121
|
+
const rewritable = /text\/html|text\/css|javascript/.test(type)
|
|
122
|
+
|| (side === 'dev' && /application\/json/.test(type));
|
|
123
|
+
if (rewritable) {
|
|
124
|
+
let body = rewriteRootPaths(await upstream.text(), side);
|
|
125
|
+
if (/text\/html/.test(type)) {
|
|
126
|
+
const injected = bridge(side);
|
|
127
|
+
body = body.includes('</head>') ? body.replace('</head>', `${injected}</head>`) : `${injected}${body}`;
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(upstream.status, responseHeaders);
|
|
130
|
+
res.end(body);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
res.writeHead(upstream.status, responseHeaders);
|
|
135
|
+
res.end(Buffer.from(await upstream.arrayBuffer()));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
send(
|
|
138
|
+
res,
|
|
139
|
+
502,
|
|
140
|
+
`Could not load ${target.href}\n\n${error.message}\n\nStart the dev server with: site dev`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { proxy };
|
|
146
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import { send, readBody } from './http.mjs';
|
|
6
|
+
import { createNotes } from './notes.mjs';
|
|
7
|
+
import { createProxy } from './proxy.mjs';
|
|
8
|
+
import { assets, renderViewer, VIEWER_VERSION } from './viewer.mjs';
|
|
9
|
+
|
|
10
|
+
function sendAsset(res, body, type) {
|
|
11
|
+
if (!body) return send(res, 404, 'not found');
|
|
12
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'max-age=86400' });
|
|
13
|
+
res.end(body);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function json(res, status, body) {
|
|
17
|
+
send(res, status, JSON.stringify(body), 'application/json; charset=utf-8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function authorized(req, session) {
|
|
21
|
+
const auth = req.headers.authorization || '';
|
|
22
|
+
if (auth !== `Bearer ${session.token}`) return false;
|
|
23
|
+
const referer = req.headers.referer || '';
|
|
24
|
+
if (!referer) return true;
|
|
25
|
+
try {
|
|
26
|
+
const pathname = new URL(referer).pathname;
|
|
27
|
+
return !pathname.startsWith('/__dev') && !pathname.startsWith('/__live');
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createServer(config, tls, session, { control = true, side: frameSide } = {}) {
|
|
34
|
+
const { devBase, liveBase, vaultDir } = config;
|
|
35
|
+
const notes = createNotes(config);
|
|
36
|
+
const { proxy } = createProxy(config);
|
|
37
|
+
|
|
38
|
+
const handler = async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const hostname = new URL(`http://${req.headers.host}`).hostname.replace(/^\[|\]$/g, '');
|
|
41
|
+
if (hostname !== config.host) {
|
|
42
|
+
send(res, 421, 'misdirected request');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
send(res, 400, 'invalid host');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const requestUrl = new URL(req.url || '/', `http://${config.host}:${config.port}`);
|
|
50
|
+
const { pathname } = requestUrl;
|
|
51
|
+
|
|
52
|
+
const isNotes = pathname === '/notes' || pathname === '/api/v1/notes';
|
|
53
|
+
const isSave = pathname === '/notes/save' || pathname === '/api/v1/notes/save';
|
|
54
|
+
|
|
55
|
+
if (!control && frameSide && !pathname.startsWith(`/__${frameSide}`)) {
|
|
56
|
+
const referer = req.headers.referer || '';
|
|
57
|
+
if (referer.includes(`/__${frameSide}/`)) {
|
|
58
|
+
requestUrl.pathname = `/__${frameSide}${pathname}`;
|
|
59
|
+
await proxy(req, res, frameSide, requestUrl);
|
|
60
|
+
} else {
|
|
61
|
+
send(res, 404, 'not found');
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!control && !pathname.startsWith('/__dev') && !pathname.startsWith('/__live')) {
|
|
67
|
+
const referer = req.headers.referer || '';
|
|
68
|
+
if (referer.includes('/__dev/')) {
|
|
69
|
+
requestUrl.pathname = `/__dev${pathname}`;
|
|
70
|
+
await proxy(req, res, 'dev', requestUrl);
|
|
71
|
+
} else if (referer.includes('/__live/')) {
|
|
72
|
+
requestUrl.pathname = `/__live${pathname}`;
|
|
73
|
+
await proxy(req, res, 'live', requestUrl);
|
|
74
|
+
} else {
|
|
75
|
+
send(res, 404, 'not found');
|
|
76
|
+
}
|
|
77
|
+
} else if (pathname === '/health') {
|
|
78
|
+
send(res, 200, JSON.stringify({
|
|
79
|
+
dev: devBase.href.replace(/\/$/, ''),
|
|
80
|
+
live: liveBase.href.replace(/\/$/, ''),
|
|
81
|
+
version: VIEWER_VERSION,
|
|
82
|
+
}), 'application/json; charset=utf-8');
|
|
83
|
+
} else if (pathname === '/api/v1/session') {
|
|
84
|
+
if (!authorized(req, session)) {
|
|
85
|
+
json(res, 401, { error: 'unauthorized' });
|
|
86
|
+
} else {
|
|
87
|
+
json(res, 200, {
|
|
88
|
+
session: {
|
|
89
|
+
version: session.version,
|
|
90
|
+
url: session.url,
|
|
91
|
+
dev: session.dev,
|
|
92
|
+
live: session.live,
|
|
93
|
+
notesFile: session.notesFile,
|
|
94
|
+
startedAt: session.startedAt,
|
|
95
|
+
},
|
|
96
|
+
capabilities: ['notes:list', 'notes:add', 'notes:resolve', 'notes:reopen', 'notes:remove', 'notes:clear'],
|
|
97
|
+
notes: notes.load(),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
} else if (isNotes) {
|
|
101
|
+
if (!authorized(req, session)) {
|
|
102
|
+
json(res, 401, { error: 'unauthorized' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (req.method === 'GET') {
|
|
106
|
+
json(res, 200, { notes: notes.load() });
|
|
107
|
+
} else if (req.method === 'POST') {
|
|
108
|
+
// Require a JSON content-type so cross-origin writes need a preflight the
|
|
109
|
+
// server (no CORS headers) will fail — closes the text/plain CSRF path.
|
|
110
|
+
if (!(req.headers['content-type'] || '').includes('application/json')) {
|
|
111
|
+
json(res, 415, { error: 'notes require Content-Type: application/json' });
|
|
112
|
+
} else {
|
|
113
|
+
try {
|
|
114
|
+
const op = JSON.parse((await readBody(req)) || '{}');
|
|
115
|
+
json(res, 200, { notes: notes.applyOp(op) });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
json(res, 400, { error: error.message });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
send(res, 405, 'method not allowed');
|
|
122
|
+
}
|
|
123
|
+
} else if (pathname === '/notes.md') {
|
|
124
|
+
send(res, 200, notes.markdown(notes.load()), 'text/markdown; charset=utf-8');
|
|
125
|
+
} else if (isSave) {
|
|
126
|
+
if (!authorized(req, session)) {
|
|
127
|
+
json(res, 401, { error: 'unauthorized' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (req.method !== 'POST') {
|
|
131
|
+
send(res, 405, 'method not allowed');
|
|
132
|
+
} else if (!vaultDir) {
|
|
133
|
+
json(res, 400, { ok: false, error: 'no vault configured' });
|
|
134
|
+
} else {
|
|
135
|
+
try {
|
|
136
|
+
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
|
|
137
|
+
const file = `${vaultDir}/sitedrift-review-${stamp}.md`;
|
|
138
|
+
fs.writeFileSync(file, notes.markdown(notes.load()));
|
|
139
|
+
json(res, 200, { ok: true, path: file });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
json(res, 500, { ok: false, error: error.message });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} else if (pathname === '/icon.svg') {
|
|
145
|
+
sendAsset(res, assets.icon, 'image/svg+xml; charset=utf-8');
|
|
146
|
+
} else if (pathname === '/viewer.css') {
|
|
147
|
+
sendAsset(res, assets.css, 'text/css; charset=utf-8');
|
|
148
|
+
} else if (pathname === '/viewer.js') {
|
|
149
|
+
sendAsset(res, assets.js, 'text/javascript; charset=utf-8');
|
|
150
|
+
} else if (pathname.startsWith('/__dev')) {
|
|
151
|
+
await proxy(req, res, 'dev', requestUrl);
|
|
152
|
+
} else if (pathname.startsWith('/__live')) {
|
|
153
|
+
await proxy(req, res, 'live', requestUrl);
|
|
154
|
+
} else {
|
|
155
|
+
// A resource requested by a proxied page (no /__side prefix) is routed by
|
|
156
|
+
// its referer; everything else is the viewer shell.
|
|
157
|
+
const referer = req.headers.referer || '';
|
|
158
|
+
if (referer.includes('/__dev/')) {
|
|
159
|
+
requestUrl.pathname = `/__dev${pathname}`;
|
|
160
|
+
await proxy(req, res, 'dev', requestUrl);
|
|
161
|
+
} else if (referer.includes('/__live/')) {
|
|
162
|
+
requestUrl.pathname = `/__live${pathname}`;
|
|
163
|
+
await proxy(req, res, 'live', requestUrl);
|
|
164
|
+
} else {
|
|
165
|
+
send(res, 200, renderViewer(config, session), 'text/html; charset=utf-8');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return tls ? https.createServer(tls, handler) : http.createServer(handler);
|
|
171
|
+
}
|
package/src/session.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SESSION_DIR = path.join(os.homedir(), '.sitedrift', 'sessions');
|
|
7
|
+
|
|
8
|
+
export function sessionFile(port) {
|
|
9
|
+
return path.join(SESSION_DIR, `${port}.json`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createSession(config, scheme) {
|
|
13
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
14
|
+
const host = config.host.includes(':') ? `[${config.host}]` : config.host;
|
|
15
|
+
const url = `${scheme}://${host}:${config.port}`;
|
|
16
|
+
const frameUrls = {
|
|
17
|
+
dev: `${scheme}://${host}:${config.port + 1}`,
|
|
18
|
+
live: `${scheme}://${host}:${config.port + 2}`,
|
|
19
|
+
};
|
|
20
|
+
const session = {
|
|
21
|
+
version: 1,
|
|
22
|
+
pid: process.pid,
|
|
23
|
+
url,
|
|
24
|
+
frameUrls,
|
|
25
|
+
token,
|
|
26
|
+
dev: config.devBase.href.replace(/\/$/, ''),
|
|
27
|
+
live: config.liveBase.href.replace(/\/$/, ''),
|
|
28
|
+
notesFile: config.notesFile,
|
|
29
|
+
startedAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
return session;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeSession(config, session) {
|
|
35
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
|
|
36
|
+
fs.writeFileSync(sessionFile(config.port), JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function removeSession(config) {
|
|
40
|
+
try {
|
|
41
|
+
const file = sessionFile(config.port);
|
|
42
|
+
const current = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
if (current.pid === process.pid) fs.unlinkSync(file);
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readSession(port) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(sessionFile(port), 'utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`No running sitedrift session found on port ${port}.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/tls.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// Cached auto-generated cert lives here so --https is instant after the first run
|
|
7
|
+
// and a mkcert-trusted CA only has to be installed once.
|
|
8
|
+
const CERT_DIR = path.join(os.homedir(), '.sitedrift');
|
|
9
|
+
const CERT_FILE = path.join(CERT_DIR, 'localhost.pem');
|
|
10
|
+
const KEY_FILE = path.join(CERT_DIR, 'localhost-key.pem');
|
|
11
|
+
const HOSTS = ['localhost', '127.0.0.1', '::1'];
|
|
12
|
+
|
|
13
|
+
function have(cmd, probe) {
|
|
14
|
+
try {
|
|
15
|
+
return !spawnSync(cmd, [probe], { stdio: 'ignore' }).error;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const hasMkcert = () => have('mkcert', '-CAROOT');
|
|
21
|
+
const hasOpenssl = () => have('openssl', 'version');
|
|
22
|
+
|
|
23
|
+
function generateWithMkcert(quiet) {
|
|
24
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
25
|
+
// -install is idempotent; first run may prompt for the OS trust store.
|
|
26
|
+
spawnSync('mkcert', ['-install'], { stdio });
|
|
27
|
+
const r = spawnSync('mkcert', ['-cert-file', CERT_FILE, '-key-file', KEY_FILE, ...HOSTS], { stdio });
|
|
28
|
+
if (r.status !== 0) throw new Error('mkcert failed to generate a certificate.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function generateWithOpenssl() {
|
|
32
|
+
const san = HOSTS.map((h) => (/^[\d.:]+$/.test(h) ? `IP:${h}` : `DNS:${h}`)).join(',');
|
|
33
|
+
const r = spawnSync('openssl', [
|
|
34
|
+
'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
|
35
|
+
'-keyout', KEY_FILE, '-out', CERT_FILE,
|
|
36
|
+
'-days', '825', '-subj', '/CN=localhost',
|
|
37
|
+
'-addext', `subjectAltName=${san}`,
|
|
38
|
+
], { stdio: 'ignore' });
|
|
39
|
+
if (r.status !== 0) throw new Error('openssl failed to generate a certificate.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NO_TOOL = new Error(
|
|
43
|
+
'sitedrift --https needs `mkcert` (recommended) or `openssl`.\n'
|
|
44
|
+
+ ' Install mkcert for zero-warning HTTPS: https://github.com/FiloSottile/mkcert#installation\n'
|
|
45
|
+
+ ' (macOS: brew install mkcert · then run: sitedrift --setup-https)\n'
|
|
46
|
+
+ ' Or bring your own cert: sitedrift --cert <file> --key <file>',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Returns { certFile, keyFile, source, trusted }. `source` is cache | mkcert | openssl.
|
|
50
|
+
function ensureCert({ quiet = true, force = false } = {}) {
|
|
51
|
+
fs.mkdirSync(CERT_DIR, { recursive: true });
|
|
52
|
+
if (!force && fs.existsSync(CERT_FILE) && fs.existsSync(KEY_FILE)) {
|
|
53
|
+
return { certFile: CERT_FILE, keyFile: KEY_FILE, source: 'cache', trusted: true };
|
|
54
|
+
}
|
|
55
|
+
if (hasMkcert()) {
|
|
56
|
+
generateWithMkcert(quiet);
|
|
57
|
+
return { certFile: CERT_FILE, keyFile: KEY_FILE, source: 'mkcert', trusted: true };
|
|
58
|
+
}
|
|
59
|
+
if (hasOpenssl()) {
|
|
60
|
+
generateWithOpenssl();
|
|
61
|
+
return { certFile: CERT_FILE, keyFile: KEY_FILE, source: 'openssl', trusted: false };
|
|
62
|
+
}
|
|
63
|
+
throw NO_TOOL;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function trustSteps(certFile) {
|
|
67
|
+
const steps = {
|
|
68
|
+
darwin: `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certFile}"`,
|
|
69
|
+
linux: `sudo cp "${certFile}" /usr/local/share/ca-certificates/sitedrift.crt && sudo update-ca-certificates`,
|
|
70
|
+
win32: `certutil -addstore -f ROOT "${certFile}"`,
|
|
71
|
+
};
|
|
72
|
+
const here = steps[process.platform];
|
|
73
|
+
return here
|
|
74
|
+
? `Trust it on this machine:\n ${here}\n (or just click through the browser warning each run)`
|
|
75
|
+
: 'Your browser will warn once — click Advanced → Proceed, or trust the cert in your OS store.';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Used by the server: resolve { cert, key } buffers, or null for plain HTTP.
|
|
79
|
+
export function resolveTls(config) {
|
|
80
|
+
if (config.certFile && config.keyFile) {
|
|
81
|
+
return { cert: fs.readFileSync(config.certFile), key: fs.readFileSync(config.keyFile) };
|
|
82
|
+
}
|
|
83
|
+
if (config.https) {
|
|
84
|
+
const c = ensureCert({ quiet: true });
|
|
85
|
+
if (c.source === 'openssl') {
|
|
86
|
+
console.log('sitedrift: generated a self-signed cert (browser will warn).');
|
|
87
|
+
console.log(' For zero warnings: sitedrift --setup-https (needs mkcert)\n');
|
|
88
|
+
}
|
|
89
|
+
return { cert: fs.readFileSync(c.certFile), key: fs.readFileSync(c.keyFile) };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Used by `sitedrift --setup-https`: generate + trust, with guided output.
|
|
95
|
+
export function setupHttps() {
|
|
96
|
+
console.log('sitedrift — HTTPS setup\n');
|
|
97
|
+
if (hasMkcert()) {
|
|
98
|
+
console.log('Found mkcert — generating a locally-trusted certificate.\n');
|
|
99
|
+
const c = ensureCert({ quiet: false, force: true });
|
|
100
|
+
console.log(`\n✓ Local CA installed and trusted cert written:\n ${c.certFile}`);
|
|
101
|
+
console.log('\nStart with HTTPS (no browser warning):\n sitedrift --https');
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (hasOpenssl()) {
|
|
105
|
+
console.log('mkcert not found — using openssl for a self-signed cert.');
|
|
106
|
+
console.log('Tip: `brew install mkcert` gives zero-warning HTTPS instead.\n');
|
|
107
|
+
const c = ensureCert({ quiet: false, force: true });
|
|
108
|
+
console.log(`✓ Self-signed cert written:\n ${c.certFile}\n`);
|
|
109
|
+
console.log(trustSteps(c.certFile));
|
|
110
|
+
console.log('\nThen start: sitedrift --https');
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
console.error(NO_TOOL.message);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
package/src/viewer.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// Bumped when the viewer assets change; busts the ?v= cache and reported in
|
|
4
|
+
// /health so the `site compare` wrapper knows when to restart the server.
|
|
5
|
+
export const VIEWER_VERSION = 28;
|
|
6
|
+
|
|
7
|
+
function readAsset(path) {
|
|
8
|
+
try {
|
|
9
|
+
return fs.readFileSync(new URL(path, import.meta.url), 'utf8');
|
|
10
|
+
} catch {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Loaded once at startup — the viewer is static; only the config blob is per-run.
|
|
16
|
+
export const assets = {
|
|
17
|
+
html: readAsset('../assets/viewer.html'),
|
|
18
|
+
css: readAsset('../assets/viewer.css'),
|
|
19
|
+
js: readAsset('../assets/viewer.js'),
|
|
20
|
+
icon: readAsset('../assets/icon.svg'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function renderViewer({ devBase, liveBase, brand, author, vaultDir }, session) {
|
|
24
|
+
const config = JSON.stringify({
|
|
25
|
+
dev: devBase.href.replace(/\/$/, ''),
|
|
26
|
+
live: liveBase.href.replace(/\/$/, ''),
|
|
27
|
+
brand,
|
|
28
|
+
author,
|
|
29
|
+
vault: !!vaultDir,
|
|
30
|
+
token: session.token,
|
|
31
|
+
api: '/api/v1',
|
|
32
|
+
frameOrigins: session.frameUrls,
|
|
33
|
+
}).replace(/</g, '\\u003c');
|
|
34
|
+
|
|
35
|
+
return assets.html
|
|
36
|
+
.replaceAll('__VERSION__', String(VIEWER_VERSION))
|
|
37
|
+
.replace('__CONFIG__', config);
|
|
38
|
+
}
|