Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

456
libs/src/LSPlugin.caller.ts Normal file
View File

@@ -0,0 +1,456 @@
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
}

1943
libs/src/LSPlugin.core.ts Normal file

File diff suppressed because it is too large Load Diff

112
libs/src/LSPlugin.shadow.ts Normal file
View File

@@ -0,0 +1,112 @@
import EventEmitter from 'eventemitter3'
import { PluginLocal } from './LSPlugin.core'
import { LSPluginUser } from './LSPlugin.user'
// @ts-ignore
const { importHTML, createSandboxContainer } = window.QSandbox || {}
function userFetch(url, opts) {
if (!url.startsWith('http')) {
url = url.replace('file://', '')
return new Promise(async (resolve, reject) => {
try {
const content = await window.apis.doAction(['readFile', url])
resolve({
text() {
return content
},
})
} catch (e) {
console.error(e)
reject(e)
}
})
}
return fetch(url, opts)
}
class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
private _frame?: HTMLElement
private _root?: ShadowRoot
private _loaded = false
private _unmountFns: Array<() => Promise<void>> = []
constructor(private _pluginLocal: PluginLocal) {
super()
_pluginLocal._dispose(() => {
this._unmount()
})
}
async load() {
const { name, entry } = this._pluginLocal.options
if (this.loaded || !entry) return
const { template, execScripts } = await importHTML(entry, {
fetch: userFetch,
})
this._mount(template, document.body)
const sandbox = createSandboxContainer(name, {
elementGetter: () => this._root?.firstChild,
})
const global = sandbox.instance.proxy as any
global.__shadow_mode__ = true
global.LSPluginLocal = this._pluginLocal
global.LSPluginShadow = this
global.LSPluginUser = global.logseq = new LSPluginUser(
this._pluginLocal.toJSON() as any,
this._pluginLocal.caller!
)
// TODO: {mount, unmount}
const execResult: any = await execScripts(global, true)
this._unmountFns.push(execResult.unmount)
this._loaded = true
}
_mount(content: string, container: HTMLElement) {
const frame = (this._frame = document.createElement('div'))
frame.classList.add('lsp-shadow-sandbox')
frame.id = this._pluginLocal.id
this._root = frame.attachShadow({ mode: 'open' })
this._root.innerHTML = `<div>${content}</div>`
container.appendChild(frame)
this.emit('mounted')
}
_unmount() {
for (const fn of this._unmountFns) {
fn && fn.call(null)
}
}
destroy() {
this.frame?.parentNode?.removeChild(this.frame)
}
get loaded(): boolean {
return this._loaded
}
get document() {
return this._root?.firstChild as HTMLElement
}
get frame(): HTMLElement {
return this._frame!
}
}
export { LSPluginShadowFrame }

1254
libs/src/LSPlugin.ts Normal file

File diff suppressed because it is too large Load Diff

1178
libs/src/LSPlugin.user.ts Normal file

File diff suppressed because it is too large Load Diff

12
libs/src/callable.apis.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PluginLocal } from './LSPlugin.core'
/**
* These APIs run in the host and are callable by users,
* similar to logseq.apis.cljs but for JS/TS.
*/
export function setSDKMetadata(this: PluginLocal, data: any) {
if (this?.sdk && data) {
this.sdk = Object.assign({}, this.sdk, data)
}
}

630
libs/src/common.ts Normal file
View File

