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:
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 }
|
||||
Reference in New Issue
Block a user