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:
456
libs/src/LSPlugin.caller.ts
Normal file
456
libs/src/LSPlugin.caller.ts
Normal 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
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
112
libs/src/LSPlugin.shadow.ts
Normal 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
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
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
12
libs/src/callable.apis.ts
Normal 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
630
libs/src/common.ts
Normal 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()
|
||||
}
|
||||
283
libs/src/modules/LSPlugin.Experiments.ts
Normal file
283
libs/src/modules/LSPlugin.Experiments.ts
Normal 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
|
||||
}
|
||||
}
|
||||
670
libs/src/modules/LSPlugin.Net.ts
Normal file
670
libs/src/modules/LSPlugin.Net.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
79
libs/src/modules/LSPlugin.Search.ts
Normal file
79
libs/src/modules/LSPlugin.Search.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
101
libs/src/modules/LSPlugin.Storage.ts
Normal file
101
libs/src/modules/LSPlugin.Storage.ts
Normal 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
641
libs/src/postmate/index.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user