@@ -0,0 +1,630 @@
import { SettingSchemaDesc, StyleString, UIOptions } from './LSPlugin'
import { PluginLocal } from './LSPlugin.core'
import * as nodePath from 'path'
import DOMPurify from 'dompurify'
import merge from 'deepmerge'
import { snakeCase } from 'change-case'
import * as callables from './callable.apis'
import EventEmitter from 'eventemitter3'
declare global {
interface Window {
api: any
apis: any
}
}
export const path =
navigator.platform.toLowerCase() === 'win32' ? nodePath.win32 : nodePath.posix
export const IS_DEV = process.env.NODE_ENV === 'development'
export const PROTOCOL_FILE = 'file://'
export const PROTOCOL_LSP = 'lsp://'
export const URL_LSP = PROTOCOL_LSP + 'logseq.io/plugins/'
export const URL_LSP_EXTERNAL = PROTOCOL_LSP + 'logseq.io/external/'
export const URL_LSP_HOST = PROTOCOL_LSP + 'logseq.com/plugins/'
export const URL_LSP_HOST_EXTERNAL = PROTOCOL_LSP + 'logseq.com/external/'
let _appPathRoot: string
// TODO: snakeCase of lodash is incompatible with `snake-case`
export const safeSnakeCase = snakeCase
export async function getAppPathRoot(): Promise<string> {
if (_appPathRoot) {
return _appPathRoot
}
return (_appPathRoot = await invokeHostExportedApi(
'_callApplication',
'getAppPath'
))
}
export async function getSDKPathRoot(): Promise<string> {
if (IS_DEV) {
// TODO: cache in preference file
return localStorage.getItem('LSP_DEV_SDK_ROOT') || 'http://localhost:8080'
}
const appPathRoot = await getAppPathRoot()
return safetyPathJoin(appPathRoot, 'js')
}
export function isObject(item: any) {
return item === Object(item) && !Array.isArray(item)
}
export function deepMerge<T>(a: Partial<T>, b: Partial<T>): T {
const overwriteArrayMerge = (destinationArray, sourceArray) => sourceArray
return merge(a, b, { arrayMerge: overwriteArrayMerge })
}
export type PluginLogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
export interface PluginLogEntry {
ts: number
level: PluginLogLevel
tag: string
message: string
}
export interface PluginLoggerOptions {
console?: boolean
maxSize?: number
level?: PluginLogLevel
}
const LOG_LEVEL_WEIGHT: Record<PluginLogLevel, number> = {
DEBUG: 10,
INFO: 20,
WARN: 30,
ERROR: 40,
}
const DEFAULT_LOG_MAX_SIZE = 500
function safeStringifyArg(it: any): string {
if (it == null) return String(it)
if (it instanceof Error) return `${it.message}${it.stack ? '\n' + it.stack : ''}`
if (typeof it === 'string') return it
if (typeof it === 'object') {
try {
const seen = new WeakSet()
return JSON.stringify(it, (_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[Circular]'
seen.add(v)
}
return v
})
} catch (_e) {
try { return String(it) } catch (_) { return '[Unserializable]' }
}
}
try { return String(it) } catch (_e) { return '[Unserializable]' }
}
export class PluginLogger extends EventEmitter<'change' | 'append' | 'clear'> {
private _logs: PluginLogEntry[] = []
private _maxSize: number
private _level: PluginLogLevel
constructor(
private _tag?: string,
private _opts?: PluginLoggerOptions
) {
super()
this._maxSize = Math.max(50, _opts?.maxSize ?? DEFAULT_LOG_MAX_SIZE)
this._level = _opts?.level ?? 'DEBUG'
}
/**
* Write a log entry.
* Backwards compatible: the legacy boolean tail flag in `payload` to force
* console output is still honored.
*/
write(level: PluginLogLevel | string, payload: any[], inConsole?: boolean) {
// back-compat: trailing boolean === true means "force console"
if (Array.isArray(payload) && payload.length &&
payload[payload.length - 1] === true) {
inConsole = true
payload = payload.slice(0, -1)
}
const lvl = (typeof level === 'string'
? (level.toUpperCase() as PluginLogLevel)
: level) as PluginLogLevel
const normalizedLevel: PluginLogLevel =
lvl in LOG_LEVEL_WEIGHT ? lvl : 'INFO'
// level filtering
if (LOG_LEVEL_WEIGHT[normalizedLevel] < LOG_LEVEL_WEIGHT[this._level]) {
return
}
const message = (payload || []).map(safeStringifyArg).join(' ')
const entry: PluginLogEntry = {
ts: Date.now(),
level: normalizedLevel,
tag: this._tag || '',
message,
}
this._logs.push(entry)
// ring buffer
if (this._logs.length > this._maxSize) {
this._logs.splice(0, this._logs.length - this._maxSize)
}
if (inConsole || this._opts?.console) {
const fn = normalizedLevel === 'ERROR'
? 'error'
: normalizedLevel === 'WARN'
? 'warn'
: normalizedLevel === 'DEBUG' ? 'debug' : 'info'
try {
// eslint-disable-next-line no-console
console[fn](`[${entry.tag}][${new Date(entry.ts).toLocaleTimeString()}] ${normalizedLevel}: ${message}`)
} catch (_e) { /* noop */ }
}
this.emit('append', entry)
this.emit('change')
}
clear() {
this._logs = []
this.emit('clear')
this.emit('change')
}
debug(...args: any[]) { this.write('DEBUG', args) }
info(...args: any[]) { this.write('INFO', args) }
warn(...args: any[]) { this.write('WARN', args) }
error(...args: any[]) { this.write('ERROR', args) }
setTag(s: string) { this._tag = s }
getTag() { return this._tag }
setLevel(l: PluginLogLevel) {
if (l in LOG_LEVEL_WEIGHT) this._level = l
}
getLevel(): PluginLogLevel { return this._level }
setMaxSize(n: number) {
this._maxSize = Math.max(50, n | 0)
if (this._logs.length > this._maxSize) {
this._logs.splice(0, this._logs.length - this._maxSize)
this.emit('change')
}
}
/** Structured entries (preferred). */
getEntries(): PluginLogEntry[] {
return this._logs.slice()
}
/** Legacy tuple format kept for backwards compatibility. */
toJSON(): Array<[PluginLogLevel, string]> {
return this._logs.map((e) => [e.level,
`[${e.tag}][${new Date(e.ts).toLocaleTimeString()}] ${e.message}`])
}
}
export function isValidUUID(s: string) {
return (
typeof s === 'string' &&
s.length === 36 &&
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test(
s
)
)
}
export function genID() {
// Math.random should be unique because of its seeding algorithm.
// Convert it to base 36 (numbers + letters), and grab the first 9 characters
// after the decimal.
return '_' + Math.random().toString(36).substr(2, 9)
}
export function ucFirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function withFileProtocol(path: string) {
if (!path) return ''
const reg = /^(http|file|lsp)/
if (!reg.test(path)) {
path = PROTOCOL_FILE + path
}
return path
}
export function safetyPathJoin(basePath: string, ...parts: Array<string>) {
try {
const url = new URL(basePath)
if (!url.origin) throw new Error(null)
const fullPath = path.join(basePath.substr(url.origin.length), ...parts)
return url.origin + fullPath
} catch (e) {
return path.join(basePath, ...parts)
}
}
export function safetyPathNormalize(basePath: string) {
if (!basePath?.match(/^(http?|lsp|assets):/)) {
basePath = path.normalize(basePath)
}
return basePath
}
export function deferred<T = any>(timeout?: number, tag?: string) {
let resolve: any, reject: any
let settled = false
const timeFn = (r: Function) => {
return (v: T) => {
timeout && clearTimeout(timeout)
r(v)
settled = true
}
}
const promise = new Promise<T>((resolve1, reject1) => {
resolve = timeFn(resolve1)
reject = timeFn(reject1)
if (timeout) {
// @ts-ignore
timeout = setTimeout(
() => reject(new Error(`[deferred timeout] ${tag}`)),
timeout
)
}
})
return {
created: Date.now(),
setTag: (t: string) => (tag = t),
resolve,
reject,
promise,
get settled() {
return settled
},
}
}
export function invokeHostExportedApi(method: string, ...args: Array<any>) {
method = method?.startsWith('_call') ? method : method?.replace(/^[_$]+/, '')
let method1 = safeSnakeCase(method)
// @ts-ignore
const nsSDK = window.logseq?.sdk
const supportedNS = nsSDK && Object.keys(nsSDK)
let nsTarget = {}
const ns0 = method1?.split('_')?.[0]
if (ns0 && supportedNS.includes(ns0)) {
method1 = method1.replace(new RegExp(`^${ns0}_`), '')
nsTarget = nsSDK?.[ns0]
}
const logseqHostExportedApi = Object.assign(
// @ts-ignore
{}, window.logseq?.api,
nsTarget, callables
)
const fn =
logseqHostExportedApi[method1] ||
window.apis[method1] ||
logseqHostExportedApi[method] ||
window.apis[method]
if (!fn) {
throw new Error(`Not existed method #${method}`)
}
return typeof fn !== 'function' ? fn : fn.apply(this, args)
}
export function setupInjectedStyle(
style: StyleString,
attrs: Record<string, any>
) {
const key = attrs['data-injected-style']
let el = key && document.querySelector(`[data-injected-style=${key}]`)
if (el) {
el.textContent = style
return
}
el = document.createElement('style')
el.textContent = style
attrs &&
Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
document.head.append(el)
return () => {
document.head.removeChild(el)
}
}
const injectedUIEffects = new Map<string, () => void>()
// @ts-ignore
window.__injectedUIEffects = injectedUIEffects
export function setupInjectedUI(
this: PluginLocal,
ui: UIOptions,
attrs: Record<string, string>,
initialCallback?: (e: { el: HTMLElement; float: boolean }) => void
) {
let slot: string = ''
let selector: string
let float: boolean
const pl = this
if ('slot' in ui) {
slot = ui.slot
selector = `#${slot}`
} else if ('path' in ui) {
selector = ui.path
} else {
float = true
}
const id = `${pl.id}--${ui.key || genID()}`
const key = id
const target = float
? document.body
: selector && document.querySelector(selector)
if (!target) {
console.error(
`${this.debugTag} can not resolve selector target ${selector}`
)
return false
}
if (ui.template) {
// safe template
ui.template = DOMPurify.sanitize(ui.template, {
ADD_TAGS: ['iframe'],
ALLOW_UNKNOWN_PROTOCOLS: true,
ADD_ATTR: [
'allow',
'src',
'allowfullscreen',
'frameborder',
'scrolling',
'target',
],
})
} else {
// remove ui
injectedUIEffects.get(id)?.call(null)
return
}
let el = document.querySelector(`#${id}`) as HTMLElement
let content = float ? el?.querySelector('.ls-ui-float-content') : el
if (content) {
content.innerHTML = ui.template
// update attributes
attrs &&
Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
let positionDirty = el.dataset.dx != null
ui.style &&
Object.entries(ui.style).forEach(([k, v]) => {
if (
positionDirty &&
['left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
) {
return
}
el.style[k] = v
})
return
}
el = document.createElement('div')
el.id = id
el.dataset.injectedUi = key || ''
if (float) {
content = document.createElement('div')
content.classList.add('ls-ui-float-content')
el.appendChild(content)
} else {
content = el
}
// TODO: enhance template
content.innerHTML = ui.template
attrs &&
Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
ui.style &&
Object.entries(ui.style).forEach(([k, v]) => {
el.style[k] = v
})
let teardownUI: () => void
let disposeFloat: () => void
// seu up float container
if (float) {
el.setAttribute('draggable', 'true')
el.setAttribute('resizable', 'true')
ui.close && (el.dataset.close = ui.close)
el.classList.add('lsp-ui-float-container', 'visible')
disposeFloat =
(pl._setupResizableContainer(el, key),
pl._setupDraggableContainer(el, {
key,
close: () => teardownUI(),
title: attrs?.title,
}))
}
if (!!slot && ui.reset) {
const exists = Array.from(
target.querySelectorAll('[data-injected-ui]')
).map((it: HTMLElement) => it.id)
exists?.forEach((exist: string) => {
injectedUIEffects.get(exist)?.call(null)
})
}
target.appendChild(el)
// TODO: How handle events
;[
'click',
'focus',
'focusin',
'focusout',
'blur',
'dblclick',
'keyup',
'keypress',
'keydown',
'change',
'input',
'contextmenu',
].forEach((type) => {
el.addEventListener(
type,
(e) => {
const target = e.target! as HTMLElement
const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
if (!trigger) return
const { preventDefault } = trigger.dataset
const msgType = trigger.dataset[`on${ucFirst(type)}`]
if (msgType)
pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
if (preventDefault?.toLowerCase() === 'true') e.preventDefault()
},
false
)
})
// callback
initialCallback?.({ el, float })
teardownUI = () => {
disposeFloat?.()
injectedUIEffects.delete(id)
target!.removeChild(el)
}
injectedUIEffects.set(id, teardownUI)
return teardownUI
}
export function cleanInjectedUI(id: string) {
if (!injectedUIEffects.has(id)) return
const clean = injectedUIEffects.get(id)
try {
clean()
} catch (e) {
console.warn('[CLEAN Injected UI] ', id, e)
}
}
export function cleanInjectedScripts(this: PluginLocal) {
const scripts = document.head.querySelectorAll(`script[data-ref=${this.id}]`)
scripts?.forEach((it) => it.remove())
}
export function transformableEvent(target: HTMLElement, e: Event) {
const obj: any = {}
if (target) {
obj.type = e.type
const ds = target.dataset
const FLAG_RECT = 'rect'
;['value', 'id', 'className', 'dataset', FLAG_RECT].forEach((k) => {
let v: any
switch (k) {
case FLAG_RECT:
if (!ds.hasOwnProperty(FLAG_RECT)) return
v = target.getBoundingClientRect().toJSON()
break
default:
v = target[k]
}
if (typeof v === 'object') {
v = { ...v }
}
obj[k] = v
})
}
return obj
}
export function injectTheme(url: string) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
document.head.appendChild(link)
const ejectTheme = () => {
try {
document.head.removeChild(link)
} catch (e) {
console.error(e)
}
}
return ejectTheme
}
export function mergeSettingsWithSchema(
settings: Record<string, any>,
schema: Array<SettingSchemaDesc>
) {
const defaults = (schema || []).reduce((a, b) => {
if ('default' in b) {
a[b.key] = b.default
}
return a
}, {})
// shadow copy
return Object.assign(defaults, settings)
}
export function normalizeKeyStr(s: string) {
if (typeof s !== 'string') return
return s.trim().replace(/\s/g, '_').toLowerCase()
}

