188 lines
5.9 KiB
JavaScript
188 lines
5.9 KiB
JavaScript
/**
|
|
* --------------------------------------------------------------------
|
|
* docmd : the zero-config documentation engine.
|
|
*
|
|
* @package @docmd/core (and ecosystem)
|
|
* @website https://docmd.io
|
|
* @repository https://github.com/docmd-io/docmd
|
|
* @license MIT
|
|
* @copyright Copyright (c) 2025 docmd.io
|
|
*
|
|
* [docmd-source] - Please do not remove this header.
|
|
* --------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Browser API for docmd plugin communication.
|
|
*
|
|
* Provides docmd.call(), docmd.send(), docmd.on(), docmd.afterReload(),
|
|
* and docmd.scheduleReload() over a WebSocket connection.
|
|
* Injected automatically by the dev server.
|
|
*/
|
|
|
|
/* global WebSocket, sessionStorage, queueMicrotask */
|
|
|
|
(function() {
|
|
if (typeof window === 'undefined') return;
|
|
if (window.docmd && window.docmd.call) return; // already initialized
|
|
|
|
const docmd = window.docmd || {};
|
|
window.docmd = docmd;
|
|
|
|
// Restore scroll position after reload
|
|
const savedScroll = sessionStorage.getItem('docmd:scrollY');
|
|
if (savedScroll) {
|
|
sessionStorage.removeItem('docmd:scrollY');
|
|
requestAnimationFrame(() => {
|
|
window.scrollTo(0, parseInt(savedScroll, 10));
|
|
});
|
|
}
|
|
|
|
let socket = null;
|
|
let messageId = 0;
|
|
const pendingCalls = new Map(); // id → { resolve, reject }
|
|
const eventListeners = new Map(); // name → Set<callback>
|
|
|
|
// retryCount lives outside connect() so it persists across reconnect calls.
|
|
// This ensures maxRetries is actually enforced — previously it reset to 0
|
|
// on every invocation, causing infinite reconnection loops on static servers.
|
|
let retryCount = 0;
|
|
const maxRetries = 5;
|
|
|
|
function connect() {
|
|
if (socket && (socket.readyState === 0 || socket.readyState === 1)) return;
|
|
if (retryCount >= maxRetries) return; // give up — not a dev server
|
|
|
|
socket = new WebSocket('ws://' + window.location.host);
|
|
|
|
socket.onopen = () => {
|
|
console.log('⚡ docmd connected');
|
|
retryCount = 0; // reset on successful connection
|
|
};
|
|
|
|
socket.onmessage = (e) => {
|
|
if (e.data === 'reload') {
|
|
sessionStorage.setItem('docmd:scrollY', String(window.scrollY));
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
let msg;
|
|
try { msg = JSON.parse(e.data); } catch { return; }
|
|
|
|
if (msg.type === 'response' && msg.id) {
|
|
const pending = pendingCalls.get(msg.id);
|
|
if (pending) {
|
|
pendingCalls.delete(msg.id);
|
|
if (msg.error) {
|
|
pending.reject(new Error(msg.error));
|
|
} else {
|
|
pending.resolve({ result: msg.result, reload: msg.reload });
|
|
}
|
|
}
|
|
} else if (msg.type === 'event' && msg.name) {
|
|
const listeners = eventListeners.get(msg.name);
|
|
if (listeners) {
|
|
listeners.forEach(cb => { try { cb(msg.data); } catch (e) { console.error(e); } });
|
|
}
|
|
}
|
|
};
|
|
|
|
// Suppress the browser's default connection-refused error in the console.
|
|
socket.onerror = () => { /* handled via onclose */ };
|
|
|
|
socket.onclose = () => {
|
|
retryCount++;
|
|
if (retryCount < maxRetries) {
|
|
setTimeout(connect, Math.min(1000 * (1.5 ** retryCount), 5000));
|
|
}
|
|
// No log on final failure — this is expected when serving a static build.
|
|
};
|
|
}
|
|
|
|
function waitForConnection() {
|
|
if (socket && socket.readyState === 1) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error('docmd: WebSocket connection timeout')), 5000);
|
|
function check() {
|
|
if (socket && socket.readyState === 1) {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
} else {
|
|
setTimeout(check, 50);
|
|
}
|
|
}
|
|
check();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Call a server-side action and return the result.
|
|
* If the action modifies files, the page reloads automatically after
|
|
* the promise resolves and the current microtask completes.
|
|
*/
|
|
docmd.call = async function(action, payload) {
|
|
await waitForConnection();
|
|
return new Promise((resolve, reject) => {
|
|
const id = String(++messageId);
|
|
pendingCalls.set(id, {
|
|
resolve: ({ result, reload }) => {
|
|
resolve(result);
|
|
if (reload) {
|
|
sessionStorage.setItem('docmd:scrollY', String(window.scrollY));
|
|
queueMicrotask(() => window.location.reload());
|
|
}
|
|
},
|
|
reject
|
|
});
|
|
socket.send(JSON.stringify({ id, type: 'call', action, payload }));
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Send a fire-and-forget event to the server.
|
|
*/
|
|
docmd.send = async function(name, data) {
|
|
await waitForConnection();
|
|
socket.send(JSON.stringify({ type: 'event', name, data }));
|
|
};
|
|
|
|
/**
|
|
* Subscribe to server-pushed events. Returns an unsubscribe function.
|
|
*/
|
|
docmd.on = function(name, callback) {
|
|
if (!eventListeners.has(name)) eventListeners.set(name, new Set());
|
|
eventListeners.get(name).add(callback);
|
|
return () => eventListeners.get(name).delete(callback);
|
|
};
|
|
|
|
/**
|
|
* Declare a named reload handler. Runs on every page load.
|
|
* If sessionStorage has stashed context for this name, calls the
|
|
* callback immediately with that context and clears the stash.
|
|
*/
|
|
docmd.afterReload = function(name, callback) {
|
|
const key = 'docmd:reload:' + name;
|
|
const raw = sessionStorage.getItem(key);
|
|
if (raw) {
|
|
sessionStorage.removeItem(key);
|
|
try {
|
|
const context = JSON.parse(raw);
|
|
callback(context);
|
|
} catch (e) {
|
|
console.error('docmd.afterReload[' + name + '] error:', e);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Stash context for a named reload handler. The matching afterReload
|
|
* handler will fire with this context after the next page reload.
|
|
*/
|
|
docmd.scheduleReload = function(name, context) {
|
|
const key = 'docmd:reload:' + name;
|
|
sessionStorage.setItem(key, JSON.stringify(context || {}));
|
|
};
|
|
|
|
// Connect
|
|
setTimeout(connect, 100);
|
|
})(); |