Initial commit: Korrith website, assessment API, PocketBase schema, Docker/Coolify deployment
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
const EVENT = 'session-journal:event';
|
||||
const MAX_TEXT = 2000;
|
||||
const MAX_BATCH = 50;
|
||||
const FLUSH_INTERVAL_MS = 1000;
|
||||
|
||||
let queue = [];
|
||||
let flushTimer = null;
|
||||
let connected = false;
|
||||
|
||||
function flushQueue() {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (!queue.length || !import.meta.hot || !connected) {
|
||||
return;
|
||||
}
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
try {
|
||||
import.meta.hot.send(EVENT, { batch });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function send(kind, data) {
|
||||
if (!import.meta.hot) {
|
||||
return;
|
||||
}
|
||||
queue.push({ kind, t: Date.now(), ...data });
|
||||
if (connected && queue.length >= MAX_BATCH) {
|
||||
flushQueue();
|
||||
} else if (!flushTimer) {
|
||||
flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('vite:ws:connect', () => {
|
||||
connected = true;
|
||||
flushQueue();
|
||||
});
|
||||
import.meta.hot.on('vite:ws:disconnect', () => {
|
||||
connected = false;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushQueue();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', flushQueue);
|
||||
|
||||
function truncate(value) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
const s = String(value);
|
||||
return s.length > MAX_TEXT ? s.slice(0, MAX_TEXT) + '...' : s;
|
||||
}
|
||||
|
||||
function isSensitiveName(name) {
|
||||
return /password|secret|token|apikey|key|auth|credential|cookie/i.test(String(name || ''));
|
||||
}
|
||||
|
||||
function sanitizeUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value), location.href);
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
if (isSensitiveName(key)) {
|
||||
url.searchParams.set(key, '[redacted]');
|
||||
}
|
||||
}
|
||||
return truncate(url.toString());
|
||||
} catch {
|
||||
return truncate(value);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeValue(name, value) {
|
||||
if (isSensitiveName(name)) {
|
||||
const length = value == null ? 0 : String(value).length;
|
||||
return '[redacted:length=' + length + ']';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => summarizeValue(name, item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return summarizeObject(value);
|
||||
}
|
||||
return truncate(value);
|
||||
}
|
||||
|
||||
function summarizeObject(value) {
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(value || {})) {
|
||||
out[key] = summarizeValue(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeRequestBody(body) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
if (body instanceof FormData) {
|
||||
const out = {};
|
||||
for (const [key, val] of body.entries()) {
|
||||
out[key] = val instanceof File
|
||||
? { fileName: truncate(val.name), type: val.type || null, size: val.size }
|
||||
: summarizeValue(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
const out = {};
|
||||
for (const [key, val] of body.entries()) out[key] = summarizeValue(key, val);
|
||||
return out;
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
return summarizeObject(JSON.parse(body));
|
||||
} catch {
|
||||
return truncate(body);
|
||||
}
|
||||
}
|
||||
if (body && typeof body === 'object' && body.constructor === Object) {
|
||||
return summarizeObject(body);
|
||||
}
|
||||
return '[' + Object.prototype.toString.call(body).slice(8, -1) + ' body]';
|
||||
}
|
||||
|
||||
function isSensitive(el) {
|
||||
if (!el) {
|
||||
return false;
|
||||
}
|
||||
if (el.type === 'password') {
|
||||
return true;
|
||||
}
|
||||
return isSensitiveName(el.name || el.id || el.getAttribute('autocomplete') || '');
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return truncate(String(value || '').replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
function labelFromLabelledBy(el) {
|
||||
const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
return '';
|
||||
}
|
||||
const parts = ids
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean)
|
||||
.map((node) => compactText(node.textContent));
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function labelFromForAttr(el) {
|
||||
if (!el.id) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const escapedId = window.CSS && CSS.escape ? CSS.escape(el.id) : el.id;
|
||||
const labelEl = document.querySelector('label[for="' + escapedId + '"]');
|
||||
return labelEl ? compactText(labelEl.textContent) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function labelFromWrappingLabel(el) {
|
||||
let cur = el.parentElement;
|
||||
for (let i = 0; i < 4 && cur; i++) {
|
||||
if (cur.tagName === 'LABEL') {
|
||||
const clone = cur.cloneNode(true);
|
||||
for (const inner of clone.querySelectorAll('input, textarea, select')) {
|
||||
inner.remove();
|
||||
}
|
||||
return compactText(clone.textContent);
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function fieldLabel(el) {
|
||||
if (!el || el.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
const sources = [
|
||||
el.getAttribute('name'),
|
||||
labelFromLabelledBy(el),
|
||||
el.getAttribute('aria-label'),
|
||||
labelFromForAttr(el),
|
||||
labelFromWrappingLabel(el),
|
||||
el.id,
|
||||
el.getAttribute('placeholder'),
|
||||
];
|
||||
for (const candidate of sources) {
|
||||
const trimmed = candidate ? String(candidate).trim() : '';
|
||||
if (trimmed) {
|
||||
return compactText(trimmed);
|
||||
}
|
||||
}
|
||||
const type = (el.getAttribute('type') || el.tagName || '').toLowerCase();
|
||||
return type ? '[' + type + ']' : null;
|
||||
}
|
||||
|
||||
function fieldValue(el) {
|
||||
if (!el || !('value' in el)) {
|
||||
return { value: '', length: 0, redacted: false };
|
||||
}
|
||||
const raw = el.value == null ? '' : String(el.value);
|
||||
if (isSensitive(el)) {
|
||||
return { value: '[redacted:length=' + raw.length + ']', length: raw.length, redacted: true };
|
||||
}
|
||||
return { value: truncate(raw), length: raw.length, redacted: false };
|
||||
}
|
||||
|
||||
function describeElement(el) {
|
||||
if (!el || el.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const role = el.getAttribute('role') || null;
|
||||
const ariaLabel = el.getAttribute('aria-label') || null;
|
||||
const name = el.getAttribute('name') || null;
|
||||
const type = el.getAttribute('type') || null;
|
||||
const id = el.id || null;
|
||||
const placeholder = el.getAttribute('placeholder') || null;
|
||||
const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName);
|
||||
const valueInfo = isInput ? fieldValue(el) : null;
|
||||
const text = isSensitive(el) ? '' : truncate(el.textContent || '');
|
||||
return {
|
||||
tag,
|
||||
role,
|
||||
ariaLabel,
|
||||
name,
|
||||
type,
|
||||
id,
|
||||
placeholder,
|
||||
label: isInput ? fieldLabel(el) : null,
|
||||
value: valueInfo ? valueInfo.value : null,
|
||||
valueLength: valueInfo ? valueInfo.length : 0,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function findInteractive(el) {
|
||||
let cur = el;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
if (cur.nodeType !== 1) { cur = cur.parentNode; continue; }
|
||||
const tag = cur.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL' ||
|
||||
cur.hasAttribute('role') || cur.hasAttribute('onclick')) {
|
||||
return cur;
|
||||
}
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
send('click', { element: describeElement(findInteractive(e.target)) });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('submit', (e) => {
|
||||
const form = e.target;
|
||||
const fields = [];
|
||||
if (form && form.elements) {
|
||||
for (const el of form.elements) {
|
||||
const label = fieldLabel(el);
|
||||
if (!label || !('value' in el)) {
|
||||
continue;
|
||||
}
|
||||
const v = fieldValue(el);
|
||||
fields.push({
|
||||
label,
|
||||
type: el.type || el.tagName.toLowerCase(),
|
||||
value: v.value,
|
||||
length: v.length,
|
||||
redacted: v.redacted,
|
||||
});
|
||||
}
|
||||
}
|
||||
send('submit', { formId: form.id || null, action: form.action || null, fields });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('focus', (e) => {
|
||||
const target = e.target;
|
||||
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
return;
|
||||
}
|
||||
send('focus', { element: describeElement(target) });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', (e) => {
|
||||
const target = e.target;
|
||||
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
return;
|
||||
}
|
||||
if (target.type === 'file') {
|
||||
return;
|
||||
}
|
||||
send('change', { element: describeElement(target) });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('blur', (e) => {
|
||||
const target = e.target;
|
||||
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
return;
|
||||
}
|
||||
if (!('value' in target) || !target.value) {
|
||||
return;
|
||||
}
|
||||
send('blur', { element: describeElement(target) });
|
||||
}, true);
|
||||
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init = {}) => {
|
||||
const started = performance.now();
|
||||
const url = typeof input === 'string' ? input : input?.url;
|
||||
const method = (init?.method || input?.method || 'GET').toUpperCase();
|
||||
const requestBody = summarizeRequestBody(init?.body);
|
||||
try {
|
||||
const response = await nativeFetch(input, init);
|
||||
const requestUrl = url || response.url;
|
||||
if (!response.ok) {
|
||||
let body = '';
|
||||
try { body = await response.clone().text(); } catch {}
|
||||
send('network.error', {
|
||||
method,
|
||||
url: sanitizeUrl(requestUrl),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
requestBody,
|
||||
response: truncate(body),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
send('network.error', {
|
||||
method,
|
||||
url: sanitizeUrl(url || ''),
|
||||
requestBody,
|
||||
message: truncate(err && err.message ? err.message : String(err)),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const NativeXMLHttpRequest = window.XMLHttpRequest;
|
||||
window.XMLHttpRequest = function SessionJournalXMLHttpRequest() {
|
||||
const xhr = new NativeXMLHttpRequest();
|
||||
let method = 'GET';
|
||||
let url = '';
|
||||
let requestBody = null;
|
||||
let started = 0;
|
||||
|
||||
const originalOpen = xhr.open;
|
||||
xhr.open = function (nextMethod, nextUrl, ...rest) {
|
||||
method = String(nextMethod || 'GET').toUpperCase();
|
||||
url = String(nextUrl || '');
|
||||
return originalOpen.call(xhr, nextMethod, nextUrl, ...rest);
|
||||
};
|
||||
|
||||
const originalSend = xhr.send;
|
||||
xhr.send = function (body) {
|
||||
started = performance.now();
|
||||
requestBody = summarizeRequestBody(body);
|
||||
return originalSend.call(xhr, body);
|
||||
};
|
||||
|
||||
xhr.addEventListener('loadend', () => {
|
||||
if (xhr.status >= 400 || xhr.status === 0) {
|
||||
let response = '';
|
||||
try { response = xhr.responseText || ''; } catch {}
|
||||
send('network.error', {
|
||||
transport: 'xhr',
|
||||
method,
|
||||
url: sanitizeUrl(url),
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
requestBody,
|
||||
response: truncate(response),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return xhr;
|
||||
};
|
||||
window.XMLHttpRequest.prototype = NativeXMLHttpRequest.prototype;
|
||||
|
||||
send('load', { url: location.href, title: document.title });
|
||||
|
||||
const origPush = history.pushState;
|
||||
history.pushState = function (...args) {
|
||||
const result = origPush.apply(this, args);
|
||||
send('navigate', { url: location.href, via: 'pushState' });
|
||||
return result;
|
||||
};
|
||||
const origReplace = history.replaceState;
|
||||
history.replaceState = function (...args) {
|
||||
const result = origReplace.apply(this, args);
|
||||
send('navigate', { url: location.href, via: 'replaceState' });
|
||||
return result;
|
||||
};
|
||||
window.addEventListener('popstate', () => {
|
||||
send('navigate', { url: location.href, via: 'popstate' });
|
||||
});
|
||||
|
||||
for (const level of ['error', 'warn']) {
|
||||
const original = console[level];
|
||||
console[level] = (...args) => {
|
||||
try {
|
||||
const text = args.map((arg) => {
|
||||
if (arg instanceof Error) {
|
||||
return arg.stack || arg.message;
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
send('console.' + level, { text: truncate(text) });
|
||||
} catch {}
|
||||
original.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
send('window.error', {
|
||||
message: truncate(e.message),
|
||||
source: e.filename || null,
|
||||
line: e.lineno || null,
|
||||
col: e.colno || null,
|
||||
stack: e.error && e.error.stack ? truncate(e.error.stack) : null,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason;
|
||||
send('unhandledrejection', {
|
||||
message: truncate(reason && reason.message ? reason.message : String(reason)),
|
||||
stack: reason && reason.stack ? truncate(reason.stack) : null,
|
||||
});
|
||||
});
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
let blankTimer = null;
|
||||
const check = () => {
|
||||
if (root.children.length === 0) {
|
||||
if (!blankTimer) {
|
||||
blankTimer = setTimeout(() => {
|
||||
send('root.empty', { url: location.href });
|
||||
blankTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
} else if (blankTimer) {
|
||||
clearTimeout(blankTimer);
|
||||
blankTimer = null;
|
||||
}
|
||||
};
|
||||
new MutationObserver(check).observe(root, { childList: true });
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const VIRTUAL_ID = 'virtual:session-journal-client';
|
||||
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
||||
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_PATH = path.resolve(PLUGIN_DIR, 'session-journal-client.js');
|
||||
const LOG_PATH = path.resolve(
|
||||
PLUGIN_DIR,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'vault',
|
||||
'temp',
|
||||
'SESSION_JOURNAL.md',
|
||||
);
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts || Date.now());
|
||||
return d.toISOString().replace('T', ' ').slice(0, 23) + 'Z';
|
||||
}
|
||||
|
||||
function formatEntry(event) {
|
||||
const { kind, t, ...rest } = event || {};
|
||||
const head = `## ${formatTime(t)} ${kind || 'unknown'}`;
|
||||
const lines = [head];
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value == null || value === '') {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
lines.push(`- ${key}: ${JSON.stringify(value)}`);
|
||||
} catch {
|
||||
lines.push(`- ${key}: [unserializable]`);
|
||||
}
|
||||
} else {
|
||||
const text = String(value);
|
||||
lines.push(`- ${key}: ${text.includes('\n') ? '\n ' + text.replace(/\n/g, '\n ') : text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
async function appendWithRotation(text) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(LOG_PATH), { recursive: true });
|
||||
await fs.appendFile(LOG_PATH, text);
|
||||
const { size } = await fs.stat(LOG_PATH);
|
||||
if (size > MAX_BYTES) {
|
||||
const content = await fs.readFile(LOG_PATH, 'utf8');
|
||||
const keep = content.slice(-Math.floor(MAX_BYTES / 2));
|
||||
await fs.writeFile(
|
||||
LOG_PATH,
|
||||
'# SESSION_JOURNAL.md (rotated - earlier entries trimmed)\n\n' + keep,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// The journal should never break the app dev server.
|
||||
}
|
||||
}
|
||||
|
||||
export default function sessionJournalPlugin() {
|
||||
return {
|
||||
name: 'session-journal',
|
||||
apply: 'serve',
|
||||
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_ID) {
|
||||
return RESOLVED_ID;
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === RESOLVED_ID) {
|
||||
return fs.readFile(CLIENT_PATH, 'utf-8');
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'</head>',
|
||||
` <script type="module" src="/@id/${VIRTUAL_ID}"></script>\n</head>`,
|
||||
);
|
||||
},
|
||||
|
||||
configureServer(server) {
|
||||
server.ws.on('session-journal:event', (payload) => {
|
||||
const events = Array.isArray(payload?.batch) ? payload.batch : [payload];
|
||||
let text = '';
|
||||
for (const event of events) {
|
||||
text += formatEntry(event);
|
||||
}
|
||||
if (text) {
|
||||
void appendWithRotation(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user