View File

@@ -0,0 +1,283 @@
import { LSPluginUser } from '../LSPlugin.user'
import { PluginLocal } from '../LSPlugin.core'
import { safeSnakeCase } from '../common'
/**
* Declarative condition for matching a block's properties map.
* Operators: has, equals, in, not, any, all.
*/
export type BlockPropertiesCondition =
| { has: string }
| { equals: [string, any] }
| { in: [string, Array<any>] }
| { not: BlockPropertiesCondition }
| { any: Array<BlockPropertiesCondition> }
| { all: Array<BlockPropertiesCondition> }
export type BlockPropertiesRendererProps = {
blockId: string
properties: Record<string, any>
}
export type BlockRendererChild = Record<string, any> & {
children?: Array<BlockRendererChild>
}
export type BlockRendererProps = BlockPropertiesRendererProps & {
uuid?: string
page?: string
content?: string
format?: string
children?: Array<BlockRendererChild>
}
export type BlockPropertiesPredicate = (
props: BlockPropertiesRendererProps
) => boolean
export type BlockRendererPredicate = (
props: BlockRendererProps
) => boolean
/**
* WARN: These are some experience features and might be adjusted at any time.
* These unofficial plugins that use these APIs are temporarily
* may not be supported on the Marketplace.
*/
export class LSPluginExperiments {
constructor(private ctx: LSPluginUser) {}
get React(): unknown {
return this.ensureHostScope().React
}
get ReactDOM(): unknown {
return this.ensureHostScope().ReactDOM
}
get Components() {
const exper = this.ensureHostScope().logseq.sdk.experiments
return {
Editor: exper.cp_page_editor as (props: { page: string } & any) => any,
}
}
get Utils() {
const utils = this.ensureHostScope().logseq.sdk.utils
const withCall = (name: string): ((input: any) => any) =>
utils[safeSnakeCase(name)]
return {
toClj: withCall('toClj'),
jsxToClj: withCall('jsxToClj'),
toJs: withCall('toJs'),
toKeyword: withCall('toKeyword'),
toSymbol: withCall('toSymbol'),
}
}
get pluginLocal(): PluginLocal {
return this.ensureHostScope().LSPluginCore.ensurePlugin(
this.ctx.baseInfo.id
)
}
public invokeExperMethod(type: string, ...args: Array<any>) {
const host = this.ensureHostScope()
type = safeSnakeCase(type)?.toLowerCase()
const fn =
host.logseq.api['exper_' + type] || host.logseq.sdk.experiments[type]
return fn?.apply(host, args)
}
async loadScripts(...scripts: Array<string>) {
scripts = scripts.map((it) => {
if (!it?.startsWith('http')) {
return this.ctx.resolveResourceFullUrl(it)
}
return it
})
scripts.unshift(this.ctx.baseInfo.id)
await this.invokeExperMethod('loadScripts', ...scripts)
}
registerFencedCodeRenderer(
lang: string,
opts: {
edit?: boolean
before?: () => Promise<void>
subs?: Array<string>
render: (props: { content: string }) => any
}
) {
return this.invokeExperMethod(
'registerFencedCodeRenderer',
this.ctx.baseInfo.id,
lang,
opts
)
}
registerDaemonRenderer(
key: string,
opts: {
before?: () => Promise<void>
subs?: Array<string>
render: (props: {}) => any
}
) {
return this.invokeExperMethod(
'registerDaemonRenderer',
this.ctx.baseInfo.id,
key,
opts
)
}
registerHostedRenderer(
key: string,
opts: {
title?: string
subs?: Array<string>
type?: string
render: (props: {}) => any
}
) {
return this.invokeExperMethod(
'registerHostedRenderer',
this.ctx.baseInfo.id,
key,
opts
)
}
registerSidebarRenderer(
key: string,
opts: {
title?: string
subs?: Array<string>
render: (props: {}) => any
[k: string]: any
}
) {
key = `_sidebar.${key}`
opts.type = 'sidebar'
return this.registerHostedRenderer(key, opts)
}
registerRouteRenderer(
key: string,
opts: {
name?: string
subs?: Array<string>
path: string
render: (props: {}) => any
}
) {
return this.invokeExperMethod(
'registerRouteRenderer',
this.ctx.baseInfo.id,
key,
opts
)
}
/**
* Register a custom renderer for the block properties area.
* The renderer is shown when the block's properties match the `when` condition.
* `when` may be either a declarative condition object or a synchronous predicate.
*
* @param key Unique key for this renderer (scoped to the plugin).
* @param opts Renderer options.
* @param opts.when Optional condition or synchronous predicate; if omitted, always matches.
* @param opts.mode "prepend" | "append" (default) | "replace".
* @param opts.priority Higher number wins when multiple replace renderers match.
* @param opts.subs Reserved subscription list for future reactive updates.
* @param opts.render React function component receiving `{ blockId, properties }`.
*/
registerBlockPropertiesRenderer(
key: string,
opts: {
when?: BlockPropertiesCondition | BlockPropertiesPredicate
mode?: 'prepend' | 'append' | 'replace'
priority?: number
subs?: Array<string>
render: (props: BlockPropertiesRendererProps) => any
}
) {
return this.invokeExperMethod(
'registerBlockPropertiesRenderer',
this.ctx.baseInfo.id,
key,
opts
)
}
/**
* Register a custom renderer for the block body.
* When the synchronous predicate matches, the plugin renderer replaces the
* default outline view by default. Users can switch back to outline view via
* an explicit UI toggle on each matched block.
*
* @param key Unique key for this renderer (scoped to the plugin).
* @param opts Renderer options.
* @param opts.when Optional synchronous predicate; if omitted, always matches.
* @param opts.includeChildren When true, passes the block's recursive children
* tree to the renderer and hides native outline children while the plugin
* renderer is active.
* @param opts.priority Higher number wins when multiple block renderers match.
* @param opts.subs Reserved subscription list for future reactive updates.
* @param opts.render React function component receiving block renderer props.
*/
registerBlockRenderer(
key: string,
opts: {
when?: BlockRendererPredicate
includeChildren?: boolean
priority?: number
subs?: Array<string>
render: (props: BlockRendererProps) => any
}
) {
return this.invokeExperMethod(
'registerBlockRenderer',
this.ctx.baseInfo.id,
key,
opts
)
}
registerExtensionsEnhancer<T = any>(
type: 'katex' | 'codemirror',
enhancer: (v: T) => Promise<any>
) {
const host = this.ensureHostScope()
switch (type) {
case 'katex':
if (host.katex) {
enhancer(host.katex).catch(console.error)
}
break
default:
}
return this.invokeExperMethod(
'registerExtensionsEnhancer',
this.ctx.baseInfo.id,
type,
enhancer
)
}
ensureHostScope(): any {
try {
window.top?.document
} catch (_e) {
console.error('Can not access host scope!')
}
return window.top
}
}

