Files
logseq/libs/src/LSPlugin.caller.ts
Charlie a95483655b refactor: plugin libs (#12395)
This pull request refactors the plugin library infrastructure and adds new experimental features for hosted/sidebar renderers. The main changes include:

Purpose: Refactor plugin communication library (Postmate) to support MessageChannel for improved performance, add support for hosted/sidebar renderers in plugins, add new debug APIs, and consolidate helper functions.

Changes:

Added MessageChannel support to Postmate for optimized plugin-host communication with backward compatibility
Introduced hosted renderer and sidebar renderer APIs for plugins to register custom UI components
Added new app APIs: get_current_route, export_debug_log_db, reset_debug_log_db
Refactored helper functions from helpers.ts to common.ts and updated all import paths
Extended block property APIs to include class properties with default values
Added comprehensive documentation for experiments APIs and plugin development
Added E2E test for plugin marketplace installation
Version bump from 0.2.12 to 0.3.1
2026-04-14 14:29:22 +08:00

457 lines
11 KiB
TypeScript

import Debug from 'debug'
import { Postmate, Model, ParentAPI, ChildAPI } from './postmate'
import EventEmitter from 'eventemitter3'
import { PluginLocal } from './LSPlugin.core'
import { deferred, IS_DEV } from './common'
import { LSPluginShadowFrame } from './LSPlugin.shadow'
const debug = Debug('LSPlugin:caller')
type DeferredActor = ReturnType<typeof deferred>
export const FLAG_AWAIT = '#await#response#'
export const LSPMSG = '#lspmsg#'
export const LSPMSG_ERROR_TAG = '#lspmsg#error#'
export const LSPMSG_SETTINGS = '#lspmsg#settings#'
export const LSPMSG_BEFORE_UNLOAD = '#lspmsg#beforeunload#'
export const LSPMSG_SYNC = '#lspmsg#reply#'
export const LSPMSG_READY = '#lspmsg#ready#'
export const LSPMSGFn = (id: string) => `${LSPMSG}${id}`
export const AWAIT_LSPMSGFn = (id: string) => `${FLAG_AWAIT}${id}`
const HEAD_BAR_HEIGHT = 45
const MAX_LAYOUT_PERCENT = 99
/**
* Call between core and user
*/
class LSPluginCaller extends EventEmitter {
private _connected: boolean = false
private _parent?: ParentAPI
private _child?: ChildAPI
private _shadow?: LSPluginShadowFrame
private _status?: 'pending' | 'timeout'
private _userModel: any = {}
private _syncGCTimer: any = null
private _call?: (
type: string,
payload: any,
actor?: DeferredActor,
) => Promise<any>
private _callUserModel?: (type: string, ...payloads: any[]) => Promise<any>
private _debugTag = ''
constructor(private _pluginLocal: PluginLocal | null) {
super()
if (_pluginLocal) {
this._debugTag = _pluginLocal.debugTag
}
}
// run in host
async connectToChild() {
if (this._connected) return
const { shadow } = this._pluginLocal!
if (shadow) {
await this._setupShadowSandbox()
} else {
await this._setupIframeSandbox()
}
}
// run in sandbox
async connectToParent(userModel = {}) {
if (this._connected) return
const caller = this
const isShadowMode = this._pluginLocal != null
let syncTag = 0
const syncActors = new Map<number, DeferredActor>()
const readyDeferred = deferred(1000 * 60)
const model: any = this._extendUserModel({
[LSPMSG_READY]: async (baseInfo: any) => {
// dynamically setup common msg handler
model[LSPMSGFn(baseInfo?.pid)] = ({
type,
payload,
}: {
type: string
payload: any
}) => {
debug(`[host (_call) -> *user] ${this._debugTag}`, type, payload)
// host._call without async
caller.emit(type, payload)
}
await readyDeferred.resolve()
},
[LSPMSG_BEFORE_UNLOAD]: async (e) => {
const actor = deferred(10 * 1000)
caller.emit('beforeunload', Object.assign({ actor }, e))
await actor.promise
},
[LSPMSG_SETTINGS]: async ({ payload }) => {
caller.emit('settings:changed', payload)
},
[LSPMSG]: async ({ ns, type, payload }: any) => {
debug(
`[host (async) -> *user] ${this._debugTag} ns=${ns} type=${type}`,
payload,
)
if (ns && ns.startsWith('hook')) {
caller.emit(`${ns}:${type}`, payload)
return
}
caller.emit(type, payload)
},
[LSPMSG_SYNC]: ({ _sync, result }: any) => {
debug(`[sync host -> *user] #${_sync}`, result)
if (syncActors.has(_sync)) {
const actor = syncActors.get(_sync)
if (actor) {
if (result?.hasOwnProperty(LSPMSG_ERROR_TAG)) {
actor.reject(result[LSPMSG_ERROR_TAG])
} else {
actor.resolve(result)
}
syncActors.delete(_sync)
}
}
},
...userModel,
})
if (isShadowMode) {
await readyDeferred.promise
return JSON.parse(JSON.stringify(this._pluginLocal?.toJSON()))
}
const pm = new Model(model)
const handshake = pm.sendHandshakeReply()
this._status = 'pending'
await handshake
.then((childRefParent: ChildAPI) => {
this._child = childRefParent
this._connected = true
this._call = async (type, payload = {}, actor) => {
if (actor) {
const tag = ++syncTag
syncActors.set(tag, actor)
payload._sync = tag
actor.setTag(`async call #${tag}`)
debug(`async call #${tag}`)
}
childRefParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
return actor?.promise as Promise<any>
}
this._callUserModel = async (type, payload) => {
try {
model[type](payload)
} catch (e) {
debug(`call user model(${type}) not exist. #${this._debugTag}`)
}
}
// actors GC
this._syncGCTimer = setInterval(() => {
if (syncActors.size > 100) {
for (const [k, v] of syncActors) {
if (v.settled) {
syncActors.delete(k)
}
}
}
}, 1000 * 60 * 30)
})
.finally(() => {
this._status = undefined
})
await readyDeferred.promise
return model.baseInfo
}
async call(type: any, payload: any = {}) {
return this._call?.call(this, type, payload)
}
// only for callable apis for sdk user
async callAsync(type: any, payload: any = {}) {
const actor = deferred(1000 * 10)
return this._call?.call(this, type, payload, actor)
}
async callUserModel(type: string, ...args: any[]) {
return this._callUserModel?.apply(this, [type, ...args])
}
async callUserModelAsync(type: string, ...args: any[]) {
type = AWAIT_LSPMSGFn(type)
return this._callUserModel?.apply(this, [type, ...args])
}
/**
* Converts a raw pixel position to a percentage or pixel CSS value,
* clamping the value to [minValue, MAX_LAYOUT_PERCENT%].
*/
private _calcLayoutPosition(
value: number,
viewportSize: number | undefined,
minValue: number = 0,
): string {
const clamped = Math.max(value, minValue)
if (typeof viewportSize === 'number') {
return `${Math.min((clamped * 100) / viewportSize, MAX_LAYOUT_PERCENT)}%`
}
return `${clamped}px`
}
/**
* Restores persisted layout data onto the container element.
*/
private async _applyContainerLayout(cnt: HTMLDivElement): Promise<void> {
const mainLayoutInfo = (await this._pluginLocal?._loadLayoutsData())?.$$0
if (!mainLayoutInfo) return
const { width, height, left, top, vw, vh } = mainLayoutInfo
cnt.dataset.inited_layout = 'true'
Object.assign(cnt.style, {
width: `${width}px`,
height: `${height}px`,
left: this._calcLayoutPosition(left, vw),
top: this._calcLayoutPosition(top, vh, HEAD_BAR_HEIGHT),
})
}
// run in host
async _setupIframeSandbox() {
const pl = this._pluginLocal!
const id = pl.id
const domId = `${id}_lsp_main`
const url = new URL(pl.options.entry!)
url.searchParams.set(
`__v__`,
IS_DEV ? Date.now().toString() : pl.options.version,
)
// clear zombie sandbox
document.querySelector(`#${domId}`)?.remove()
const cnt = document.createElement('div')
cnt.classList.add('lsp-iframe-sandbox-container')
cnt.id = domId
cnt.dataset.pid = id
try {
await this._applyContainerLayout(cnt)
} catch (e) {
console.error('[Restore Layout Error]', e)
}
document.body.appendChild(cnt)
const pt = new Postmate({
id: id + '_iframe',
container: cnt,
url: url.href,
classListArray: ['lsp-iframe-sandbox'],
model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) },
allow: pl.options.allow,
// for optimized postmate message
enableMessageChannel: true,
})
this._status = 'pending'
const HANDSHAKE_TIMEOUT = 8_000
let timer: ReturnType<typeof setTimeout>
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
pt.destroy()
reject(new Error('handshake Timeout'))
}, HANDSHAKE_TIMEOUT)
})
try {
const parentRefChild = await Promise.race([
pt.sendHandshake(),
timeout,
]) as ParentAPI
this._parent = parentRefChild
this._connected = true
this.emit('connected')
parentRefChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
debug(`[user -> *host] `, type, payload)
this._pluginLocal?.emit(type, payload || {})
this._pluginLocal?.caller.emit(type, payload || {})
})
this._call = async (...args: any) => {
// parent all will get message before handshake
parentRefChild.call(LSPMSGFn(pl.id), {
type: args[0],
payload: Object.assign(args[1] || {}, {
$$pid: pl.id,
}),
})
}
this._callUserModel = async (type, ...payloads: any[]) => {
if (type.startsWith(FLAG_AWAIT)) {
return await parentRefChild.get(
type.replace(FLAG_AWAIT, ''),
...payloads,
)
} else {
parentRefChild.call(type, payloads?.[0])
}
}
} catch (e) {
debug('[iframe sandbox] error', e)
throw e
} finally {
clearTimeout(timer!)
this._status = undefined
}
}
async _setupShadowSandbox() {
const pl = this._pluginLocal!
const shadow = (this._shadow = new LSPluginShadowFrame(pl))
try {
this._status = 'pending'
await shadow.load()
this._connected = true
this.emit('connected')
this._call = async (type, payload = {}, actor) => {
actor && (payload.actor = actor)
// @ts-ignore Call in same thread
this._pluginLocal?.emit(
type,
Object.assign(payload, {
$$pid: pl.id,
}),
)
return actor?.promise
}
this._callUserModel = async (...args: any) => {
let type = args[0] as string
if (type?.startsWith(FLAG_AWAIT)) {
type = type.replace(FLAG_AWAIT, '')
}
const payload = args[1] || {}
const fn = this._userModel[type]
if (typeof fn === 'function') {
await fn.call(null, payload)
}
}
} catch (e) {
debug('[shadow sandbox] error', e)
throw e
} finally {
this._status = undefined
}
}
_extendUserModel(model: any) {
return Object.assign(this._userModel, model)
}
_getSandboxIframeContainer() {
return this._parent?.frame.parentNode as HTMLDivElement
}
_getSandboxShadowContainer() {
return this._shadow?.frame as HTMLDivElement
}
_getSandboxIframeRoot() {
return this._parent?.frame
}
_getSandboxShadowRoot() {
return this._shadow?.frame
}
set debugTag(value: string) {
this._debugTag = value
}
async destroy() {
let root: HTMLElement = null
if (this._parent) {
root = this._getSandboxIframeContainer()
this._parent.destroy()
}
if (this._shadow) {
root = this._getSandboxShadowContainer()
this._shadow.destroy()
}
root?.parentNode?.removeChild(root)
// clear GC timer
if (this._syncGCTimer) {
clearInterval(this._syncGCTimer)
this._syncGCTimer = null
}
this._connected = false
this._parent = undefined
this._child = undefined
this._shadow = undefined
this._call = undefined
this._callUserModel = undefined
this._status = undefined
}
}
export {
LSPluginCaller
}