Enables apps to access the full DenchClaw platform — CRUD on workspace objects, AI chat with streaming, inter-app messaging, KV store, HTTP proxy, webhooks, cron scheduling, and widget display mode.
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/**
|
|
* DenchClaw App Bridge SDK.
|
|
*
|
|
* This module generates the client-side SDK script that gets injected into
|
|
* app iframes, providing `window.dench` for app-to-DenchClaw communication.
|
|
*
|
|
* Protocol:
|
|
* App -> Parent: { type: "dench:request", id, method, params }
|
|
* Parent -> App: { type: "dench:response", id, result, error }
|
|
* Parent -> App: { type: "dench:stream", streamId, event, data }
|
|
* Parent -> App: { type: "dench:stream-end", streamId, result }
|
|
* Parent -> App: { type: "dench:event", channel, data }
|
|
* Parent -> App: { type: "dench:tool-invoke", toolName, args, invokeId }
|
|
*/
|
|
|
|
export function generateBridgeScript(): string {
|
|
return `
|
|
(function() {
|
|
if (window.dench) return;
|
|
|
|
var _pendingRequests = {};
|
|
var _requestId = 0;
|
|
var _streamCallbacks = {};
|
|
var _streamId = 0;
|
|
var _eventListeners = {};
|
|
var _toolHandlers = {};
|
|
var _appMessageHandler = null;
|
|
var _webhookHandlers = {};
|
|
|
|
function sendRequest(method, params) {
|
|
return new Promise(function(resolve, reject) {
|
|
var id = ++_requestId;
|
|
_pendingRequests[id] = { resolve: resolve, reject: reject };
|
|
window.parent.postMessage({
|
|
type: "dench:request",
|
|
id: id,
|
|
method: method,
|
|
params: params
|
|
}, "*");
|
|
|
|
setTimeout(function() {
|
|
if (_pendingRequests[id]) {
|
|
_pendingRequests[id].reject(new Error("Request timeout: " + method));
|
|
delete _pendingRequests[id];
|
|
}
|
|
}, 30000);
|
|
});
|
|
}
|
|
|
|
function sendStreamRequest(method, params, onEvent) {
|
|
return new Promise(function(resolve, reject) {
|
|
var id = ++_requestId;
|
|
var sid = ++_streamId;
|
|
_streamCallbacks[sid] = onEvent;
|
|
_pendingRequests[id] = {
|
|
resolve: function(result) {
|
|
delete _streamCallbacks[sid];
|
|
resolve(result);
|
|
},
|
|
reject: function(err) {
|
|
delete _streamCallbacks[sid];
|
|
reject(err);
|
|
}
|
|
};
|
|
window.parent.postMessage({
|
|
type: "dench:request",
|
|
id: id,
|
|
method: method,
|
|
params: Object.assign({}, params, { _streamId: sid })
|
|
}, "*");
|
|
|
|
setTimeout(function() {
|
|
if (_pendingRequests[id]) {
|
|
delete _streamCallbacks[sid];
|
|
_pendingRequests[id].reject(new Error("Request timeout: " + method));
|
|
delete _pendingRequests[id];
|
|
}
|
|
}, 300000);
|
|
});
|
|
}
|
|
|
|
window.addEventListener("message", function(event) {
|
|
if (!event.data) return;
|
|
var d = event.data;
|
|
|
|
if (d.type === "dench:response") {
|
|
var pending = _pendingRequests[d.id];
|
|
if (!pending) return;
|
|
delete _pendingRequests[d.id];
|
|
if (d.error) {
|
|
pending.reject(new Error(d.error));
|
|
} else {
|
|
pending.resolve(d.result);
|
|
}
|
|
}
|
|
|
|
else if (d.type === "dench:stream") {
|
|
var cb = _streamCallbacks[d.streamId];
|
|
if (cb) cb({ type: d.event, data: d.data, name: d.name, args: d.args, result: d.result });
|
|
}
|
|
|
|
else if (d.type === "dench:stream-end") {
|
|
// Stream end is handled via the normal response path
|
|
}
|
|
|
|
else if (d.type === "dench:event") {
|
|
var channel = d.channel;
|
|
if (channel === "apps.message" && _appMessageHandler) {
|
|
_appMessageHandler(d.data);
|
|
}
|
|
if (channel && channel.indexOf("webhooks.") === 0) {
|
|
var hookName = channel.substring(9);
|
|
var whCb = _webhookHandlers[hookName];
|
|
if (whCb) whCb(d.data);
|
|
}
|
|
var listeners = _eventListeners[channel];
|
|
if (listeners) {
|
|
for (var i = 0; i < listeners.length; i++) {
|
|
try { listeners[i](d.data); } catch(e) { console.error("Event handler error:", e); }
|
|
}
|
|
}
|
|
}
|
|
|
|
else if (d.type === "dench:tool-invoke") {
|
|
var handler = _toolHandlers[d.toolName];
|
|
if (handler) {
|
|
Promise.resolve().then(function() {
|
|
return handler(d.args);
|
|
}).then(function(result) {
|
|
window.parent.postMessage({
|
|
type: "dench:tool-response",
|
|
invokeId: d.invokeId,
|
|
result: result
|
|
}, "*");
|
|
}).catch(function(err) {
|
|
window.parent.postMessage({
|
|
type: "dench:tool-response",
|
|
invokeId: d.invokeId,
|
|
error: err.message || "Tool handler failed"
|
|
}, "*");
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
window.dench = {
|
|
db: {
|
|
query: function(sql) { return sendRequest("db.query", { sql: sql }); },
|
|
execute: function(sql) { return sendRequest("db.execute", { sql: sql }); }
|
|
},
|
|
objects: {
|
|
list: function(name, opts) { return sendRequest("objects.list", Object.assign({ name: name }, opts || {})); },
|
|
get: function(name, entryId) { return sendRequest("objects.get", { name: name, entryId: entryId }); },
|
|
create: function(name, fields) { return sendRequest("objects.create", { name: name, fields: fields }); },
|
|
update: function(name, entryId, fields) { return sendRequest("objects.update", { name: name, entryId: entryId, fields: fields }); },
|
|
delete: function(name, entryId) { return sendRequest("objects.delete", { name: name, entryId: entryId }); },
|
|
bulkDelete: function(name, entryIds) { return sendRequest("objects.bulkDelete", { name: name, entryIds: entryIds }); },
|
|
getSchema: function(name) { return sendRequest("objects.getSchema", { name: name }); },
|
|
getOptions: function(name, query) { return sendRequest("objects.getOptions", { name: name, query: query }); }
|
|
},
|
|
files: {
|
|
read: function(path) { return sendRequest("files.read", { path: path }); },
|
|
list: function(dir) { return sendRequest("files.list", { dir: dir }); },
|
|
write: function(path, content) { return sendRequest("files.write", { path: path, content: content }); },
|
|
delete: function(path) { return sendRequest("files.delete", { path: path }); },
|
|
mkdir: function(path) { return sendRequest("files.mkdir", { path: path }); }
|
|
},
|
|
app: {
|
|
getManifest: function() { return sendRequest("app.getManifest"); },
|
|
getTheme: function() { return sendRequest("app.getTheme"); }
|
|
},
|
|
chat: {
|
|
createSession: function(title) { return sendRequest("chat.createSession", { title: title }); },
|
|
send: function(sessionId, message, opts) {
|
|
if (opts && opts.onEvent) {
|
|
return sendStreamRequest("chat.send", { sessionId: sessionId, message: message }, opts.onEvent);
|
|
}
|
|
return sendRequest("chat.send", { sessionId: sessionId, message: message });
|
|
},
|
|
getHistory: function(sessionId) { return sendRequest("chat.getHistory", { sessionId: sessionId }); },
|
|
getSessions: function(opts) { return sendRequest("chat.getSessions", opts || {}); },
|
|
abort: function(sessionId) { return sendRequest("chat.abort", { sessionId: sessionId }); },
|
|
isActive: function(sessionId) { return sendRequest("chat.isActive", { sessionId: sessionId }); }
|
|
},
|
|
agent: {
|
|
send: function(message) { return sendRequest("agent.send", { message: message }); }
|
|
},
|
|
tool: {
|
|
register: function(name, handler) {
|
|
_toolHandlers[name] = handler;
|
|
return sendRequest("tool.register", { name: name });
|
|
}
|
|
},
|
|
memory: {
|
|
get: function() { return sendRequest("memory.get"); }
|
|
},
|
|
ui: {
|
|
toast: function(message, opts) { return sendRequest("ui.toast", Object.assign({ message: message }, opts || {})); },
|
|
navigate: function(path) { return sendRequest("ui.navigate", { path: path }); },
|
|
openEntry: function(objectName, entryId) { return sendRequest("ui.openEntry", { objectName: objectName, entryId: entryId }); },
|
|
setTitle: function(title) { return sendRequest("ui.setTitle", { title: title }); },
|
|
confirm: function(message) { return sendRequest("ui.confirm", { message: message }); },
|
|
prompt: function(message, defaultValue) { return sendRequest("ui.prompt", { message: message, defaultValue: defaultValue }); }
|
|
},
|
|
store: {
|
|
get: function(key) { return sendRequest("store.get", { key: key }); },
|
|
set: function(key, value) { return sendRequest("store.set", { key: key, value: value }); },
|
|
delete: function(key) { return sendRequest("store.delete", { key: key }); },
|
|
list: function() { return sendRequest("store.list"); },
|
|
clear: function() { return sendRequest("store.clear"); }
|
|
},
|
|
http: {
|
|
fetch: function(url, opts) { return sendRequest("http.fetch", Object.assign({ url: url }, opts || {})); }
|
|
},
|
|
events: {
|
|
on: function(channel, callback) {
|
|
if (!_eventListeners[channel]) _eventListeners[channel] = [];
|
|
_eventListeners[channel].push(callback);
|
|
sendRequest("events.subscribe", { channel: channel }).catch(function() {});
|
|
},
|
|
off: function(channel, callback) {
|
|
if (!callback) {
|
|
delete _eventListeners[channel];
|
|
} else if (_eventListeners[channel]) {
|
|
_eventListeners[channel] = _eventListeners[channel].filter(function(cb) { return cb !== callback; });
|
|
if (_eventListeners[channel].length === 0) delete _eventListeners[channel];
|
|
}
|
|
sendRequest("events.unsubscribe", { channel: channel }).catch(function() {});
|
|
}
|
|
},
|
|
context: {
|
|
getWorkspace: function() { return sendRequest("context.getWorkspace"); },
|
|
getAppInfo: function() { return sendRequest("context.getAppInfo"); }
|
|
},
|
|
apps: {
|
|
send: function(targetApp, message) { return sendRequest("apps.send", { targetApp: targetApp, message: message }); },
|
|
on: function(eventType, callback) {
|
|
if (eventType === "message") _appMessageHandler = callback;
|
|
},
|
|
list: function() { return sendRequest("apps.list"); }
|
|
},
|
|
cron: {
|
|
schedule: function(opts) { return sendRequest("cron.schedule", opts); },
|
|
list: function() { return sendRequest("cron.list"); },
|
|
run: function(jobId) { return sendRequest("cron.run", { jobId: jobId }); },
|
|
cancel: function(jobId) { return sendRequest("cron.cancel", { jobId: jobId }); }
|
|
},
|
|
webhooks: {
|
|
register: function(hookName) { return sendRequest("webhooks.register", { hookName: hookName }); },
|
|
on: function(hookName, callback) {
|
|
_webhookHandlers[hookName] = callback;
|
|
sendRequest("webhooks.subscribe", { hookName: hookName }).catch(function() {});
|
|
},
|
|
poll: function(hookName, opts) { return sendRequest("webhooks.poll", Object.assign({ hookName: hookName }, opts || {})); }
|
|
},
|
|
clipboard: {
|
|
read: function() { return sendRequest("clipboard.read"); },
|
|
write: function(text) { return sendRequest("clipboard.write", { text: text }); }
|
|
}
|
|
};
|
|
})();
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Wraps raw HTML content with the bridge SDK script tag.
|
|
* Used when serving HTML files to inject the SDK automatically.
|
|
*/
|
|
export function injectBridgeIntoHtml(html: string): string {
|
|
const script = `<script>${generateBridgeScript()}</script>`;
|
|
|
|
// Try to inject before </head>
|
|
if (html.includes("</head>")) {
|
|
return html.replace("</head>", `${script}\n</head>`);
|
|
}
|
|
|
|
// Try to inject after <head>
|
|
if (html.includes("<head>")) {
|
|
return html.replace("<head>", `<head>\n${script}`);
|
|
}
|
|
|
|
// Fallback: prepend to the HTML
|
|
return `${script}\n${html}`;
|
|
}
|