View File

@@ -0,0 +1,670 @@
import { LSPluginUser } from '../LSPlugin.user'
export type NetMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
export type NetResponseType =
| 'json'
| 'text'
| 'base64'
| 'arraybuffer'
| 'arrayBuffer'
export type NetHeaders = Record<string, string>
export type NetRetryOptions = {
retries?: number
delay?: number
factor?: number
retryOn?:
| Array<number>
| ((response: LSPluginNetResponse<any> | Error) => boolean)
}
export type NetCacheOptions = {
key?: string
ttl?: number
maxEntries?: number
}
export type NetRequestOptions<TBody = any> = {
url: string
method?: NetMethod
headers?: NetHeaders
body?: TBody | string | ArrayBuffer
data?: TBody | string | ArrayBuffer
responseType?: NetResponseType
timeout?: number
retry?: number | NetRetryOptions
cache?: boolean | NetCacheOptions
signal?: AbortSignal
abortable?: boolean
}
export type NetRequestConfig<TBody = any> = Omit<
NetRequestOptions<TBody>,
'url' | 'method'
>
export type NetRequestBody<TBody = any> = TBody | string | ArrayBuffer
export type NetResponsePayload<T = any> = {
status: number
statusText: string
ok: boolean
url: string
headers: NetHeaders
body: T
}
type NormalizedRetryOptions = Required<
Pick<NetRetryOptions, 'retries' | 'delay' | 'factor'>
> &
Pick<NetRetryOptions, 'retryOn'>
type CacheEntry<T = any> = {
expires: number
response: LSPluginNetResponse<T>
}
type HostResponseType = 'json' | 'text' | 'base64' | 'arraybuffer'
type HostRequestID = string | number
const DEFAULT_CACHE_TTL = 5 * 60 * 1000
const DEFAULT_CACHE_MAX_ENTRIES = 100
const CLIENT_MSG_CALLBACK = '#lsp#request#callback'
const genTaskCallbackType = (id: HostRequestID) => `task_callback_${id}`
function normalizeResponseType(type?: NetResponseType): HostResponseType {
return type === 'arrayBuffer' ? 'arraybuffer' : type || 'json'
}
function makeAbortError() {
const error = new Error('The request was aborted')
error.name = 'AbortError'
return error
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function responseToCachePayload<T>(
response: LSPluginNetResponse<T>
): NetResponsePayload<T> {
return {
status: response.status,
statusText: response.statusText,
ok: response.ok,
url: response.url,
headers: { ...response.headers },
body: response.body,
}
}
function encodeText(value: string) {
return new TextEncoder().encode(value).buffer
}
function decodeText(value: ArrayBuffer) {
return new TextDecoder().decode(value)
}
function base64ToArrayBuffer(value: string) {
const binary = atob(value)
const payload = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
payload[i] = binary.charCodeAt(i)
}
return payload.buffer
}
function arrayBufferToBase64(value: ArrayBuffer) {
const payload = new Uint8Array(value)
const chunkSize = 0x8000
let binary = ''
for (let i = 0; i < payload.length; i += chunkSize) {
binary += String.fromCharCode(...payload.subarray(i, i + chunkSize))
}
return btoa(binary)
}
function headersToRecord(headers: Headers) {
const result: NetHeaders = {}
headers.forEach((value, key) => {
result[key] = value
})
return result
}
function bodyToFetchBody(value: unknown): BodyInit | undefined {
if (value == null) return undefined
if (typeof value === 'string') return value
if (value instanceof ArrayBuffer) return value
if (ArrayBuffer.isView(value)) return value as BodyInit
return JSON.stringify(value)
}
export class LSPluginNetResponse<T = any> {
constructor(
private _payload: NetResponsePayload<T>,
private _responseType: HostResponseType
) {}
get status() {
return this._payload.status
}
get statusText() {
return this._payload.statusText
}
get ok() {
return this._payload.ok
}
get url() {
return this._payload.url
}
get headers() {
return this._payload.headers
}
get body() {
return this._payload.body
}
async json<R = any>(): Promise<R> {
const { body } = this._payload
if (this._responseType === 'json') {
return body as unknown as R
}
if (typeof body === 'string') {
return JSON.parse(body) as R
}
if (body instanceof ArrayBuffer) {
return JSON.parse(decodeText(body)) as R
}
return body as unknown as R
}
async text(): Promise<string> {
const { body } = this._payload
if (typeof body === 'string') {
return body
}
if (body instanceof ArrayBuffer) {
return decodeText(body)
}
return JSON.stringify(body)
}
async arrayBuffer(): Promise<ArrayBuffer> {
const { body } = this._payload
if (body instanceof ArrayBuffer) {
return body
}
if (this._responseType === 'base64' && typeof body === 'string') {
return base64ToArrayBuffer(body)
}
if (typeof body === 'string') {
return encodeText(body)
}
return encodeText(JSON.stringify(body))
}
clone(): LSPluginNetResponse<T> {
return new LSPluginNetResponse(
responseToCachePayload(this),
this._responseType
)
}
}
export class LSPluginNetError<T = any> extends Error {
constructor(public response: LSPluginNetResponse<T>) {
super(
`HTTP request failed with status ${response.status} ${response.statusText}`.trim()
)
this.name = 'LSPluginNetError'
}
get status() {
return this.response.status
}
get statusText() {
return this.response.statusText
}
get url() {
return this.response.url
}
get headers() {
return this.response.headers
}
get body() {
return this.response.body
}
}
function responseBodyOrThrow<T>(response: LSPluginNetResponse<T>) {
if (response.status >= 200 && response.status < 300) {
return response.body
}
throw new LSPluginNetError(response)
}
/**
* HTTP client for plugins. Desktop requests are proxied by the host process so
* iframe plugins can avoid browser CORS limitations. Web requests fall back to
* browser fetch and are subject to normal CORS rules.
*/
export class LSPluginNet {
private _cache = new Map<string, CacheEntry>()
private _events = new EventTarget()
constructor(private _ctx: LSPluginUser) {
this._ctx.caller.on(CLIENT_MSG_CALLBACK, (e: any) => {
const reqId = e?.requestId
if (!reqId) return
this._events.dispatchEvent(
new CustomEvent(genTaskCallbackType(reqId), { detail: e?.payload })
)
})
}
async request<T = any>(
options: NetRequestOptions
): Promise<LSPluginNetResponse<T>> {
const method = (options.method || 'GET').toUpperCase() as NetMethod
const responseType = normalizeResponseType(options.responseType)
const retry = this.normalizeRetry(options.retry)
const cacheKey = this.getCacheKey(options, method, responseType)
if (cacheKey) {
const cached = this._cache.get(cacheKey)
if (cached && cached.expires > Date.now()) {
this._cache.delete(cacheKey)
this._cache.set(cacheKey, cached)
return cached.response.clone()
}
cached && this._cache.delete(cacheKey)
}
let attempt = 0
while (true) {
try {
const response = await this.performRequest<T>(
options,
method,
responseType
)
if (attempt < retry.retries && this.shouldRetry(retry, response)) {
await wait(retry.delay * Math.pow(retry.factor, attempt++))
continue
}
if (cacheKey) {
this.pruneCache(this.getCacheMaxEntries(options.cache))
this._cache.set(cacheKey, {
expires: Date.now() + this.getCacheTTL(options.cache),
response: response.clone(),
})
}
return response
} catch (e: any) {
if (
options.signal?.aborted ||
attempt >= retry.retries ||
!this.shouldRetry(retry, e)
) {
throw e
}
await wait(retry.delay * Math.pow(retry.factor, attempt++))
}
}
}
get<T = any>(
url: string,
options?: NetRequestConfig
): Promise<T> {
return this.requestWithMethod<T>('GET', url, options)
}
head<T = any>(
url: string,
options?: NetRequestConfig
): Promise<T> {
return this.requestWithMethod<T>('HEAD', url, options)
}
post<T = any, TBody = any>(
url: string,
body?: NetRequestBody<TBody>,
options?: NetRequestConfig<TBody>
): Promise<T> {
return this.requestWithBody<T, TBody>('POST', url, body, options)
}
put<T = any, TBody = any>(
url: string,
body?: NetRequestBody<TBody>,
options?: NetRequestConfig<TBody>
): Promise<T> {
return this.requestWithBody<T, TBody>('PUT', url, body, options)
}
patch<T = any, TBody = any>(
url: string,
body?: NetRequestBody<TBody>,
options?: NetRequestConfig<TBody>
): Promise<T> {
return this.requestWithBody<T, TBody>('PATCH', url, body, options)
}
delete<T = any, TBody = any>(
url: string,
options?: NetRequestConfig<TBody>
): Promise<T> {
return this.requestWithMethod<T, TBody>('DELETE', url, options)
}
private requestWithMethod<T = any, TBody = any>(
method: NetMethod,
url: string,
options: NetRequestConfig<TBody> = {}
) {
return this.request<T>({
...options,
url,
method,
}).then(responseBodyOrThrow)
}
private requestWithBody<T = any, TBody = any>(
method: Extract<NetMethod, 'POST' | 'PUT' | 'PATCH'>,
url: string,
body?: NetRequestBody<TBody>,
options: NetRequestConfig<TBody> = {}
) {
const requestBody = typeof body === 'undefined' ? options.body : body
return this.request<T>({
...options,
url,
method,
body: requestBody,
}).then(responseBodyOrThrow)
}
private async performRequest<T>(
options: NetRequestOptions,
method: NetMethod,
responseType: HostResponseType
) {
if (this.shouldUseFetchFallback()) {
return this.performFetchRequest<T>(options, method, responseType)
}
try {
return await this.performProxyRequest<T>(options, method, responseType)
} catch (e) {
if (this.shouldFallbackAfterProxyError(e)) {
return this.performFetchRequest<T>(options, method, responseType)
}
throw e
}
}
private async performProxyRequest<T>(
options: NetRequestOptions,
method: NetMethod,
responseType: HostResponseType
) {
if (options.signal?.aborted) {
throw makeAbortError()
}
const abortable = Boolean(options.abortable || options.signal)
const reqId = await this._ctx._execCallableAPIAsync(
'exper_request',
this._ctx.baseInfo.id,
{
url: options.url,
method,
headers: options.headers,
data: options.body ?? options.data,
timeout: options.timeout,
returnType: responseType,
abortable,
includeResponse: true,
}
)
if (!reqId) {
throw new Error('Host HTTP request proxy is not available')
}
const callbackType = genTaskCallbackType(reqId)
const payloadPromise = new Promise<NetResponsePayload<T>>(
(resolve, reject) => {
const listener = (event: Event) => {
const payload = (event as CustomEvent).detail
this._events.removeEventListener(callbackType, listener)
if (payload && payload instanceof Error) {
reject(payload)
} else {
resolve(payload)
}
}
this._events.addEventListener(callbackType, listener, { once: true })
}
)
const abort = () => this.abortProxyRequest(reqId, abortable)
options.signal?.addEventListener('abort', abort, { once: true })
try {
const payload = await payloadPromise
return new LSPluginNetResponse(payload, responseType)
} finally {
options.signal?.removeEventListener('abort', abort)
}
}
private abortProxyRequest(reqId: HostRequestID, abortable: boolean) {
if (!abortable) return
this._ctx._execCallableAPI('http_request_abort', reqId)
}
private async performFetchRequest<T>(
options: NetRequestOptions,
method: NetMethod,
responseType: HostResponseType
) {
if (typeof fetch !== 'function') {
throw new Error('Browser fetch is not available')
}
if (options.signal?.aborted) {
throw makeAbortError()
}
const timeout =
typeof options.timeout === 'number' && options.timeout > 0
? options.timeout
: undefined
const controller = timeout || options.signal ? new AbortController() : null
const abort = () => controller?.abort()
const timeoutId = timeout ? setTimeout(abort, timeout) : null
options.signal?.addEventListener('abort', abort, { once: true })
try {
const response = await fetch(options.url, {
method,
headers: options.headers,
body: ['GET', 'HEAD'].includes(method)
? undefined
: bodyToFetchBody(options.body ?? options.data),
signal: controller?.signal || options.signal,
})
const body = await this.readFetchBody<T>(response, method, responseType)
return new LSPluginNetResponse<T>(
{
status: response.status,
statusText: response.statusText,
ok: response.ok,
url: response.url,
headers: headersToRecord(response.headers),
body,
},
responseType
)
} finally {
if (timeoutId) clearTimeout(timeoutId)
options.signal?.removeEventListener('abort', abort)
}
}
private async readFetchBody<T>(
response: Response,
method: NetMethod,
responseType: HostResponseType
) {
if (method === 'HEAD' || response.status === 204 || response.status === 205) {
return null as T
}
switch (responseType) {
case 'json':
return (await response.json()) as T
case 'text':
return (await response.text()) as T
case 'arraybuffer':
return (await response.arrayBuffer()) as T
case 'base64':
return arrayBufferToBase64(await response.arrayBuffer()) as T
}
}
private shouldUseFetchFallback() {
return Boolean((this._ctx.baseInfo as any)?.webMode)
}
private shouldFallbackAfterProxyError(error: unknown) {
return error == null && typeof fetch === 'function'
}
private normalizeRetry(
retry?: number | NetRetryOptions
): NormalizedRetryOptions {
if (typeof retry === 'number') {
return { retries: Math.max(0, retry), delay: 300, factor: 2 }
}
return {
retries: Math.max(0, retry?.retries || 0),
delay: retry?.delay || 300,
factor: retry?.factor || 2,
retryOn: retry?.retryOn,
}
}
private shouldRetry(
retry: NormalizedRetryOptions,
value: LSPluginNetResponse | Error
) {
const { retryOn } = retry
if (typeof retryOn === 'function') {
return retryOn(value)
}
if (value instanceof LSPluginNetResponse) {
return Array.isArray(retryOn)
? retryOn.includes(value.status)
: value.status === 429 || value.status >= 500
}
return true
}
private getCacheKey(
options: NetRequestOptions,
method: NetMethod,
responseType: HostResponseType
) {
if (!options.cache || !['GET', 'HEAD'].includes(method)) return null
if (typeof options.cache === 'object' && options.cache.key) {
return options.cache.key
}
return JSON.stringify({
method,
url: options.url,
headers: options.headers || {},
responseType,
})
}
private getCacheTTL(cache: NetRequestOptions['cache']) {
return typeof cache === 'object' && typeof cache.ttl === 'number'
? Math.max(0, cache.ttl)
: DEFAULT_CACHE_TTL
}
private getCacheMaxEntries(cache: NetRequestOptions['cache']) {
return typeof cache === 'object' && typeof cache.maxEntries === 'number'
? Math.max(1, cache.maxEntries)
: DEFAULT_CACHE_MAX_ENTRIES
}
private pruneCache(maxEntries: number) {
while (this._cache.size >= maxEntries) {
const oldest = this._cache.keys().next().value
if (!oldest) return
this._cache.delete(oldest)
}
}
get ctx(): LSPluginUser {
return this._ctx
}
}

View File

@@ -0,0 +1,79 @@
import { IPluginSearchServiceHooks } from '../LSPlugin'
import { LSPluginUser } from '../LSPlugin.user'
import { isArray, isFunction, mapKeys } from 'lodash-es'
export class LSPluginSearchService {
/**
* @param ctx
* @param serviceHooks
*/
constructor(
private ctx: LSPluginUser,
private serviceHooks: IPluginSearchServiceHooks
) {
ctx._execCallableAPI(
'register-search-service',
ctx.baseInfo.id,
serviceHooks.name,
serviceHooks.options
)
// hook events TODO: remove listeners
const wrapHookEvent = (k) => `service:search:${k}:${serviceHooks.name}`
Object.entries({
query: {
f: 'onQuery',
args: ['graph', 'q', true],
reply: true,
transformOutput: (data: any) => {
// TODO: transform keys?
if (isArray(data?.blocks)) {
data.blocks = data.blocks.map((it) => {
return it && mapKeys(it, (_, k) => `block/${k}`)
})
}
return data
},
},
rebuildBlocksIndice: { f: 'onIndiceInit', args: ['graph', 'blocks'] },
transactBlocks: { f: 'onBlocksChanged', args: ['graph', 'data'] },
truncateBlocks: { f: 'onIndiceReset', args: ['graph'] },
removeDb: { f: 'onGraph', args: ['graph'] },
}).forEach(([k, v]) => {
const hookEvent = wrapHookEvent(k)
ctx.caller.on(hookEvent, async (payload: any) => {
if (isFunction(serviceHooks?.[v.f])) {
let ret = null
try {
ret = await serviceHooks[v.f].apply(
serviceHooks,
(v.args || []).map((prop: any) => {
if (!payload) return
if (prop === true) return payload
if (payload.hasOwnProperty(prop)) {
const ret = payload[prop]
delete payload[prop]
return ret
}
})
)
if (v.transformOutput) {
ret = v.transformOutput(ret)
}
} catch (e) {
console.error('[SearchService] ', e)
ret = e
} finally {
if (v.reply) {
ctx.caller.call(`${hookEvent}:reply`, ret)
}
}
}
})
})
}
}

View File

@@ -0,0 +1,101 @@
import { LSPluginUser } from '../LSPlugin.user'
export interface IAsyncStorage {
getItem(key: string): Promise<string | undefined>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
hasItem(key: string): Promise<boolean>
allKeys(): Promise<Array<string>>
clear(): Promise<void>
}
/**
* A storage based on local files under specific context
*/
class LSPluginFileStorage implements IAsyncStorage {
/**
* @param ctx
* @param opts
*/
constructor(
private ctx: LSPluginUser,
private opts?: {
assets: boolean
}
) {}
/**
* plugin id
*/
get ctxId() {
return this.ctx.baseInfo.id
}
/**
* @param key A string as file name that support nested directory
* @param value Storage value
*/
setItem(key: string, value: string | any): Promise<void> {
return this.ctx.caller.callAsync(`api:call`, {
method: 'write-plugin-storage-file',
args: [this.ctxId, key, value, this.opts?.assets],
})
}
/**
* @param key
*/
getItem(key: string): Promise<string | any> {
return this.ctx.caller.callAsync(`api:call`, {
method: 'read-plugin-storage-file',
args: [this.ctxId, key, this.opts?.assets],
})
}
/**
* @param key
*/
removeItem(key: string): Promise<void> {
return this.ctx.caller.callAsync(`api:call`, {
method: 'unlink-plugin-storage-file',
args: [this.ctxId, key, this.opts?.assets],
})
}
/**
* Get all path file keys
*/
allKeys(): Promise<Array<string>> {
return this.ctx.caller.callAsync(`api:call`, {
method: 'list-plugin-storage-files',
args: [this.ctxId, this.opts?.assets],
})
}
/**
* Clears the storage
*/
clear(): Promise<void> {
return this.ctx.caller.callAsync(`api:call`, {
method: 'clear-plugin-storage-files',
args: [this.ctxId, this.opts?.assets],
})
}
/**
* @param key
*/
hasItem(key: string): Promise<boolean> {
return this.ctx.caller.callAsync(`api:call`, {
method: 'exist-plugin-storage-file',
args: [this.ctxId, key, this.opts?.assets],
})
}
}
export { LSPluginFileStorage }

641
libs/src/postmate/index.ts Normal file
View File

@@ -0,0 +1,641 @@
// Fork from https://github.com/dollarshaveclub/postmate
export const messageType = 'application/x-postmate-v1+json'
export const defaultRequestTimeout = 10_000
export const maxHandshakeRequests = 5
let _messageId = 0
const generateNewMessageId = () => ++_messageId
const log = (...args: any) => (Postmate.debug ? console.log(...args) : null)
const resolveOrigin = (url: string) => {
const a = document.createElement('a')
a.href = url
// file:// URLs always have origin === 'null'; use '*' to allow postMessage
if (a.protocol === 'file:') return '*'
const protocol = a.protocol.length > 4 ? a.protocol : window.location.protocol
const host = a.host.length
? a.port === '80' || a.port === '443'
? a.hostname
: a.host
: window.location.host
return a.origin || `${protocol}//${host}`
}
const messageTypes = {
handshake: 1,
'handshake-reply': 1,
call: 1,
emit: 1,
reply: 1,
request: 1,
}
export const sanitize = (message: any, allowedOrigin: any) => {
if (typeof allowedOrigin === 'string' && allowedOrigin !== '*' && message.origin !== allowedOrigin)
return false
if (!message.data) return false
if (typeof message.data === 'object' && !('postmate' in message.data))
return false
if (message.data.type !== messageType) return false
if (!messageTypes[message.data.postmate]) return false
return true
}
export const resolveValue = (model: any, property: string, args: Array<any>) => {
// args arguments passed from parent to child function
const unwrappedContext =
typeof model[property] === 'function'
? model[property].apply(null, args)
: model[property]
return Promise.resolve(unwrappedContext)
}
/**
* Composes an API to be used by the parent
* @param {Object} info Information on the consumer
*/
export class ParentAPI {
public parent: Window
public frame: HTMLIFrameElement
public child: Window
public events = {}
public childOrigin: string
public listener: (e: any) => void
private readonly messagePort?: MessagePort
private addTransportListener(handler: (e: any) => void) {
if (this.messagePort) {
console.debug('[DEBUG] Using MessagePort for communication:', this.frame.src)
// MessagePort delivers MessageEvent too, but without origin/source.
this.messagePort.addEventListener('message', handler as any)
// Some browsers require start() when using addEventListener.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
;(this.messagePort as any).start?.()
} else {
console.debug('[DEBUG] Using postMessage for communication:', this.frame.src)
this.parent.addEventListener('message', handler, false)
}
}
private removeTransportListener(handler: (e: any) => void) {
if (this.messagePort) {
this.messagePort.removeEventListener('message', handler as any)
} else {
this.parent.removeEventListener('message', handler, false)
}
}
private postToChild(payload: any) {
if (this.messagePort) {
this.messagePort.postMessage(payload)
return
}
this.child.postMessage(payload, this.childOrigin)
}
constructor(info: Postmate) {
this.parent = info.parent
this.frame = info.frame
this.child = info.child
this.childOrigin = info.childOrigin
this.messagePort = info.messagePort
if (process.env.NODE_ENV !== 'production') {
log('Parent: Registering API')
log('Parent: Awaiting messages...')
}
this.listener = (e) => {
// Port messages don't have origin/source, so we only enforce postmate/type.
if (this.messagePort) {
if (!e?.data) return false
if (typeof e.data === 'object' && !('postmate' in e.data)) return false
if (e.data.type !== messageType) return false
if (!messageTypes[e.data.postmate]) return false
} else {
if (!sanitize(e, this.childOrigin)) return false
}
/**
* the assignments below ensures that e, data, and value are all defined
*/
const { data, name } = ((e || {}).data || {}).value || {}
if (e.data.postmate === 'emit') {
if (process.env.NODE_ENV !== 'production') {
log(`Parent: Received event emission: ${name}`)
}
if (name in this.events) {
this.events[name].forEach((callback: Function) => {
callback.call(this, data)
})
}
}
}
// NOTE: older SDKs rely on window.postMessage only, so we must
// initially add listener on window (messagePort is not set yet at construction time)
this.addTransportListener(this.listener)
if (process.env.NODE_ENV !== 'production') {
log('Parent: Awaiting event emissions from Child')
}
}
get(property: string, ...args: any) {
return new Promise((resolve, reject) => {
// Extract data from response and kill listeners
const uid = generateNewMessageId()
const timeoutMs =
typeof (Postmate as any).requestTimeout === 'number'
? (Postmate as any).requestTimeout
: defaultRequestTimeout
let timer: any
const transact = (e) => {
if (e?.data?.uid === uid && e.data.postmate === 'reply') {
this.removeTransportListener(transact)
if (timer) clearTimeout(timer)
if (e.data.error) {
reject(e.data.error)
} else {
resolve(e.data.value)
}
}
}
// Prepare for response from Child...
this.addTransportListener(transact)
if (timeoutMs > 0) {
timer = setTimeout(() => {
this.removeTransportListener(transact)
reject(new Error(`Postmate: request timeout (${timeoutMs}ms)`))
}, timeoutMs)
}
// Then ask child for information
this.postToChild({
postmate: 'request',
type: messageType,
property,
args,
uid,
})
})
}
call(property: string, data: any) {
// Send information to the child
this.postToChild({
postmate: 'call',
type: messageType,
property,
data,
})
}
on(eventName: string, callback: Function) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(callback)
}
destroy() {
if (process.env.NODE_ENV !== 'production') {
log('Parent: Destroying Postmate instance')
}
this.removeTransportListener(this.listener)
try {
this.messagePort?.close()
} catch (e) {
// ignore
}
this.frame.parentNode.removeChild(this.frame)
}
}
// Composes an API to be used by the child
export class ChildAPI {
private readonly model: any
private parent: Window
private readonly parentOrigin: string
private child: Window
private messagePort?: MessagePort
private readonly listener: (e: any) => void
private addTransportListener(handler: (e: any) => void) {
if (this.messagePort) {
this.messagePort.addEventListener('message', handler as any)
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
;(this.messagePort as any).start?.()
} else {
this.child.addEventListener('message', handler, false)
}
}
private postToParent(payload: any, fallbackEvent?: MessageEvent<any>) {
if (this.messagePort) {
this.messagePort.postMessage(payload)
return
}
// reply uses the event source/origin, others use stored parentOrigin.
if (fallbackEvent?.source) {
const replyOrigin = fallbackEvent.origin === 'null' ? '*' : fallbackEvent.origin
;(fallbackEvent.source as WindowProxy).postMessage(payload, replyOrigin)
} else {
this.parent.postMessage(payload, this.parentOrigin)
}
}
constructor(info: Model) {
this.model = info.model
this.parent = info.parent
this.parentOrigin = info.parentOrigin
this.child = info.child
this.messagePort = info.messagePort
if (process.env.NODE_ENV !== 'production') {
log('Child: Registering API')
log('Child: Awaiting messages...')
}
this.listener = (e) => {
// Port messages don't have origin/source, so we only enforce postmate/type.
if (this.messagePort) {
if (!e?.data) return
if (typeof e.data === 'object' && !('postmate' in e.data)) return
if (e.data.type !== messageType) return
if (!messageTypes[e.data.postmate]) return
} else {
if (!sanitize(e, this.parentOrigin)) return
}
const { property, uid, data, args } = e.data
if (e.data.postmate === 'call') {
if (
property in this.model &&
typeof this.model[property] === 'function'
) {
this.model[property](data)
}
return
}
// Reply to Parent
resolveValue(this.model, property, args)
.then((value) => {
this.postToParent(
{
property,
postmate: 'reply',
type: messageType,
uid,
value,
},
e
)
})
.catch((error) => {
this.postToParent(
{
property,
postmate: 'reply',
type: messageType,
uid,
error,
},
e
)
})
}
// Initially add listener on window (messagePort is not set yet at construction time)
this.addTransportListener(this.listener)
// Listen for channel ready event to switch transport from window to MessagePort
if (!this.messagePort) {
const channelReadyHandler = (event: CustomEvent) => {
const port = event.detail?.port
if (port) {
if (process.env.NODE_ENV !== 'production') {
log('Child: Switching from window.postMessage to MessagePort')
}
// Step 1: Remove listener from window to avoid duplicate message handling
this.child.removeEventListener('message', this.listener, false)
// Step 2: Update messagePort reference (this affects this.listener validation logic)
this.messagePort = port
// Step 3: Add the same listener to MessagePort (now all communication goes through port)
this.messagePort.addEventListener('message', this.listener as any)
;(this.messagePort as any).start?.()
if (process.env.NODE_ENV !== 'production') {
log('Child: Successfully switched to MessagePort. All future messages will use MessageChannel.')
}
// Step 4: Clean up the channel-ready event listener
this.child.removeEventListener('postmate:channel-ready', channelReadyHandler as any)
}
}
this.child.addEventListener('postmate:channel-ready', channelReadyHandler as any)
}
}
emit(name: string, data: any) {
if (process.env.NODE_ENV !== 'production') {
log(`Child: Emitting Event "${name}"`, data)
}
this.postToParent({
postmate: 'emit',
type: messageType,
value: {
name,
data,
},
})
}
}
export type PostMateOptions = {
container: HTMLElement
url: string
id?: string
classListArray?: Array<string>
name?: string
model?: any,
allow?: string
/**
* Prefer using MessageChannel/MessagePort after handshake.
* Defaults to false to keep backward-compatible behavior with older SDKs.
*/
enableMessageChannel?: boolean
}
/**
* The entry point of the Parent.
*/
export class Postmate {
static debug = false // eslint-disable-line no-undef
static requestTimeout: number = defaultRequestTimeout
public container?: HTMLElement
public parent: Window
public frame: HTMLIFrameElement
public child?: Window
public childOrigin?: string
public url: string
public model: any
static Model: any
// Preferred transport after handshake.
public messagePort?: MessagePort
private readonly enableMessageChannel: boolean
constructor(opts: PostMateOptions) {
this.container = opts.container
this.url = opts.url
this.parent = window
this.frame = document.createElement('iframe')
if (opts.id) this.frame.id = opts.id
if (opts.name) this.frame.name = opts.name
if (opts.allow) this.frame.allow = opts.allow
this.frame.classList.add.apply(
this.frame.classList,
opts.classListArray || []
)
this.container.appendChild(this.frame)
this.child = this.frame.contentWindow
this.model = opts.model || {}
this.enableMessageChannel = !!opts.enableMessageChannel
}
sendHandshake(url?: string) {
url = url || this.url
const childOrigin = resolveOrigin(url)
let attempt = 0
let responseInterval: any
return new Promise((resolve, reject) => {
const runtimeSupportsMessageChannel =
typeof MessageChannel !== 'undefined' &&
typeof (MessageChannel as any) === 'function'
const shouldUseMessageChannel =
this.enableMessageChannel && runtimeSupportsMessageChannel
const reply = (e: any) => {
if (!sanitize(e, childOrigin)) return false
if (e.data.postmate === 'handshake-reply') {
clearInterval(responseInterval)
if (process.env.NODE_ENV !== 'production') {
log('Parent: Received handshake reply from Child')
}
this.parent.removeEventListener('message', reply, false)
this.childOrigin = e.origin
// After successful handshake, establish MessageChannel if enabled and child supports it
if (shouldUseMessageChannel) {
// Check if child returned a port (child initiated the channel)
if (e?.ports?.length) {
const returnedPort = e.ports[0]
if (returnedPort) {
this.messagePort = returnedPort
;(this.messagePort as any).start?.()
if (process.env.NODE_ENV !== 'production') {
log('Parent: Using MessageChannel returned by child')
}
}
} else if (e.data.acceptsMessageChannel) {
// Child signals it accepts MessageChannel, so parent creates and transfers port
const channel = new MessageChannel()
this.messagePort = channel.port1
;(this.messagePort as any).start?.()
// Send port2 to child in a follow-up message
this.child.postMessage(
{
postmate: 'setup-channel',
type: messageType,
},
childOrigin,
[channel.port2]
)
if (process.env.NODE_ENV !== 'production') {
log('Parent: Sent MessageChannel port to child')
}
}
}
if (process.env.NODE_ENV !== 'production') {
log('Parent: Saving Child origin', this.childOrigin)
}
return resolve(new ParentAPI(this))
}
// Might need to remove since parent might be receiving different messages
// from different hosts
if (process.env.NODE_ENV !== 'production') {
log('Parent: Invalid handshake reply')
}
return reject('Failed handshake')
}
this.parent.addEventListener('message', reply, false)
const doSend = () => {
attempt++
if (process.env.NODE_ENV !== 'production') {
log(`Parent: Sending handshake attempt ${attempt}`, { childOrigin })
}
// Always use window.postMessage for handshake to maintain backward compatibility
const payload: any = {
postmate: 'handshake',
type: messageType,
model: this.model,
// Signal to child that parent supports MessageChannel
enableMessageChannel: shouldUseMessageChannel ? 1 : 0,
}
this.child.postMessage(payload, childOrigin)
if (attempt === maxHandshakeRequests) {
clearInterval(responseInterval)
}
}
const loaded = () => {
doSend()
responseInterval = setInterval(doSend, 500)
}
this.frame.addEventListener('load', loaded)
if (process.env.NODE_ENV !== 'production') {
log('Parent: Loading frame', { url })
}
this.frame.src = url
})
}
destroy() {
if (process.env.NODE_ENV !== 'production') {
log('Postmate: Destroying Postmate instance')
}
try {
this.messagePort?.close()
} catch (e) {
// ignore
}
this.frame.parentNode.removeChild(this.frame)
}
}
/**
* The entry point of the Child
*/
export class Model {
public child: Window
public model: any
public parent: Window
public parentOrigin: string
public messagePort?: MessagePort
private enableMessageChannel: boolean
constructor(model: any) {
this.child = window
this.model = model
this.parent = this.child.parent
// Child side is controlled by what parent sends in handshake; default false.
this.enableMessageChannel = false
}
sendHandshakeReply() {
return new Promise((resolve, reject) => {
const shake = (e: MessageEvent<any>) => {
if (!e.data.postmate) {
return
}
if (e.data.postmate === 'handshake') {
if (process.env.NODE_ENV !== 'production') {
log('Child: Received handshake from Parent')
}
this.child.removeEventListener('message', shake, false)
// Check if parent supports MessageChannel (but don't use it yet during handshake)
this.enableMessageChannel = !!e.data?.enableMessageChannel
this.parentOrigin = e.origin
if (process.env.NODE_ENV !== 'production') {
log('Child: Sending handshake reply to Parent')
}
const runtimeSupportsMessageChannel =
typeof MessageChannel !== 'undefined' &&
typeof (MessageChannel as any) === 'function'
// Reply using window.postMessage for backward compatibility
// Signal whether child accepts MessageChannel for future communication
;(e.source as WindowProxy).postMessage(
{
postmate: 'handshake-reply',
type: messageType,
// Tell parent if child can accept MessageChannel
acceptsMessageChannel: this.enableMessageChannel && runtimeSupportsMessageChannel ? 1 : 0,
},
e.origin === 'null' ? '*' : e.origin
)
// Extend model with the one provided by the parent
const defaults = e.data.model
if (defaults) {
Object.keys(defaults).forEach((key) => {
this.model[key] = defaults[key]
})
if (process.env.NODE_ENV !== 'production') {
log('Child: Inherited and extended model from Parent')
}
}
// Set up listener for MessageChannel port if both sides support it
if (this.enableMessageChannel && runtimeSupportsMessageChannel) {
const setupChannel = (setupEvent: MessageEvent<any>) => {
if (
setupEvent.data?.postmate === 'setup-channel' &&
setupEvent.data?.type === messageType &&
setupEvent.origin === this.parentOrigin
) {
const transferredPort = setupEvent?.ports?.[0]
if (transferredPort) {
this.messagePort = transferredPort
;(this.messagePort as any).start?.()
if (process.env.NODE_ENV !== 'production') {
log('Child: Received and activated MessageChannel port from parent')
}
// Important: Notify the ChildAPI instance (created below) to switch transport
// We'll emit a custom event that ChildAPI can listen to
this.child.dispatchEvent(new CustomEvent('postmate:channel-ready', {
detail: { port: this.messagePort }
}))
}
this.child.removeEventListener('message', setupChannel, false)
}
}
this.child.addEventListener('message', setupChannel, false)
}
if (process.env.NODE_ENV !== 'production') {
log('Child: Saving Parent origin', this.parentOrigin)
}
return resolve(new ChildAPI(this))
}
return reject('Handshake Reply Failed')
}
this.child.addEventListener('message', shake, false)
})
}
}